<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ol_minje.log</title>
        <link>https://velog.io/</link>
        <description>큐트걸</description>
        <lastBuildDate>Fri, 06 Mar 2026 10:24:25 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ol_minje.log</title>
            <url>https://velog.velcdn.com/images/ol_minje/profile/6fd85750-078b-4292-9e7f-2c33baeaefa9/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ol_minje.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ol_minje" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[바이브코딩으로 작은 웹사이트를 만들었다]]></title>
            <link>https://velog.io/@ol_minje/%EB%B0%94%EC%9D%B4%EB%B8%8C%EC%BD%94%EB%94%A9%EC%9C%BC%EB%A1%9C-%EC%9E%91%EC%9D%80-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@ol_minje/%EB%B0%94%EC%9D%B4%EB%B8%8C%EC%BD%94%EB%94%A9%EC%9C%BC%EB%A1%9C-%EC%9E%91%EC%9D%80-%EC%9B%B9%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Fri, 06 Mar 2026 10:24:25 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>개발자 도구부터 연습용 사이트, 가볍게 써보는 생성기까지 한 번에 정리</p>
</blockquote>
<p>최근 바이브코딩으로 작은 웹사이트들을 연달아 만들고 있다.</p>
<p>처음에는 단순히 “빨리 만들 수 있는지”를 확인하는 실험에 가까웠는데, 몇 개를 공개해보니 생각보다 방향이 분명해졌다. 큰 서비스 하나를 오래 붙잡는 방식보다, 실제로 필요한 작은 문제를 빠르게 풀고 바로 공개한 뒤 피드백으로 다듬는 방식이 지금은 더 잘 맞았다.</p>
<p>이번 글에서는 지금까지 공개한 웹사이트 14개를 한 번에 정리해보려고 한다.</p>
<p>내가 이 프로젝트들을 만들 때 기준은 단순했다.</p>
<p>첫째, 들어가자마자 설명 없이 바로 써볼 수 있을 것.<br>둘째, 특정 상황에서 다시 떠오를 만큼 목적이 분명할 것.<br>셋째, 완벽하지 않더라도 지금 당장 도움이 될 것.</p>
<p>이 기준으로 만들다 보니 자연스럽게 세 가지 종류로 나뉘었다.<br>개발하면서 바로 쓰는 도구, 짧게 연습해볼 수 있는 사이트, 그리고 가볍게 써보는 생성기나 허브형 프로젝트다.</p>
<h2 id="1-개발하면서-바로-쓰는-도구들">1. 개발하면서 바로 쓰는 도구들</h2>
<p>가장 먼저 만든 축은 개발 도구 쪽이다.</p>
<p>JSON Fixer는 깨진 JSON을 직접 고치면서 감을 익히는 연습용 사이트다.<br>실무에서는 문법 오류 하나 때문에 흐름이 끊기는 경우가 많아서, 이런 문제를 빠르게 눈에 익히는 데 초점을 맞췄다.</p>
<p>SQL Quiz는 SQL 쿼리 결과를 바로 실행하기 전에 먼저 예측해보는 방식의 퀴즈다.<br>SELECT, JOIN, GROUP BY 같은 구문은 “읽을 때 이해한 것”과 “실제로 결과를 맞히는 것” 사이에 차이가 있어서, 그 간극을 줄이는 용도로 만들었다.</p>
<p>Regex Playground는 정규식을 직접 넣고 테스트해볼 수 있는 연습 도구다.<br>정규식은 결국 손으로 많이 만져볼수록 익숙해지는 영역이라, 검색과 복붙 대신 직접 실험하는 흐름을 만들고 싶었다.</p>
<p>Tailwind Preview는 Tailwind CSS 클래스를 넣으면 결과를 빠르게 미리보는 도구다.<br>클래스 조합을 머릿속으로만 상상하지 않고 바로 확인할 수 있게 해두면 UI 프로토타이핑 속도가 꽤 빨라진다.</p>
<p>JSON to TypeScript 변환기는 JSON 데이터를 넣으면 TypeScript interface 초안을 만들 수 있게 한 도구다.<br>프론트엔드에서 API 응답을 타입으로 옮길 때 반복되는 작업을 줄이기 위해 만들었다.</p>
<p>Dev Toolbox는 개발하면서 자주 찾게 되는 작은 유틸리티를 모아둔 사이트다.<br>작은 기능 하나 때문에 검색 탭이 계속 늘어나는 게 번거로워서, 자주 쓰는 것들을 한 번에 묶어보는 방향으로 만들었다.</p>
<p>Utility Tools Hub는 개발자와 크리에이터가 두루 쓸 수 있는 온라인 도구를 모아본 허브다.<br>특정 기능 하나보다는 “어디서부터 시작할지 모를 때 들어가는 출발점”에 더 가깝다.</p>
<p>AI Prompt Generator는 작업 목적에 맞는 프롬프트 초안을 빠르게 만들어보는 도구다.<br>프롬프트를 매번 처음부터 쓰는 대신, 시작점을 빨리 잡는 데 의미를 두고 만들었다.</p>
<h2 id="2-짧게-연습해볼-수-있는-사이트들">2. 짧게 연습해볼 수 있는 사이트들</h2>
<p>두 번째 축은 연습형 사이트다.</p>
<p>티켓팅 보안문자 연습은 공연이나 콘서트 예매 전에 CAPTCHA 입력 감각을 빠르게 올려보는 용도로 만들었다.<br>티켓팅에서 보안문자는 의외로 체감 스트레스가 큰 요소라서, 이 부분만 따로 연습해볼 수 있게 해보고 싶었다.</p>
<p>Typing Speed Test는 가장 단순하게 타자 속도를 측정할 수 있는 사이트다.<br>복잡한 기능보다 “들어가서 바로 시작하고 바로 결과를 확인한다”는 흐름 자체에 집중했다.</p>
<p>이 두 프로젝트는 공통적으로 길게 설명할 필요가 없다.<br>짧게 들어갔다가 바로 써보고, 바로 결과를 얻고, 필요하면 다시 돌아오는 구조가 핵심이다.</p>
<h2 id="3-가볍게-써보는-생성기와-허브들">3. 가볍게 써보는 생성기와 허브들</h2>
<p>마지막은 비교적 가볍게 접근할 수 있는 사이트들이다.</p>
<p>랜덤 닉네임 생성기는 이름을 정해야 할 때 빠르게 후보를 얻는 용도로 만들었다.<br>생각보다 닉네임 하나 정하는 데 시간이 오래 걸릴 때가 많아서, 부담 없이 돌려보는 도구가 있으면 좋겠다고 생각했다.</p>
<p>한줄소개 생성기는 포트폴리오나 자기소개 첫 문장을 잡는 데 도움을 주기 위해 만들었다.<br>특히 취업 준비나 이직 준비 과정에서는 “첫 문장” 하나가 가장 막막한 경우가 많다.</p>
<p>Smart Calculator Hub는 다양한 온라인 계산기를 한데 모아둔 사이트다.<br>계산할 때마다 검색해서 다른 사이트를 전전하는 대신, 자주 쓰는 계산기를 한 곳에서 해결할 수 있도록 구성했다.</p>
<p>Fun Simulator Hub는 말 그대로 재미있는 랜덤 시뮬레이터를 모아둔 공간이다.<br>생산성보다는 가볍게 들어가서 한 번씩 돌려보는 재미에 더 가깝다.</p>
<h2 id="만들면서-느낀-점">만들면서 느낀 점</h2>
<p>이 14개를 만들면서 가장 크게 느낀 건, 사람들이 거대한 기능보다도 “지금 바로 써볼 수 있는 작은 완성”에 더 빨리 반응한다는 점이었다.</p>
<p>서비스를 만드는 입장에서도 마찬가지였다.<br>큰 아이디어를 오래 품고 있는 것보다, 작은 문제를 분명하게 정의하고 빠르게 구현해보는 쪽이 훨씬 많은 걸 남겼다.<br>무엇이 실제로 쓰이는지, 어디서 이탈하는지, 어떤 도구는 다시 찾게 되고 어떤 도구는 한 번 보고 끝나는지가 더 선명하게 보였다.</p>
<p>그래서 앞으로는 무조건 개수를 늘리는 방향보다는, 반응이 좋았던 몇 개를 골라 더 깊게 다듬어보려고 한다.<br>결국 중요한 건 “만들었다”가 아니라 “다시 방문할 이유가 있는가”라고 생각한다.</p>
<p>아직은 작은 프로젝트들이지만, 직접 써보고 피드백을 받을수록 더 나아질 수 있다고 본다.<br>어떤 사이트가 가장 유용했는지, 어디가 불편했는지, 어떤 기능이 추가되면 좋을지 의견을 남겨주면 다음 개선에 반영해보겠다.</p>
<h2 id="링크-모음">링크 모음</h2>
<ul>
<li><p>티켓팅 보안문자 연습<br><a href="https://ticketing-practice.lovable.app/">https://ticketing-practice.lovable.app/</a></p>
</li>
<li><p>Typing Speed Test<br><a href="https://typing-speed-letsgo.lovable.app/">https://typing-speed-letsgo.lovable.app/</a></p>
</li>
<li><p>JSON Fixer<br><a href="https://j-fix-hero.lovable.app/">https://j-fix-hero.lovable.app/</a></p>
</li>
<li><p>SQL Quiz<br><a href="https://sql-guess-game.lovable.app/">https://sql-guess-game.lovable.app/</a></p>
</li>
<li><p>Regex Playground<br><a href="https://regex-playground-buddy.lovable.app/">https://regex-playground-buddy.lovable.app/</a></p>
</li>
<li><p>랜덤 닉네임 생성기<br><a href="https://nickname-create.lovable.app/">https://nickname-create.lovable.app/</a></p>
</li>
<li><p>한줄소개 생성기<br><a href="https://self-introduction-generator.lovable.app/">https://self-introduction-generator.lovable.app/</a></p>
</li>
<li><p>Tailwind Preview<br><a href="https://tailwind-peek-studio.lovable.app/">https://tailwind-peek-studio.lovable.app/</a></p>
</li>
<li><p>JSON to TypeScript 변환기<br><a href="https://swift-json-types.lovable.app/">https://swift-json-types.lovable.app/</a></p>
</li>
<li><p>Dev Toolbox<br><a href="https://dev-tools-utills.lovable.app/">https://dev-tools-utills.lovable.app/</a></p>
</li>
<li><p>Smart Calculator Hub<br><a href="https://fav-calculators.lovable.app/">https://fav-calculators.lovable.app/</a></p>
</li>
<li><p>Utility Tools Hub<br><a href="https://maker-tool-spot.lovable.app/">https://maker-tool-spot.lovable.app/</a></p>
</li>
<li><p>AI Prompt Generator<br><a href="https://promptexplorer-ai.lovable.app/">https://promptexplorer-ai.lovable.app/</a></p>
</li>
<li><p>Fun Simulator Hub<br><a href="https://fun-random-lab.lovable.app/">https://fun-random-lab.lovable.app/</a></p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Compound Component 패턴과 Context API의 연관성]]></title>
            <link>https://velog.io/@ol_minje/Compound-Component-%ED%8C%A8%ED%84%B4%EA%B3%BC-Context-API%EC%9D%98-%EC%97%B0%EA%B4%80%EC%84%B1</link>
            <guid>https://velog.io/@ol_minje/Compound-Component-%ED%8C%A8%ED%84%B4%EA%B3%BC-Context-API%EC%9D%98-%EC%97%B0%EA%B4%80%EC%84%B1</guid>
            <pubDate>Tue, 30 Dec 2025 11:55:04 GMT</pubDate>
            <description><![CDATA[<p>최근 <code>Tabs</code>나 <code>Calendar</code>와 같은 복잡한 UI를 라이브러리 없이 순수 컴포넌트로 직접 구현하면서, Shadcn UI나 Radix UI가 왜 그렇게 코드를 짰는지 이해했다. 특히 <strong>Compound Component(복합 컴포넌트) 패턴</strong>을 직접 적용해 보니, 이게 왜 <strong>Context API</strong>랑 찰떡궁합인지 알아갔으며, 이에대해 알게된 내용을 정리해본다.</p>
<h2 id="1-compound-component-패턴이란">1. Compound Component 패턴이란?</h2>
<p><strong>Compound Component 패턴</strong>은 하나의 작업을 수행하기 위해 여러 개의 하위 컴포넌트들이 협력하여 하나의 단위로 동작하는 설계 방식이다.</p>
<p>이해한 대로 비유하자면 ‘조립식 장난감’ 같다. 부모 컴포넌트가 &quot;넌 이렇게 생겨야 해!&quot;라고 모든 걸 정해주는 게 아니라, 사용자가 필요한 부품(하위 컴포넌트)을 골라 원하는 위치에 끼워 맞추면서 전체 기능을 완성하는 식!</p>
<h3 id="❌-패턴-적용-전-props-중심">❌ 패턴 적용 전 (Props 중심)</h3>
<p>부모 컴포넌트에 수많은 Props를 전달해야 하며, 내부 구조를 변경하기 어렵다.</p>
<pre><code class="language-jsx">&lt;Tabs 
  items={items} 
  defaultValue=&quot;tab1&quot; 
  onTabChange={...} 
  tabListClassName=&quot;...&quot;
/&gt;</code></pre>
<h3 id="✅-패턴-적용-후-구조-중심">✅ 패턴 적용 후 (구조 중심)</h3>
<p>각 역할이 명확히 분리된 하위 컴포넌트를 조합할 수 있다. 직관적 미쳤음</p>
<pre><code class="language-jsx">&lt;TabsRoot defaultValue=&quot;tab1&quot;&gt;
  &lt;TabsList&gt;
    &lt;TabsTrigger value=&quot;tab1&quot;&gt;탭1&lt;/TabsTrigger&gt;
  &lt;/TabsList&gt;
  &lt;TabsContent value=&quot;tab1&quot;&gt;내용1&lt;/TabsContent&gt;
&lt;/TabsRoot&gt;</code></pre>
<hr>
<h2 id="2-compound-component와-context-api의-연관성">2. Compound Component와 Context API의 연관성</h2>
<p>Compound 패턴을 만들 때 Context API는 부품들을 연결해 주는 역할을 해준다. (like 블루투스..? 무선..?)</p>
<h3 id="1-명시적인-props-전달-제거-props-drilling-방지">1) 명시적인 Props 전달 제거 (Props Drilling 방지)</h3>
<p><code>TabsRoot</code>가 가진 <code>activeTab</code> 상태를 <code>TabsTrigger</code>나 <code>TabsContent</code>가 알기 위해서는 원래 부모를 거쳐 Props로 전달받아야 한다. 
하지만 Context API를 사용하면 하위 컴포넌트들이 어디에 위치하든 상관없이 필요한 상태에 직접 접근할 수 있게 된다.</p>
<h3 id="2-유연한-레이아웃-구성">2) 유연한 레이아웃 구성</h3>
<p>Context API 덕분에 하위 컴포넌트의 순서나 중첩 구조가 자유로워졌다.
<code>TabsList</code> 안에 <code>TabsTrigger</code>를 넣든, 별도의 <code>div</code>로 감싸든 상관없이 Context를 통해 상태를 공유하기 때문에 UI 배치가 자유롭다.</p>
<h3 id="3-캡슐화와-사용성-향상">3) 캡슐화와 사용성 향상</h3>
<p>탭 전환 함수나 현재 활성화된 값 같은 복잡한 로직은 Context 안으로 싹 숨겨버리고(캡슐화), 사용자에게는 필요한 컴포넌트만 딱 보여줄 수 있다. 쓰는 사람 입장에서는 복잡한 계산 없이 UI를 선언적으로 툭툭 가져다 쓰기만 하면 되는!</p>
<hr>
<p>직접 구현해 보니 <strong>Compound Component가 ‘구조적인 자유’를 준다면, Context API는 그 자유 속에서도 ‘데이터의 일관성’을 꽉 잡아주는 느낌</strong>이었다. 라이브러리 없이 순수 컴포넌트를 만들 때 이 조합은 정말 강력하고 괜찮은 선택인 것 같음!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 10. 최적화와 배포]]></title>
            <link>https://velog.io/@ol_minje/Section-10.-%EC%B5%9C%EC%A0%81%ED%99%94%EC%99%80-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@ol_minje/Section-10.-%EC%B5%9C%EC%A0%81%ED%99%94%EC%99%80-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Thu, 23 Oct 2025 08:21:00 GMT</pubDate>
            <description><![CDATA[<h2 id="1-이미지-최적화image-optimization">1. 이미지 최적화(Image Optimization)</h2>
<p>이미지는 웹 성능에서 생각보다 큰 비중을 차지한다.</p>
<blockquote>
<h3 id="대표적인-이미지-최적화-기법">대표적인 이미지 최적화 기법</h3>
</blockquote>
<ul>
<li>WebP, AVIF 등 차세대 포맷으로 변환</li>
<li>디바이스 해상도에 맞는 이미지 제공</li>
<li>Lazy Loading 적용 (보이는 순간에만 로드)</li>
<li>Blur Placeholder로 깔끔한 로딩 연출</li>
</ul>
<p>Next.js는 성능 향상을 위해 Image 컴포넌트를 제공한다.</p>
<blockquote>
<p>즉, 단순히 <code>&lt;img&gt;</code> 대신 <code>&lt;Image&gt;</code>를 사용하기만 해도
자동으로 리사이징, 포맷 변환, Lazy Loading, CDN 캐싱이 적용된다.
이미지 최적화에는 다양한 기법이 존재한다.</p>
</blockquote>
<pre><code class="language-tsx">import Image from &#39;next/image&#39;;
//...
export default function BookItem({
  id,
  title,
  subTitle,
  description,
  author,
  publisher,
  coverImgUrl,
}: BookData) {
  return (
    &lt;Link href={`/book/${id}`} className={style.container}&gt;
      &lt;Image src={coverImgUrl} width={80} height={105} alt={`도서 ${title}의 표시 이미지`} /&gt;
      &lt;div&gt;
        &lt;div className={style.title}&gt;{title}&lt;/div&gt;
        &lt;div className={style.subTitle}&gt;{subTitle}&lt;/div&gt;
        &lt;br /&gt;
        &lt;div className={style.author}&gt;
          {author} | {publisher}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/Link&gt;
  );
}
</code></pre>
<hr>
<h3 id="⚠️-invalid-src-prop-오류-해결하기">⚠️ <code>Invalid src prop</code> 오류 해결하기</h3>
<p>Next.js의 <code>Image</code>는 보안상 외부 도메인의 이미지를 기본적으로 차단한다.
그래서 외부 이미지를 사용하려면 next.config.js에 허용 도메인을 명시해야 한다.</p>
<pre><code class="language-jsx">import type { NextConfig } from &#39;next&#39;;

const nextConfig: NextConfig = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
  images: {
    domains: [&#39;shopping-phinf.pstatic.net&#39;], // 외부 이미지 도메인 등록
  },
};

export default nextConfig;</code></pre>
<p>설정 후 빌드하면, Next.js가 자동으로 해당 이미지를 프록시 캐싱 및 최적화해서 제공해준다!</p>
<hr>
<h2 id="seo-search-engine-optimization--검색-엔진-최적화">SEO (Search Engine Optimization) — 검색 엔진 최적화</h2>
<p>Next.js는 SEO를 위해 <code>metadata</code>라는 정식 속성을 제공한다.</p>
<blockquote>
<p>페이지의 <strong>제목(title), 설명(description), OG 태그(Open Graph)</strong> 등을 손쉽게 설정할 수 있음</p>
</blockquote>
<hr>
<h3 id="기본-메타데이터-설정하기">기본 메타데이터 설정하기</h3>
<p>검색된 약속어 <code>metadata</code>를 사용하여 설정을 진행하면,</p>
<pre><code class="language-jsx">export const metadata: Metadata = {
  title: &#39;한입 북스&#39;,
  description: &#39;한입 북스에 등록된 도서를 만나보세요.&#39;,
  openGraph: {
    title: &#39;한입 북스&#39;,
    description: &#39;한입 북스에 등록된 도서를 만나보세요.&#39;,
    images: [&#39;/thumbnail.png&#39;],
  },
};</code></pre>
<p>페이지가 검색 엔진에 더 잘 노출괴도, SNS 공유 시 썸네일이 깔끔하게 표시된다.</p>
<hr>
<h3 id="동적인-메타-데이터-설정하기">동적인 메타 데이터 설정하기</h3>
<p>Next.js는 정적인 메타데이터뿐만 아니라,
페이지별 데이터에 따라 동적으로 메타정보를 설정할 수 있게 해준다.</p>
<pre><code class="language-jsx">export async function generateMetadata({
  searchParams,
}: {
  searchParams: Promise&lt;{ q?: string }&gt;;
}) {
  // 현재 페이지의 메타 데이터를 동적으로 생성하는 역할
  const { q } = await searchParams;

  return {
    title: `${q}: 한입북스 검색`,
    description: `${q}의 검색 결과입니다.`,
    openGraph: {
      title: `${q}: 한입북스 검색`,
      description: `${q}의 검색 결과입니다.`,
      images: [&#39;/thumbnail.png&#39;],
    },
  };
}

export default async function Page({ searchParams }: { searchParams: Promise&lt;{ q?: string }&gt; }) {
  const { q } = await searchParams;</code></pre>
<blockquote>
<p>이렇게 하면 검색 결과 페이지의 쿼리(<code>q</code>) 값에 따라
브라우저 탭 제목, 설명, OG 메타데이터가 동적으로 변한다.</p>
</blockquote>
<hr>
<h3 id="api-기반-메타데이터-생성">API 기반 메타데이터 생성</h3>
<p>도서 상세 페이지의 경우, 책 정보를 API로 불러와서 그 데이터를 기반으로 SEO 메타를 생성할 수 있다. (따봉)</p>
<pre><code class="language-jsx">export async function generateMetadata({ params }: { params: Promise&lt;{ id: string }&gt; }) {
  const { id } = await params;

  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/${id}`);

  if (!response.ok) {
    throw new Error(response.statusText);
  }

  const book: BookData = await response.json();

  return {
    title: `${book.title} - 한입북스`,
    description: `${book.description}`,
    openGraph: {
      title: `${book.title} - 한입북스`,
      description: `${book.description}`,
      images: [book.coverImgUrl],
    },
  };
}

export default async function Page({ params }: { params: Promise&lt;{ id: string }&gt; }) {
  const { id } = await params;
//...</code></pre>
<blockquote>
<p>✅ API를 메타데이터 내부에서 직접 호출해도 안전한 이유는? <strong>Request Memoization</strong></p>
</blockquote>
<p>같은 페이지 내에서 동일한 API를 여러 번 호출하더라도 자동으로 한 번만 요청하고 캐싱해주는 아름다운 친구,
덕분에 <code>generateMetadate()</code>와 실제 페이지 렌더링에서 같은 데이터를 불러와도 불필요한 <strong>중복 요청이 발생하지 않는다.</strong></p>
<hr>
<h2 id="✅-정리하며">✅ 정리하며</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Image 컴포넌트</td>
<td>이미지 최적화</td>
<td>WebP 변환, 리사이징, Lazy Loading 자동 적용</td>
</tr>
<tr>
<td>next.config.js</td>
<td>외부 이미지 도메인 등록</td>
<td>외부 리소스 접근 허용</td>
</tr>
<tr>
<td>Metadata</td>
<td>SEO 설정</td>
<td>페이지의 메타 정보 정의</td>
</tr>
<tr>
<td>generateMetadata</td>
<td>동적 SEO</td>
<td>검색어·데이터 기반 메타데이터 자동 생성</td>
</tr>
<tr>
<td>Request Memoization</td>
<td>API 중복 방지</td>
<td>동일 요청을 한 번만 수행, 자동 캐싱</td>
</tr>
</tbody></table>
<p><a href="https://onebite-books-page-eta-flax.vercel.app/">https://onebite-books-page-eta-flax.vercel.app/</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 9. 고급 라우팅 패턴]]></title>
            <link>https://velog.io/@ol_minje/Section-9.-%EA%B3%A0%EA%B8%89-%EB%9D%BC%EC%9A%B0%ED%8C%85-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@ol_minje/Section-9.-%EA%B3%A0%EA%B8%89-%EB%9D%BC%EC%9A%B0%ED%8C%85-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Wed, 22 Oct 2025 10:55:30 GMT</pubDate>
            <description><![CDATA[<h1 id="parallel-route-병렬-라우트">Parallel Route (병렬 라우트)</h1>
<blockquote>
<p>하나의 화면에 <strong>여러 개의 페이지 컴포넌트들를 동시</strong>에 렌더링하는 패턴</p>
</blockquote>
<p><a href="https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering">공식문서</a></p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/390b9104-11e9-4509-ba1a-f778792fdc93/image.png" alt="Parallel 폴더 구조"></p>
<p>Parallel Route는 <code>@슬롯이름</code> 폴더(= Slot)를 만들어 쓰고,
이 슬롯들은 부모 레이아웃의 <strong>props로 주입</strong>된다.</p>
<p>예를 들어 <code>@sidebar</code> 슬롯이 있다면 레이아웃에서는 이렇게 받는다:</p>
<pre><code>export default function Layout({
  children,
  sidebar,
}: {
  children: React.ReactNode;
  sidebar: React.ReactNode;
}) {
  return (
    &lt;div&gt;
      {sidebar}
      {children}
    &lt;/div&gt;
  );
}</code></pre><blockquote>
<p><strong>💡 Slot은 URL에 영향을 주지 않는다.</strong>
Route Group처럼 경로에는 나타나지 않고, 레이아웃에 “추가로 끼워 넣을 페이지”를 전달하는 방식으로,
<code>children</code>은 기본 페이지 슬롯이므로 따로 폴더가 없어도 자동으로 주입된다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/51e380e3-c215-4145-af1f-2f8b6614e6ae/image.png" alt="Parallel 적용 결과 화면"></p>
<p>이처럼 Next의 페럴렐은 여러 개의 페이지 컴포넌트를 하나의 화면에 병렬로 렌더링 시켜주는 기능이다.</p>
<hr>
<h2 id="slot-안에-새로운-페이지-생성하기">Slot 안에 새로운 페이지 생성하기</h2>
<p><code>parallel/@feed/setting/page.tsx</code></p>
<pre><code class="language-jsx">export default function Page() {
  return &lt;div&gt;@feed/setting&lt;/div&gt;;
}</code></pre>
<p>부모 레이아웃에서 여러 슬롯을 동시에 받으면 이렇게 적용하면 된다. </p>
<pre><code class="language-jsx">import Link from &#39;next/link&#39;;

export default function Layout({
  children,
  sidebar,
  feed,
}: {
  children: React.ReactNode;
  sidebar: React.ReactNode;
  feed: React.ReactNode;
}) {
  return (
    &lt;div&gt;
      &lt;div&gt;
        &lt;Link href={&#39;/parallel&#39;}&gt;parallel&lt;/Link&gt;
        &amp;nbsp;
        &lt;Link href={&#39;/parallel/setting&#39;}&gt;parallel/setting&lt;/Link&gt;
      &lt;/div&gt;
      {sidebar}
      {children}
      {feed}
    &lt;/div&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/e7bf05ab-efa3-4ad1-8201-da01373a7a36/image.gif" alt="parallel setting"></p>
<p>슬롯 내의 페이지로 이동할 때 <code>layout.tsx</code>의 props의 값은 어떻게 될까?</p>
<hr>
<h3 id="슬롯-전환-시-동작-포인트">슬롯 전환 시 동작 포인트!</h3>
<ul>
<li><code>feed</code> 슬롯의 경우 하위 페위지가 존재하므로 해당 UI가 렌더링된다</li>
<li><code>sidebar</code> 슬롯에 <strong>해당 경로의 페이지가 없으면 <code>404</code></strong>가 될 수 있는데, <strong>&quot;직전 슬롯 렌더링 결과&quot;</strong>를 재사용해 깜빡임을 줄여준다.
이는 클라이언트 네비게이션(<code>Link</code>, <code>push</code>)에만 해당되며, <strong>새로고침의 경우 초기 렌더링이므로 이전 결과가 없어 <code>404</code></strong>가 보일 수 있다.</li>
</ul>
<hr>
<h2 id="defaulttsx"><code>default.tsx</code></h2>
<blockquote>
<p><strong>슬롯의 안전망</strong>
Slot에 매칭되는 페이지가 없을 대 보여줄 기본 UI를 제공하는 파일이다.</p>
</blockquote>
<pre><code class="language-jsx">export default function Default() {
  return &lt;div&gt;/parallel/default&lt;/div&gt;;
}</code></pre>
<p>이렇게 하면 네비게이션/새로고침 상관없이 <strong>항상 슬롯 자리를 안전하게 채워준다</strong>.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/17febd40-9571-4ee2-b76c-3760126d42e9/image.gif" alt="parallel default"></p>
<hr>
<h1 id="intercepting-route인터셉팅-라우트">Intercepting Route(인터셉팅 라우트)</h1>
<blockquote>
<p><strong>&quot;같은 경로를 가더라도, 상황에 따라 다른 UI로 가로채기&quot;</strong>
동일한 경로에 접속하더라도 특정 조건을 만족하면, 그때는 원래 페이지가 아닌 다른 페이지를 렌더링 하도록 설정하는 기능</p>
</blockquote>
<hr>
<h2 id="📌-폴더명-규칙-요약">📌 폴더명 규칙 요약</h2>
<ul>
<li><code>(.)</code> : 동일 수준 경로의 페이지를 가로챔</li>
<li><code>(..)</code> : 한 단계 상위 경로 기준으로 탐색</li>
<li><code>(..)(..)</code> : 두 단계 상위 …</li>
<li><code>(...)</code> : app 바로 아래 기준</li>
</ul>
<pre><code class="language-jsx">// app/(.)book/[id]/page.tsx
export default function Page() {
  return &lt;div&gt;가로채기 성공!&lt;/div&gt;;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/8ab74a27-f256-457c-a54e-3ceb3661f3f1/image.gif" alt="Intercepting Route"></p>
<p>도서 상세를 불러오기 위해 기존에 사용하던 페이지의 컴포넌트를 <code>import</code> 해주고, <code>props</code>를 전달해준다.
이때 <code>props</code>는 인터셉터 과정에서 똑같이 전달되기 때문에 그대로 전달해준다.</p>
<pre><code class="language-jsx">import BookPage from &#39;@/app/book/[id]/page&#39;;

export default function Page(props: any) {
  return (
    &lt;div&gt;
      가로채기 성공!
      &lt;BookPage {...props} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<hr>
<h2 id="모달로-띄우기">모달로 띄우기</h2>
<pre><code class="language-jsx">&#39;use client&#39;;

import style from &#39;@/components/modal.module.css&#39;;
import { useRouter } from &#39;next/navigation&#39;;
import { useEffect, useRef } from &#39;react&#39;;
import { createPortal } from &#39;react-dom&#39;;

export default function Modal({ children }: { children: React.ReactNode }) {
  const dialogRef = useRef&lt;HTMLDialogElement&gt;(null);
  const router = useRouter();

  useEffect(() =&gt; {
    if (!dialogRef.current?.open) {
      dialogRef.current?.showModal();
      dialogRef.current?.scrollTo({
        top: 0,
      });
    }
  }, []);

  return createPortal(
    &lt;dialog
      onClose={() =&gt; router.back()}
      onClick={(e) =&gt; {
        // 모달의 배경이 클릭된거면 뒤로가기
        if ((e.target as any).nodeName === &#39;DIALOG&#39;) {
          router.back();
        }
      }}
      ref={dialogRef}
      className={style.modal}
    &gt;
      {children}
    &lt;/dialog&gt;,
    document.getElementById(&#39;modal-root&#39;) as HTMLElement
  );
}</code></pre>
<p>루트 레이아웃에 아래와 같이 모달 요소를 작성한다.</p>
<pre><code class="language-jsx">//...
export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;
        &lt;div className={style.container}&gt;
          &lt;header&gt;
            &lt;Link href={&#39;/&#39;}&gt;📚 ONEBITE BOOKS&lt;/Link&gt;
          &lt;/header&gt;
          &lt;main&gt;{children}&lt;/main&gt;
          &lt;Footer /&gt;
        &lt;/div&gt;
      &lt;/body&gt;
      &lt;div id=&quot;modal-root&quot;&gt;&lt;/div&gt;
    &lt;/html&gt;
  );
}</code></pre>
<hr>
<h2 id="intercepting--parallel-route-modal-슬롯">Intercepting &amp; Parallel Route: <code>@modal</code> 슬롯</h2>
<p>도서 리스트는 그대로 두고, 상세는 모달 슬롯으로 병렬 렌더링하기!</p>
<pre><code class="language-jsx">// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: Readonly&lt;{
  children: React.ReactNode;
  modal: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;body&gt;
        &lt;div className={style.container}&gt;
          &lt;header&gt;
            &lt;Link href={&#39;/&#39;}&gt;📚 ONEBITE BOOKS&lt;/Link&gt;
          &lt;/header&gt;
          &lt;main&gt;{children}&lt;/main&gt;
          &lt;Footer /&gt;
        &lt;/div&gt;
        {modal}
        &lt;div id=&quot;modal-root&quot;&gt;&lt;/div&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
<p><code>app/@modal/(.)book/[id]/page.tsx</code>에 작업한 도서 상세 슬롯을 옮겨서, 도서 상세 모달 뒷 배경으로 북 리스트가 보이는 메인 페이지가 보이도록 해보자.</p>
<p>그리고 <code>@modal</code> 경로에 <code>default.tsx</code> 페이지도 함께 만들어준다!</p>
<blockquote>
<p><code>(.)</code>로 <code>book/[id]</code>를 가로채고, 모달 슬롯에 렌더링하기</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/2141650c-9007-477c-8bbd-da466802b33a/image.gif" alt="Intercepting &amp; Parallel Route"></p>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li>Parallel Route: 여러 페이지를 동시에 렌더링 (Slot은 레이아웃 props로 전달됨)</li>
<li><code>default.tsx</code>: 슬롯에 페이지가 없을 때 기본 UI 제공</li>
<li>Intercepting Route: 같은 경로라도 클라이언트 네비게이션이면 가로채어 다른 UI(예: 모달)로 렌더링</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 8. Server Action]]></title>
            <link>https://velog.io/@ol_minje/Section-8.-Server-Action</link>
            <guid>https://velog.io/@ol_minje/Section-8.-Server-Action</guid>
            <pubDate>Tue, 21 Oct 2025 12:07:20 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="server-action">Server Action</h1>
<p><em>클라이언트에서 서버 함수를 직접 호출한다!</em></p>
<blockquote>
<p><strong>Server Action이란?</strong>
Server Action은 브라우저에서 직접 호출할 수 있는, <strong>서버에서만 실행되는 비동기 함수</strong>이다.</p>
</blockquote>
<p>Next.js의 서버 액션은 클라이언트에서 특정 <code>Form</code>이 제출될 때, 그 이벤트를 <strong>서버 함수로 바로 연결해주는 기능</strong>을 제공한다.
즉, API 라우트를 따로 만들 필요 없이 단 한 줄의 자바스크립트 함수로 폼 데이터를 서버에서 처리할 수 있다.</p>
<pre><code class="language-tsx">function ReviewEditor() {
  async function createReviewAction(formData: FormData) {
    &#39;use server&#39;;
    console.log(&#39;server action called&#39;);
  }

  return (
    &lt;section&gt;
      &lt;form action={createReviewAction}&gt;
        &lt;input name=&quot;content&quot; placeholder=&quot;리뷰 내용&quot; /&gt;
        &lt;input name=&quot;author&quot; placeholder=&quot;작성자&quot; /&gt;
        &lt;button type=&quot;submit&quot;&gt;작성하기&lt;/button&gt;
      &lt;/form&gt;
    &lt;/section&gt;
  );
}</code></pre>
<p>폼이 제출되면 브라우저가 서버 액션 함수를 직접 호출한다.
이떄, <strong>입력한 값들은 자동으로 FormData 객체로 전달</strong>된다.
이 방식은 오직 자바스크립트 함수 하나만으로 쉽고 간결하게 설정할 수 있다는 강점을 갖고 있다.</p>
<p>서버 액션 함수를 실행할 컴포넌트에 콘솔로 어떻게 출력되는지 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/02e1a6ec-2645-43e7-bf0d-0e227097b498/image.png" alt="콘솔 출력 결과"></p>
<p>작성하기 버튼을 클릭하면 사진과 같은 결과를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/31271366-b1a1-4cc3-a4dc-5360f0785447/image.png" alt="브라우저 네트워크 확인 결과"></p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/1170d3dc-146a-4bad-93ba-11d4f23e4875/image.png" alt="브라우저 네트워크 페이로드 확인 결과"></p>
<hr>
<h2 id="1-리뷰-추가-기능-구현하기">(1) 리뷰 추가 기능 구현하기</h2>
<p>서버 관련 로직은 <code>app/actions</code> 폴더에 정리하는 게 좋다.
리뷰 추가 기능을 위한 서버 액션은 아래처럼 작성할 수 있다.👇</p>
<pre><code class="language-jsx">&#39;use server&#39;;

export async function createReviewAction(formData: FormData) {
  const bookId = formData.get(&#39;bookId&#39;)?.toString();
  const content = formData.get(&#39;content&#39;)?.toString();
  const author = formData.get(&#39;author&#39;)?.toString();

  if (!bookId || !content || !author) {
    return;
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: &#39;POST&#39;,
      body: JSON.stringify({ bookId, content, author }),
    });
    console.log(response.status);
  } catch (err) {
    console.error(err);
    return;
  }
}</code></pre>
<p>그리고 컴포넌트에서는 이렇게 연결한다.👇</p>
<pre><code class="language-tsx">function ReviewEditor({ bookId }: { bookId: string }) {
  return (
    &lt;section&gt;
      &lt;form action={createReviewAction}&gt;
        &lt;input name=&quot;bookId&quot; value={bookId} hidden /&gt;
        &lt;input required name=&quot;content&quot; placeholder=&quot;리뷰 내용&quot; /&gt;
        &lt;input required name=&quot;author&quot; placeholder=&quot;작성자&quot; /&gt;
        &lt;button type=&quot;submit&quot;&gt;작성하기&lt;/button&gt;
      &lt;/form&gt;
    &lt;/section&gt;
  );
}</code></pre>
<blockquote>
<p><strong>⚠️ 참고</strong></p>
</blockquote>
<pre><code class="language-dhall">You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.</code></pre>
<p><code>input</code>에 <code>value</code> 속성을 지정하고 <code>onChange</code>가 없으면 &quot;읽기 전용&quot; 경고가 발생한다.
이런 경우 동작에는 문제가 없지만 <code>readonly</code> 속성을 추가하면 해결된다.</p>
<hr>
<h2 id="2-리뷰-조회-기능-구현하기">(2) 리뷰 조회 기능 구현하기</h2>
<p>리뷰 데이터를 가져오는 컴포넌트는 서버 컴포넌트로 작성하면 된다.</p>
<pre><code class="language-jsx">//...
async function ReviewList({ bookId }: { bookId: string }) {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/book/${bookId}`);

  if (!response.ok) {
    throw new Error(`Review fetch failed : ${response.statusText}`);
  }

  const reviews: ReviewData[] = await response.json();

  return (
    &lt;section&gt;
      {reviews.map((review) =&gt; (
        &lt;RevioewItem key={`review-item-${review.id}`} {...review} /&gt;
      ))}
    &lt;/section&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/762f5158-0517-402e-9a98-6c2f488a48c5/image.png" alt="리뷰 조회 기능 결과"></p>
<hr>
<h2 id="3-서버-액션으로-페이지-재검증하기">(3) 서버 액션으로 페이지 재검증하기</h2>
<p>리뷰를 작성한 뒤 새로고침하지 않아도 목록이 자동으로 갱신되도록 만들 수 있다.
이때 사용하는 함수가 바로 <code>revalidatePath</code>이다.</p>
<p>서버 액션이 실시간으로 완료되었을 떄, 사용자가 보고있는 페이지를 재검증하여 사용자 경험을 높여보자.</p>
<pre><code class="language-tsx">&#39;use server&#39;;

import { revalidatePath } from &#39;next/cache&#39;;

export async function createReviewAction(formData: FormData) {
  const bookId = formData.get(&#39;bookId&#39;)?.toString();
  const content = formData.get(&#39;content&#39;)?.toString();
  const author = formData.get(&#39;author&#39;)?.toString();

  if (!bookId || !content || !author) {
    return;
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: &#39;POST&#39;,
      body: JSON.stringify({ bookId, content, author }),
    });
    console.log(response.status);
    revalidatePath(`/book/${bookId}`);
  } catch (err) {
    console.error(err);
    return;
  }
}</code></pre>
<p><code>revalidatePath()</code>는 특정 경로의 페이지 캐시를 무효화하고 새로 생성한다. (=페이지 재검증)
즉, 리뷰를 등록하면 <code>/book/[id]</code> 페이지가 자동으로 갱신!</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/3ff65f2b-d76b-4602-9e89-a9b577ff35b1/image.png" alt="재검증 로직"></p>
<p><em>*PURGE: 숙청하다</em></p>
<blockquote>
<p><strong>⚠️ <code>revalidatePath</code> 메서드 주의할 점</strong></p>
<ol>
<li>서버 액션 내부에서만 호출이 가능하다</li>
<li>경로의 재검증을 진행하기 떄문에, 해당 경로에 있는 페이지의 모든 캐시들을 전부 다 무효화 시킨다. </li>
<li><code>cache: ‘force-cache’</code>로 설정해도 무효화되어 캐시가 삭제된다.</li>
<li>데이터 캐시뿐만 아니라 Pull Route Cache도 함께 사라진다. 즉, Pull Route Cache로써 저장되어 있던 페이지도 삭제되기 때문에, 해당 페이지에 접속하면 Next의 서버는 실시간으로 새롭게 페이지를 다시 생성하게 된다.</li>
</ol>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/e27c4137-0dad-4a5d-af95-5f9b55a7be25/image.gif" alt="revalidatePath 결과 화면"></p>
<hr>
<h3 id="다양한-재검증-방식-살펴보기">다양한 재검증 방식 살펴보기</h3>
<p><code>revalidatePat</code>의 옵션을 활용하여 다양한 재검증 방식을 살펴보자</p>
<table>
<thead>
<tr>
<th>종류</th>
<th>코드 예시</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>특정 페이지만</td>
<td><code>revalidatePath(&#39;/book/1&#39;)</code></td>
<td>해당 페이지만 갱신</td>
</tr>
<tr>
<td>특정 경로의 모든 동적 페이지</td>
<td><code>revalidatePath(&#39;/book/[id]&#39;, &#39;page&#39;)</code></td>
<td>모든 도서 페이지 재검증</td>
</tr>
<tr>
<td>특정 레이아웃의 모든 페이지</td>
<td><code>revalidatePath(&#39;/(with-searchbar)&#39;, &#39;layout&#39;)</code></td>
<td>레이아웃 단위 재검증</td>
</tr>
<tr>
<td>전체 사이트</td>
<td><code>revalidatePath(&#39;/&#39;, &#39;layout&#39;)</code></td>
<td>전체 페이지 재생성</td>
</tr>
<tr>
<td>특정 데이터만</td>
<td><code>revalidateTag(&#39;review-1&#39;)</code></td>
<td>태그가 지정된 데이터 캐시만 무효화</td>
</tr>
</tbody></table>
<hr>
<h3 id="태그-기반-캐시-재검증-revalidatetag">태그 기반 캐시 재검증 (revalidateTag)</h3>
<p>데이터 패칭 시 <code>{ next: { tags: [&#39;review-1&#39;] } }</code> 옵션을 부여하면,
서버 액션에서 <code>revalidateTag(&#39;review-1&#39;)</code> 호출만으로 해당 데이터만 갱신할 수 있</p>
<pre><code class="language-jsx">// page.tsx
const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review/book/${bookId}`, {
    next: { tags: [`review-${bookId}`] },
});

// create-review.tsx
&#39;use server&#39;;

import { revalidatePath, revalidateTag } from &#39;next/cache&#39;;

export async function createReviewAction(formData: FormData) {
//...
  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: &#39;POST&#39;,
      body: JSON.stringify({ bookId, content, author }),
    });

    revalidateTag(`review-${bookId}`);
  } catch (err) {
//...</code></pre>
<p>이 방식은 페이지 전체를 다시 렌더링하지 않아도 되기 떄문에, <strong>성능 면에서 훨씬 효율적</strong>이다.</p>
<hr>
<h3 id="클라이언트-컴포넌트에서의-서버-액션---useactionstate">클라이언트 컴포넌트에서의 서버 액션 - <code>useActionState</code></h3>
<p>클라이언트에서도 서버 액션을 제어할 수 있다.
로딩 상태나 에러 메시지를 관리할 때 유용하다.</p>
<p>서버는 아래와 같이 구성할 수 있다.👇</p>
<pre><code class="language-jsx">&#39;use server&#39;;

import { revalidateTag } from &#39;next/cache&#39;;

export async function createReviewAction(state: any, formData: FormData) {
  const bookId = formData.get(&#39;bookId&#39;)?.toString();
  const content = formData.get(&#39;content&#39;)?.toString();
  const author = formData.get(&#39;author&#39;)?.toString();

  if (!bookId || !content || !author) {
    return {
      status: false,
      error: &#39;리뷰 내용과 작성자를 입력해 주세요.&#39;,
    };
  }

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/review`, {
      method: &#39;POST&#39;,
      body: JSON.stringify({ bookId, content, author }),
    });
    if (!response.ok) {
      throw new Error(response.statusText);
    }
    revalidateTag(`review-${bookId}`);
    return {
      status: true,
      error: &#39;&#39;,
    };
  } catch (err) {
    console.error(err);
    return {
      status: false,
      error: `리뷰 저장에 실패했습니다: ${err}`,
    };
  }
}</code></pre>
<p>컴포넌트는 아래와 같이 구성할 수 있다.👇</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { createReviewAction } from &#39;@/actions/create-review.actions&#39;;
import style from &#39;@/components/review-editor.module.css&#39;;
import { useActionState, useEffect } from &#39;react&#39;;

export default function ReviewEditor({ bookId }: { bookId: string }) {
  const [state, formAction, isPending] = useActionState(createReviewAction, null);

  useEffect(() =&gt; {
    if (state &amp;&amp; !state.status) {
      alert(state.error);
    }
  }, [state]);

  return (
    &lt;section&gt;
      &lt;form className={style.form_container} action={formAction}&gt;
        &lt;input name=&quot;bookId&quot; value={bookId} hidden readOnly /&gt;
        &lt;textarea disabled={isPending} required name=&quot;content&quot; placeholder=&quot;리뷰 내용&quot; /&gt;
        &lt;div className={style.submit_container}&gt;
          &lt;input disabled={isPending} required name=&quot;author&quot; placeholder=&quot;작성자&quot; /&gt;
          &lt;button disabled={isPending} type=&quot;submit&quot;&gt;
            {isPending ? &#39;...&#39; : &#39;작성하기&#39;}
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/section&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/5a7f7670-d531-4cac-8db7-804ed28fbdb9/image.png" alt="에러 발생 확인 결과"></p>
<hr>
<h2 id="4-리뷰-삭제-기능-구현하기">(4) 리뷰 삭제 기능 구현하기</h2>
<p><code>requestSubmit</code>로 사용자가 버튼을 클릭한 것과 동일하게 동작하도록 이벤트 핸들러를 추가한다.</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { deleteReviewAction } from &#39;@/actions/delete-review.action&#39;;
import { useActionState, useEffect, useRef } from &#39;react&#39;;

export default function ReviewItemDeleteButton({
  reviewId,
  bookId,
}: {
  reviewId: number;
  bookId: number;
}) {
  const formRef = useRef&lt;HTMLFormElement&gt;(null);
  const [state, formAction, isPending] = useActionState(deleteReviewAction, null);

  useEffect(() =&gt; {
    if (state &amp;&amp; !state.status) {
      alert(state.error);
    }
  }, [state]);

  return (
    &lt;form ref={formRef} action={formAction}&gt;
      &lt;input name=&quot;reviewId&quot; value={reviewId} hidden readOnly /&gt;
      &lt;input name=&quot;bookId&quot; value={bookId} hidden readOnly /&gt;
      {isPending ? (
        &lt;div&gt;...&lt;/div&gt;
      ) : (
        &lt;div onClick={() =&gt; formRef.current?.requestSubmit()}&gt;삭제하기&lt;/div&gt;
      )}
    &lt;/form&gt;
  );
}</code></pre>
<blockquote>
<p>⚠️ <strong>왜 <code>submit</code>을 사용하지 않나요?</strong>
<code>submit</code>은 메서드 유효성 검사나 이벤트 핸들러 등을 다 무시하고 강제로 폼 제출을 발생시키기 때문에 원하지 않은 동작으로 이어질 수 있는 위험성이 있다.
이러한 문제를 예방하기 위해, 폼 유효성 검사와 이벤트를 유지한 채로 제출할 수 있는 <code>requestSubmit</code> 메서드를 사용한다. </p>
</blockquote>
<pre><code class="language-jsx"> &lt;ReviewItemDeleteButton reviewId={id} bookId={bookId} /&gt;</code></pre>
<p>브라우저에서 확인하면 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/07b5b97b-a83e-4358-ba7c-f8fab95b9717/image.gif" alt="삭제 기능 결과 화면"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 7. 스트리밍과 에러핸들링]]></title>
            <link>https://velog.io/@ol_minje/Section-7.-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D%EA%B3%BC-%EC%97%90%EB%9F%AC%ED%95%B8%EB%93%A4%EB%A7%81</link>
            <guid>https://velog.io/@ol_minje/Section-7.-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D%EA%B3%BC-%EC%97%90%EB%9F%AC%ED%95%B8%EB%93%A4%EB%A7%81</guid>
            <pubDate>Tue, 21 Oct 2025 07:53:35 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="스트리밍이란">스트리밍이란?</h1>
<blockquote>
<p>데이터를 흘려보내는 기술 즉, &quot;강물처럼 데이터를 흘려보낸다!&quot;</p>
</blockquote>
<p>스트리밍이란 말 그대로 데이터를 잘게 쪼개서 연속적으로 전달하는 기술이다.
즉, 모든 데이터를 다 받기 전에 일단 보낼 수 있는 만큼 먼저 렌더링해주는 방식!</p>
<p><strong>그럼 왜 사용할까?</strong>
👉 사용자에게 빠르게 ‘뭔가’를 보여줄 수 있기 때문이다.</p>
<p>데이터를 기다리는 동안 아무것도 안 보이는 대신, 로딩바나 스켈레톤처럼 대체 UI를 먼저 보여주면 사용자는 &quot;앱이 멈췄나?&quot;라는 불안감 없이 기다릴 수 있다.</p>
<hr>
<h3 id="스트리밍-이전의-문제">스트리밍 이전의 문제</h3>
<p>예를 들어, 검색 페이지(<code>/search</code>) 같은 <strong>Dyanamic Page는</strong> 사용자가 요청할 때마다 서버가 모든 컴포넌트를 실행해서 <strong>매번 새롭게 렌더링</strong>해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/99e39319-53e5-4b5f-892e-aeac19d45434/image.png" alt="스트리밍 이전 로직"></p>
<p>그런데 만약 특정 컴포넌트 안에서 API 요청이 오래 걸리면, 전체 페이지가 그 데이터를 기다리느라 <strong>하염없이 로딩 상태</strong>로 머물게 된다.</p>
<h3 id="스트리밍-적용-후">스트리밍 적용 후</h3>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/39666fcf-c865-4994-8644-71da17fca1c0/image.png" alt="스트리밍 적용 로직"></p>
<p>스트리밍을 적용하면 느리게 렌더링되는 부분은 일단 대체 UI로 보여주고,
데이터가 준비되면 그 부분만 교체해서 빠르게 화면을 완성한다.</p>
<blockquote>
<p><strong>페이지 스트리밍(Page Streaming)</strong>
오래 걸리는 컴포넌트의 렌더링을 기다리는 동안, 빠르게 렌더링할 수 있는 컴포넌트들을 먼저 보여주는 기술</p>
</blockquote>
<hr>
<h2 id="1-페이지-스트리밍-적용하기">1. 페이지 스트리밍 적용하기</h2>
<p>동적 페이지와 같은 폴더에 <code>loading.tsx</code> 파일을 만들어주면 끝!</p>
<pre><code class="language-jsx">export default function Loading() {
 return &lt;div&gt;Loading&lt;/div&gt;
}</code></pre>
<h3 id="⚠️-주의할-점">⚠️ 주의할 점</h3>
<ol>
<li><strong><code>loading.tsx</code>는 비동기 페이지에만 적용된다.</strong>
 비동기 컴포넌트가 아니면 데이터를 불러오지 않고 있다는 의미이기 때문에, <code>async</code> 키워드가 있는 비동기 컴포넌트에서만 적용된다.</li>
<li><strong>페이지 컴포넌트 전용이다.</strong>
 일반 컴포넌트 폴더 안에서는 사용할 수 없고, 대신 <code>Suspense</code>를 이용해야 한다.</li>
<li><strong>Query String 변경 시엔 트리거되지 않음.</strong>
 예를 들어 검색창의 쿼리만 바뀌면 전체 페이지가 로딩 상태로 가지 않는다. 그래서 페이지 전체가 한 번에 업데이트되어 UX가 살짝 어색할 수 있다.</li>
</ol>
<hr>
<h2 id="2-컴포넌트-스트리밍-적용하기">2. 컴포넌트 스트리밍 적용하기</h2>
<p><code>Suspense</code>는 컴포넌트 단위로 스트리밍을 적용할 수 있게 해준다. 다음처럼 <code>fallback</code> UI를 함께 지정해주면 된다.</p>
<pre><code class="language-jsx">import BookItem from &#39;@/components/book-item&#39;;
import { BookData } from &#39;@/types&#39;;
import { Suspense } from &#39;react&#39;;

async function SearchResult({ q }: { q?: string }) {
  const response = await fetch(`{process.env.NEXT_PUBLIC_API_SERVER_URL}/book?q=${q || &#39;&#39;}}`, {
    cache: &#39;force-cache&#39;,
  });
  if (!response.ok) {
    return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
  }

  const books: BookData[] = await response.json();

  return (
    &lt;div&gt;
      {books.map((book) =&gt; (
        &lt;BookItem key={book.id} {...book} /&gt;
      ))}
    &lt;/div&gt;
  );
}

export default async function Page({ searchParams }: { searchParams: Promise&lt;{ q?: string }&gt; }) {
  const { q } = await searchParams;

  return (
    &lt;Suspense&gt;
      &lt;SearchResult q={q || &#39;&#39;} /&gt;
    &lt;/Suspense&gt;
  );
}</code></pre>
<p>이떄 <code>key</code> 값을 바꿔주면 <code>Suspense</code>가 다시 로딩 상태로 돌아간다. 검색어가 바뀔 떄마다 새로운 로딩 UI가 뜨는 이유!</p>
<pre><code class="language-jsx">//...
export default async function Page({ searchParams }: { searchParams: Promise&lt;{ q?: string }&gt; }) {
  const { q } = await searchParams;

  return (
    &lt;Suspense key={q || &#39;&#39;} fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
      &lt;SearchResult q={q || &#39;&#39;} /&gt;
    &lt;/Suspense&gt;
  );
}</code></pre>
<hr>
<h2 id="3-💀-스켈레톤-ui로-사용자-경험-업그레이드">3. 💀 스켈레톤 UI로 사용자 경험 업그레이드</h2>
<blockquote>
<p><code>스켈레톤 = 뼈대</code>
데이터를 불러오기 전, 페이지 구조만 먼저 보여주는 UI이다.</p>
</blockquote>
<pre><code class="language-jsx">export default function Home() {
  return (
    &lt;div className={style.container}&gt;
      &lt;section&gt;
        &lt;h3&gt;지금 추천하는 도서&lt;/h3&gt;
        &lt;Suspense fallback={&lt;BookListSkeleton count={3} /&gt;}&gt;
          &lt;RecoBooks /&gt;
        &lt;/Suspense&gt;
      &lt;/section&gt;
      &lt;section&gt;
        &lt;h3&gt;등록된 모든 도서&lt;/h3&gt;
        &lt;Suspense fallback={&lt;BookListSkeleton count={3} /&gt;}&gt;
          &lt;AllBooks /&gt;
        &lt;/Suspense&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>로딩 중에는 이렇게 스켈레톤 컴포넌트를 보여주면 된다.👇</p>
<pre><code class="language-jsx">import BookItemSkeleton from &#39;./book-item-skeleton&#39;;

export default function BookListSkeleton({ count }: { count: number }) {
  return new Array(count)
    .fill(0)
    .map((_, idx) =&gt; &lt;BookItemSkeleton key={`book-item-skeleton-${idx}`} /&gt;);
}</code></pre>
<pre><code class="language-jsx">import style from &#39;@/components/skeleton/book-item-skeleton.module.css&#39;;
export default function BookItemSkeleton() {
  return (
    &lt;div className={style.container}&gt;
      &lt;div className={style.cover_img}&gt;&lt;/div&gt;
      &lt;div className={style.info_container}&gt;
        &lt;div className={style.title}&gt;&lt;/div&gt;
        &lt;div className={style.subTitle}&gt;&lt;/div&gt;
        &lt;br /&gt;
        &lt;div className={style.author}&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>스켈레톤 UI는 사용자에게 “앱이 살아있다”는 신호를 주는 UX 기술로,
직접 구현해도 좋지만 귀찮다면 <code>react-loading-skeleton</code> 라이브러리를 쓰면 편하다고 하네용!</p>
<hr>
<h2 id="4-에러-핸들링errortsx">4. 에러 핸들링(error.tsx)</h2>
<p><code>error.tsx</code> 파일을 같은 경로에 만들어주면, 해당 페이지에서 발생한 에러를 전용 화면으로 처리할 수 있다.
이떄, 에러 페이지의 경우 서버 연결의 실패 등을 표현해주기 떄문에 <code>&#39;use client&#39;</code>로 설정해줘야 한다.</p>
<pre><code class="language-jsx">&#39;use client&#39;;

export default function Error() {
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>추가로 <code>error</code>에 대해서 명시하고 싶다면, Next.js에서 제공하는 <code>error</code> props를 이용하면 된다.</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { useEffect } from &#39;react&#39;;

export default function Error({ error }: { error: Error }) {
  useEffect(() =&gt; {
    console.error(error);
  }, []);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>여기에서 <code>Error</code> 에는 <code>message</code> 라는 값이 있기 떄문에 메시지만 따로 출력할 수 있다.</p>
<hr>
<h3 id="reset-props">Reset Props</h3>
<p>에러 컴포넌트에서 <code>error</code> 이외에 추가적으로 <code>reset</code>이라는 props가 더 제공된다.</p>
<blockquote>
<p><code>**reset props</code>**
에러가 발생한 페이지를 복구하기 위해서 다시 한 번 컴포넌트들을 렌더링 시켜주는 기능을 가진 함수이다.</p>
</blockquote>
<p>우리는 이걸 활용하여 <code>다시 시도 버튼</code>을 추가해보자!</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { useEffect } from &#39;react&#39;;

export default function Error({ error, reset }: { error: Error; reset: () =&gt; void }) {
  useEffect(() =&gt; {
    console.error(error.message);
  }, []);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button onClick={() =&gt; reset()}&gt;다시 시도&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<blockquote>
<p>*<em><code>reset()</code> *</em>
클라이언트 측에서 서버에서 전달받은 데이터를 토대로 다시 렌더링하는 메서드로,
서버 컴포넌트를 다시 실행하지 않아서 데이터 페칭을 다시 불러오지는 않는다.</p>
</blockquote>
<p><strong>그러면 서버를 다시 실행시키려면 어떻게 해야할까?</strong></p>
<p>--</p>
<h3 id="서버-다시-실행하기">서버 다시 실행하기</h3>
<ol>
<li><p>브라우저를 새로고침한다.<br> <code>reset()</code> 함수가 있는 곳에 <code>window.location.reeload()</code>를 대신 넣어준다.
 하지만 이 방식은 오류가 발생하지 않은 곳들도 새로 가져오기 떄문에 그렇게 좋은 방식은 아니다.</p>
</li>
<li><p><strong><code>useRouter</code>의 <code>refresh</code> 메서드를 이용</strong>한다.</p>
<pre><code class="language-jsx"> &#39;use client&#39;;

 import { useRouter } from &#39;next/navigation&#39;;
 import { useEffect } from &#39;react&#39;;

 export default function Error({ error, reset }: { error: Error; reset: () =&gt; void }) {
   const router = useRouter();

   useEffect(() =&gt; {
     console.error(error.message);
   }, []);
   return (
     &lt;div&gt;
       &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
       &lt;button onClick={() =&gt; router.refresh()}&gt;다시 시도&lt;/button&gt;
     &lt;/div&gt;
   );
 }</code></pre>
<p> 현재 페이지에 필요한 서버 컴포넌트들을 다시 불러오는 역할을 수행한다.</p>
</li>
</ol>
<blockquote>
<p><strong>👉 정리하면</strong></p>
</blockquote>
<ul>
<li><code>router.refresh()</code>는 서버 컴포넌트를 다시 불러오고,</li>
<li><code>reset()</code>은 클라이언트의 에러 상태를 리셋한다.</li>
</ul>
<p>서버도 다시 불러오고 에러 상태인 클라이언트도 다시 리셋하자!</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { useRouter } from &#39;next/navigation&#39;;
import { useEffect } from &#39;react&#39;;

export default function Error({ error, reset }: { error: Error; reset: () =&gt; void }) {
  const router = useRouter();

  useEffect(() =&gt; {
    console.error(error.message);
  }, []);
  return (
    &lt;div&gt;
      &lt;h3&gt;오류가 발생했습니다.&lt;/h3&gt;
      &lt;button
        onClick={() =&gt; {
          router.refresh();
          reset();
        }}
      &gt;
        다시 시도
      &lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<blockquote>
<p><strong>⚠️ 주의할 점은</strong>
<code>refresh()</code> 메서드는 비동기로 실행되기 때문에, 해당 메서드의 동작이 끝난 후에 <code>reset()</code>을 진행해야 한다.</p>
</blockquote>
<pre><code class="language-jsx">//...
onClick={() =&gt; {
    startTransition(() =&gt; {
        router.refresh();
        reset();
    });
}}
//...</code></pre>
<blockquote>
</blockquote>
<p>그리고 <code>error.tsx</code> 파일은 하위까지 적용된다는 점 유의! (레이아웃과는 다르게 중첩되지 않고 덮어씌어짐.)</p>
<blockquote>
</blockquote>
<p>마지막으로 <strong>에러가 발생한 지점의 레이아웃까지만 렌더링을 시켜주기 때문에 위치를 잘 조정</strong>해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 6. 데이터 캐싱 (2)]]></title>
            <link>https://velog.io/@ol_minje/Section-6.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1-2</link>
            <guid>https://velog.io/@ol_minje/Section-6.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1-2</guid>
            <pubDate>Fri, 17 Oct 2025 05:31:28 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="라우트-세그먼트-옵션">라우트 세그먼트 옵션</h1>
<blockquote>
<p>페이지의 동작을 강제로 지정하는 옵션들</p>
</blockquote>
<p>앞의 <code>dynamicParams</code>도 여기에 속하고, 캐싱/정적화 방식까지 페이지 성격을 딱! 정해줄 수 있다.
그 중 가장 자주 사용하는 옵션인 <code>dynamic</code>이라는 옵션에 대해서 살펴보자.</p>
<hr>
<h3 id="dynamic"><code>dynamic</code></h3>
<blockquote>
<p>특정 페이지를 <code>static</code> 또는 <code>dynamic</code>으로 <strong>“강제”</strong> 설정.</p>
</blockquote>
<pre><code class="language-jsx">export const dynamic = &#39;auto&#39; | &#39;force-dynamic&#39; | &#39;force-static&#39; | &#39;error&#39;;</code></pre>
<ul>
<li><code>auto(기본)</code>: 아무것도 강제하지 않음. Next가 페이지 내용을 보고 자동 판정.</li>
<li><code>force-dynamic</code>: 매 요청마다 새로 렌더링(동적 페이지).</li>
<li><code>force-static</code>: 정적으로 고정(SSG처럼 캐싱).
쿼리스트링/쿠키/헤더 같은 동적 함수 쓰는 페이지에 억지로 쓰면 기능이 안 먹을 수 있음!</li>
<li><code>error</code>: 정적 렌더링이 불가능하면 빌드 타임에 에러를 띄워줌.
(EX: <code>Search</code> 페이지에 <code>error</code>로 두면 <code>couldn&#39;t be rendered statically</code> 같은 에러로 잡아줌.)
Search Params라는 쿼리 스트링의 값을 불러오는 동적 함수를 사용하고 있기 떄문에, Static Page로 변경할 수 없다는 오류가 발생한다.</li>
</ul>
<blockquote>
<p>✅ 검색/필터처럼 요청마다 데이터가 바뀌는 페이지는 <code>auto(=동적 판단)</code>나 f<code>orce-dynamic</code>이 안전!</p>
</blockquote>
<hr>
<h1 id="클라이언트-라우터-캐시client-router-cache">클라이언트 라우터 캐시(Client Router Cache)</h1>
<blockquote>
<p><strong>브라우저에 저장되는 캐시</strong>
페이지 이동을 효율적으로 진행하기 위해 페이지의 일부 데이터를 보관함</p>
</blockquote>
<h3 id="무슨-데이터가-저장될까">무슨 데이터가 저장될까?</h3>
<p>RSC Payload(서버 컴포넌트 결과물) 안에는</p>
<ul>
<li>루트 레이아웃</li>
<li>현재 페이지의 레이아웃</li>
<li>페이지(서버 컴포넌트 트리)</li>
</ul>
<p>등이 모두 들어있다.
Next.js는 여기서 <strong>레이아웃 부분만 뽑아서 클라이언트 라우터 캐시에 자동 저장</strong>해둔다.</p>
<hr>
<h3 id="그래서-어떤-이득">그래서 어떤 이득?</h3>
<p>이후 다른 페이지로 네이게이션할 때,</p>
<ul>
<li>레이아웃은 브라우저 캐시에서 즉시 재사용</li>
<li>페이지(서버 컴포넌트) 데이터만 새로 요청하여 빠르게 진행</li>
</ul>
<p>즉, 공통 UI(헤더/사이드바/탑바)는 재다운로드 X, 바뀌는 페이지 내용만 최소 요청으로 가져온다!</p>
<blockquote>
<p>마치 <strong>캐싱된 데이터처럼</strong> 그대로 레이아웃을 사용하게 되고, 
그 외에 페이지 컴포넌트나 기타 등의 서버 컴포넌트에 해당하는 <strong>데이터들은 따로 요청해서 별도로 받아오는 동작을 수행</strong>한다.</p>
</blockquote>
<blockquote>
<p><strong>클라이언트 라우터 캐시(Client Router Cache)</strong>는 브라우저 측에 저장되는 캐시로, 한 번 방문한 페이지의 레이아웃 정보만 따로 보관해 둔다.
이후 사용자가 다른 페이지로 이동할 때, 이미 저장된 공통 레이아웃 데이터를 다시 <strong>서버에서 불러오지 않고 재사용함</strong>으로써 <strong>페이지 이동 속도를 최적화</strong>하는 기술이다.
<strong>덕분에 중복된 레이아웃을 중복으로 요청받지 않음!</strong></p>
</blockquote>
<hr>
<h3 id="실습">실습</h3>
<pre><code class="language-jsx">import { ReactNode, Suspense } from &#39;react&#39;;
import Searchbar from &#39;../../components/searchbar&#39;;

export default function Layout({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;div&gt;{new Date().toLocaleDateString()}&lt;/div&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
        &lt;Searchbar /&gt;
      &lt;/Suspense&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<ul>
<li><p><strong>라우팅으로 페이지를 왔다 갔다 해도 날짜가 안 바뀌는 걸 확인</strong>
→ 이 컴포넌트가 레이아웃이라 클라이언트 라우터 <strong>캐시에 보관되어 다시 렌더링되지 않기 때문.</strong></p>
</li>
<li><p><strong>새로고침하면 날짜가 갱신</strong>
→ 브라우저 캐시(클라이언트 라우터 캐시)는 새로고침/탭 종료 시 비움.</p>
</li>
</ul>
<blockquote>
<p>페이지 이동에는 강하고, 새로고침에는 초기화된다!</p>
</blockquote>
<hr>
<h2 id="✅-정리하며">✅ 정리하며</h2>
<ul>
<li><p>레이아웃에서 “시간/랜덤값” 같은 매 순간 달라지는 값을 렌더링하면,
네비게이션 시에는 안 바뀌는 게 정상 (캐시 때문).
→ 페이지 컴포넌트에서 다루거나 클라이언트 컴포넌트 + 이펙트로 처리.</p>
</li>
<li><p>검색 페이지처럼 동적 함수(쿼리스트링/쿠키/헤더)를 쓰면
억지로 <code>force-static</code> 하면 동작 망가짐. <code>auto</code>나 <code>force-dynamic</code> 추천!</p>
</li>
<li><p>레이아웃에 클라이언트 훅(useSearchParams)을 쓸 땐
<code>&quot;use client&quot;</code> + <code>&lt;Suspense&gt;</code> 로 비동기 흐름을 안전하게 감싸기.</p>
</li>
</ul>
<blockquote>
<p>라우트 세그먼트 옵션으로 <strong>&quot;페이지 성격&quot;</strong>을 명확히 하고, 클라이언트 라우터 캐시로 <strong>&quot;레이아웃 재사용&quot;</strong>하면 
▶ 기깔난 App Router 네비게이션!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 6. 데이터 캐싱 (1)]]></title>
            <link>https://velog.io/@ol_minje/Section-6.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1</link>
            <guid>https://velog.io/@ol_minje/Section-6.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%B1</guid>
            <pubDate>Thu, 16 Oct 2025 08:06:11 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="full-route-cache란">Full Route Cache란?</h1>
<blockquote>
<p>“빌드 타임에 서버가 특정 페이지의 렌더링 결과를 미리 캐싱해두는 기능”</p>
</blockquote>
<p>Next.js의 서버는 페이지를 한 번 렌더링한 뒤,
그 결과물 전체를 “Full Route Cache” 라는 이름으로 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/4fe1ade9-1382-40f3-9cf4-c9c6b024cc18/image.png" alt="풀라우트 캐시 동작 과정 1번"></p>
<p>이후 동일한 페이지의 요청이 들어오면, 다시 렌더링하지 않고 <strong>캐싱된 HTML 결과를 그래도 전달</strong>한다.
즉, 정적 페이지(SSG)의 방식과 유사하며 빠른 응답이 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/8e1c3357-9496-4316-9b2e-aa5d8797454a/image.png" alt="풀라우트 캐시 동작 과정 2번"></p>
<hr>
<h2 id="정적-페이지와-동적-페이지-구분">정적 페이지와 동적 페이지 구분</h2>
<blockquote>
<p>Next.js는 어떤 기능을 사용하느냐에 따라 자동으로 페이지를 구분한다.</p>
</blockquote>
<hr>
<h3 id="daynamic-page로-설정되는-기준">Daynamic Page로 설정되는 기준</h3>
<blockquote>
<p>“특정 페이지가 접속 요청을 받을 떄마다 매번 변화가 생기거나, 데이터가 달라지는 경우”</p>
</blockquote>
<p>다음 중 하나라도 해당되면 <strong>동적 페이지로 분류</strong>된다.</p>
<ol>
<li>캐시되지 않은 데이터 페칭을을 사용할 경우 (서버 컴포넌트에만 해당)</li>
<li>동적 함수(쿠키, 헤더, 쿼리스트링)을 사용하는 경우</li>
</ol>
<hr>
<h3 id="static-page로-설정되는-기준">Static Page로 설정되는 기준</h3>
<blockquote>
<p>Dynamic Page가 아니면 모두 Static Page가 된다. (기본값)</p>
</blockquote>
<p>비교해보자</p>
<table>
<thead>
<tr>
<th>동적 함수</th>
<th>데이터 캐시</th>
<th>페이지 분류</th>
</tr>
</thead>
<tbody><tr>
<td>🟢</td>
<td>❌</td>
<td>Dynamic Page</td>
</tr>
<tr>
<td>🟢</td>
<td>🟢</td>
<td>Dynamic Page</td>
</tr>
<tr>
<td>❌</td>
<td>❌</td>
<td>Dynamic Page</td>
</tr>
<tr>
<td>❌</td>
<td>🟢</td>
<td>Static Page</td>
</tr>
</tbody></table>
<blockquote>
<h3 id="full-route-cache와-revalidate">Full Route Cache와 <code>revalidate</code></h3>
<p>Full Route Cache는 <code>revalidate</code> 옵션과 함께 사용해
ISR(Incremental Static Regeneration) 방식처럼 동작할 수도 있다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/ae11d217-9c24-4bcc-a3cf-564764da96f7/image.png" alt="풀라우트캐시 revalidate"></p>
<p><strong>동작 순서</strong></p>
<ol>
<li>지정한 시간이 지나면 Next 서버는 해당 캐시를 <code>Stale</code>(만료됨)로 표시</li>
<li>먼저 <strong>기존 캐시</strong>를 그대로 응답</li>
<li>그 뒤 서버에서 새로운 데이터를 fetch</li>
<li>렌더링 후 캐시 업데이트(Full Route Cache 최신화)</li>
</ol>
<hr>
<h2 id="실습하며-적용">실습하며 적용!</h2>
<h3 id="1️⃣-usesearchparams-오류-해결">1️⃣ <code>useSearchParams</code> 오류 해결</h3>
<blockquote>
<p>현재 <code>useSearchParams()</code>라는 훅은 브라우저에서만 실행 가능하기 떄문에
사전 렌더링 과정에서 빌드할 수 없다는 오류가 발생한다.</p>
</blockquote>
<pre><code class="language-jsx">&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { useRouter, useSearchParams } from &quot;next/navigation&quot;;
import style from &quot;./serachbar.module.css&quot;;

export default function Searchbar() {
  const router = useRouter();
  const searchParams = useSearchParams(); // 문제 발생!
  const [search, setSearch] = useState(&quot;&quot;);</code></pre>
<p>이를 해결하기 위해서는 <strong>프로젝트 빌드 시 해당 파일이 빌드되지 않도록 설정</strong>해줘야 하며,
해당 컴포넌트를 사용하고 있는 부모 컴포넌트로 가서 <code>Suspense</code>로 해당 컴포넌트를 감싸서 <strong>렌더링 순서를 제어</strong>한다.</p>
<pre><code class="language-jsx">import { ReactNode, Suspense } from &#39;react&#39;;
import Searchbar from &#39;../../components/searchbar&#39;;

export default function Layout({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
        &lt;Searchbar /&gt;
      &lt;/Suspense&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<blockquote>
<h3 id="suspense란">Suspense란?</h3>
<p><em>다음 주제에서 진행되지만 미리 살펴보자</em></p>
<p>비동기 로직이 포함된 컴포넌트를 &quot;준비될 때까지 잠시 렌더링하지 않게 해주는 장치&quot;이다.</p>
</blockquote>
<ul>
<li><code>fallback</code> 속성으로 로딩 상태 UI를 지정할 수 있다.</li>
<li>여기서는 <code>useSearchParams</code>가 비동기 Hook이므로 <code>Suspense</code>가 필요하다.<blockquote>
</blockquote>
<code>Suspense</code> 컴포넌트로 묶여있는 컴포넌트는 <strong>미완성 상태라</strong> 곧바로 렌더링하지 않으며, 대신 <code>fallback</code>이라는 <code>props</code>로 전달한 <strong>대체 UI가 로딩 UI로서 대신 렌더링</strong>된다.
(이떄, “미완성 상태”는 컴포넌트의 비동기 작업이 종료될 때까지)<blockquote>
<pre><code class="language-jsx">export default async function Page({ searchParams }: { searchParams: SearchParams }) {
  const { q } = await searchParams;

  return &lt;div&gt;Search Query: {q}&lt;/div&gt;;
}</code></pre>
</blockquote>
</li>
</ul>
<hr>
<h3 id="2️⃣-동적-경로에-적용하기">2️⃣ 동적 경로에 적용하기</h3>
<p>Query String을 사용하는 <code>search</code> 페이지처럼, 페이지 자체는 캐시 불가하지만, <strong>fetch</strong> 단위로 캐싱이 가능하다.</p>
<pre><code class="language-jsx">export default async function Page({ searchParams }: { searchParams: Promise&lt;{ q?: string }&gt; }) {
  const { q } = await searchParams;
  const response = await fetch(`{process.env.NEXT_PUBLIC_API_SERVER_URL}/book?q=${q || &#39;&#39;}}`, {
    cache: &#39;force-cache&#39;,
  });
  //...</code></pre>
<hr>
<h3 id="3️⃣-정적-경로-미리-생성하기">3️⃣ 정적 경로 미리 생성하기</h3>
<p>URL 파라미터를 사용하는 <code>book/[id]</code> 빌드 타임에 어떤 경로가 존재할지 알 수 없기 때문에 기본적으로 Dynamic Page로 처리된다.</p>
<p>우리는 정적 경로로 지정해야 하니,
빌드 타임에 서버에게 해당 페이지가 어떤 경로를 가질 수 있는지 미리 알려주기 위해 <code>generateStaticParams</code> 함수를 생성해준다.</p>
<blockquote>
<p><code>generateStaticParams</code> 함수는 &quot;정적인 파라미터를 생성하는 함수&quot;이다.</p>
</blockquote>
<pre><code class="language-jsx">import { BookData } from &#39;@/types&#39;;
import style from &#39;./page.module.css&#39;;

export function generateStaticParams() {
  return [{ id: &#39;1&#39; }, { id: &#39;2&#39; }, { id: &#39;3&#39; }, { id: &#39;4&#39; }, { id: &#39;5&#39; }];
}

export default async function Page({ params }: { params: Promise&lt;{ id: string | string[] }&gt; }) {
//...</code></pre>
<p>반환 값으로 어떤 URL 파라미터의 어떤 페이지가 빌드 타임에 존재하는지 알려줘야 한다.</p>
<blockquote>
<h3 id="⚠️-주의할-점">⚠️ 주의할 점</h3>
</blockquote>
<ol>
<li><code>id</code>는 문자열(string) 로만 지정해야 함</li>
<li>generateStaticParams를 사용한 페이지는 무조건 정적 페이지로 설정되므로, 내부의 <code>fetch</code>도 자동으로 정적 캐시로 동작함. (데이터 캐싱이 설정되지 않은 패칭도 정적으로 설정되니 주의!)</li>
</ol>
<p><strong>&lt; 이전 빌드 결과 &gt;</strong></p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/5f6649b0-1ca3-4667-aa78-df3293be9e39/image.png" alt="이전 빌드 결과"></p>
<p><strong>&lt; 이후 빌드 결과 &gt;</strong></p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/55c587b3-84cc-420c-8409-43c4d0e2408a/image.png" alt="이후 빌드 결과"></p>
<h3 id="정적-경로-외의-페이지는-왜-렌더링될까">정적 경로 외의 페이지는 왜 렌더링될까?</h3>
<blockquote>
<p><code>generateStaticParams</code>로 설정하지 않은 페이지도 렌더링 되는 이유는 무엇일까?</p>
</blockquote>
<p><code>generateStaticParams</code>로 지정하지 않은 페이지는
자동으로 Dynamic Page로 생성되어 Full Route Cache에 저장된다.
즉, 최초 접속 이후에는 캐시된 버전이 빠르게 반환된다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/9027f24b-b248-4bbe-a0e6-81adc4f7868d/image.png" alt="네트워크 확인 결과"></p>
<blockquote>
<h3 id="🚫-지정되지-않은-경로는-not-found로-처리하기">🚫 지정되지 않은 경로는 Not Found로 처리하기</h3>
</blockquote>
<pre><code class="language-tsx">export const dynamicParams = false;</code></pre>
<p>이 설정은 generateStaticParams에 정의된 경로 외에는
모두 404 페이지로 이동한다.</p>
<hr>
<h2 id="404-not-found-페이지">404 Not Found 페이지</h2>
<p>요청 실패 시 모든 페이지에 노출할 공통 페이지를 만들어보자.</p>
<p>우선 <code>navigation</code> 의 <code>notFound()</code> 함수를 호출하여 API 로직에 아래와 같이 추가한다.</p>
<pre><code class="language-jsx">if (!response.ok) {
  if (response.status === 404) {
    notFound();
  }
  return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
}
//...</code></pre>
<p>그리고 <code>app/not-found.tsx</code> 파일을 추가하면 자동으로 렌더링된다.</p>
<pre><code class="language-jsx">export default function NotFound() {
  return &lt;div&gt;404: NotFound&lt;/div&gt;;
}</code></pre>
<p>빌드하고 재실행하여 브라우저로 접속해서 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/7807feff-b116-4ffa-b1f6-d85d23f87b93/image.png" alt="404 Not Found 페이지"></p>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Full Route Cache</strong></td>
<td>빌드 타임에 생성된 페이지 결과를 저장</td>
</tr>
<tr>
<td><strong>Dynamic Page</strong></td>
<td>요청마다 새 데이터로 렌더링</td>
</tr>
<tr>
<td><strong>Static Page</strong></td>
<td>캐싱된 HTML로 즉시 응답</td>
</tr>
<tr>
<td><strong>revalidate</strong></td>
<td>일정 시간 후 캐시 갱신 (ISR 유사)</td>
</tr>
<tr>
<td><strong>generateStaticParams</strong></td>
<td>정적 경로를 빌드 타임에 미리 생성</td>
</tr>
<tr>
<td><strong>Suspense</strong></td>
<td>비동기 컴포넌트를 로딩 UI로 감싸는 장치</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 5. 데이터 페칭]]></title>
            <link>https://velog.io/@ol_minje/Section-5.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8E%98%EC%B9%AD</link>
            <guid>https://velog.io/@ol_minje/Section-5.-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8E%98%EC%B9%AD</guid>
            <pubDate>Wed, 15 Oct 2025 05:47:24 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="1️⃣-앱-라우터의-데이터-페칭">1️⃣ 앱 라우터의 데이터 페칭</h1>
<p>기존 Page Router에서는 서버에서 데이터를 불러오기 위해 <code>getServerSideProps</code>, <code>getStaticProps</code>, <code>getStaticPaths</code> 와 같은 전용 함수를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/aee86166-dabb-457c-8456-87585aad17bb/image.png" alt="page router의 데이터 페칭 과정 1">
이 함수들이 데이터를 불러오고, 그 결과를 <code>props</code> 형태로 컴포넌트에 전달했다.</p>
<blockquote>
<p>즉, 모든 데이터가 &quot;페이지 단위&quot;로만 전달될 수 있었음.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/de3fb8fe-1266-4f06-8a20-9cec0b3f2e9a/image.png" alt="page router의 데이터 페칭 과정 2"></p>
<hr>
<h2 id="⚠️-page-router의-문제점">⚠️ Page Router의 문제점</h2>
<ul>
<li>모든 컴포넌트가 클라이언트 컴포넌트로 동작함.</li>
<li>이로 인해 <strong>데이터 페칭이 서버와 브라우저에서 모두 실행</strong>되어 비효율적.</li>
<li>또한, 데이터를 최상위 페이지 컴포넌트에서만 페칭할 수 있어, 하위 컴포넌트로 넘겨주러면 <code>props</code> 전달 혹은 <code>Context API</code>를 사용해야 했음</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/bbd6d3e1-a776-4c7c-b3c1-eab1b0648e60/image.png" alt="page router의 데이터 페칭 문제점"></p>
<hr>
<h2 id="📢-app-router의-등장">📢 App Router의 등장</h2>
<p><em>서버 컴포넌트의 등장으로 문제 해결!</em></p>
<p>App Router에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트로 동작한다. 즉, 브라우저에서 실행되지 않기 때문에 <code>async</code>와 같은 키워드를 붙여도 문제없다.</p>
<blockquote>
<p>“데이터는 필요한 곳에서 직접 불러와라” - Next.js 공식문서 (Fetching data where it’s needed)</p>
</blockquote>
<p>덕분에 각 컴포넌트가 <strong>스스로 필요한 데이터를 <code>fetch</code>해서 렌더링</strong>할 수 있게 되었다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/ffa6ab11-4b4a-44f3-bd58-1904bef7b664/image.png" alt="app router의 데이터 페칭"></p>
<hr>
<h2 id="실습해보자">실습해보자!</h2>
<pre><code class="language-jsx">import BookItem from &#39;@/components/book-item&#39;;
import books from &#39;@/mock/books.json&#39;;
import style from &#39;./page.module.css&#39;;

export default function Home() {
  return (
    &lt;div className={style.container}&gt;
      &lt;section&gt;
        &lt;h3&gt;지금 추천하는 도서&lt;/h3&gt;
        {books.map((book) =&gt; (
          &lt;BookItem key={book.id} {...book} /&gt;
        ))}
      &lt;/section&gt;
      &lt;section&gt;
        &lt;h3&gt;등록된 모든 도서&lt;/h3&gt;
        {books.map((book) =&gt; (
          &lt;BookItem key={book.id} {...book} /&gt;
        ))}
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>데이터 페칭을 할 수 있도록 <code>async</code> 키워드로 비동기 함수를 만든 후 아래와 같이 작성했다.</p>
<pre><code class="language-jsx">import BookItem from &#39;@/components/book-item&#39;;
import { BookData } from &#39;@/types&#39;;
import style from &#39;./page.module.css&#39;;

async function AllBooks() {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/books`);
  if (!response.ok) {
    return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
  }

  const allBooks: BookData[] = await response.json();

  return (
    &lt;div&gt;
      {allBooks.map((book) =&gt; (
        &lt;BookItem key={book.id} {...book} /&gt;
      ))}
    &lt;/div&gt;
  );
}

async function RecoBooks() {
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/books/random`);
  if (!response.ok) {
    return &lt;div&gt;오류가 발생했습니다...&lt;/div&gt;;
  }

  const randomBooks: BookData[] = await response.json();

  return (
    &lt;div&gt;
      {randomBooks.map((book) =&gt; (
        &lt;BookItem key={book.id} {...book} /&gt;
      ))}
    &lt;/div&gt;
  );
}

export default function Home() {
  return (
    &lt;div className={style.container}&gt;
      &lt;section&gt;
        &lt;h3&gt;지금 추천하는 도서&lt;/h3&gt;
        &lt;RecoBooks /&gt;
      &lt;/section&gt;
      &lt;section&gt;
        &lt;h3&gt;등록된 모든 도서&lt;/h3&gt;
        &lt;AllBooks /&gt;
      &lt;/section&gt;
    &lt;/div&gt;
  );
}</code></pre>
<hr>
<h1 id="3️⃣-데이터-캐시-data-cache">3️⃣ 데이터 캐시 (Data Cache)</h1>
<p>Next.js는 <code>fetch()</code> 결과를 서버 측에서 자동으로 캐싱할 수 있도록 만들었다.
이 덕분에 동일한 요청이 반복될 때, 데이터를 어떤 방식으로 캐싱할지 지정할 수 있다.</p>
<ul>
<li>영구적으로 데이터를 보관</li>
<li>혹은 특정 시간을 주기로 데이터 갱신</li>
</ul>
<p>등의 기능을 수행하며, 사용 방법은 아래와 같다.</p>
<pre><code class="language-jsx">const response = await fetch(`/api`, { cache: &quot;force-cache&quot; });</code></pre>
<hr>
<h2 id="cache-옵션">Cache 옵션</h2>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>cache: &quot;no-store&quot;</code></td>
<td>캐싱하지 않음 (기본값). 항상 새로운 요청을 보냄</td>
</tr>
<tr>
<td><code>cache: &quot;force-cache&quot;</code></td>
<td>요청 결과를 무조건 캐싱. 이후 동일 요청은 캐시에서 가져옴</td>
</tr>
<tr>
<td><code>next: { revalidate: 3 }</code></td>
<td>3초마다 캐시를 갱신 (ISR 방식과 유사)</td>
</tr>
<tr>
<td><code>next: { tags: [&#39;a&#39;] }</code></td>
<td>On-Demand Revalidation — 특정 태그를 지정해 수동으로 최신화</td>
</tr>
</tbody></table>
<h3 id="-cache-no-store-"><code>{ cache: &quot;no-store&quot; }</code></h3>
<ul>
<li>데이터 페칭의 결과를 아예 캐싱하지 않도록 설정하는 옵션</li>
<li>기본값으로 지정되는 옵션이다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/d9c6fb39-3ecf-4538-8ffd-28828b165f66/image.png" alt="캐시 미저장 옵션 콘솔 결과"></p>
<blockquote>
<p>새로고침 후 콘솔에서 확인해보면<code>cache skip</code>으로 출력됨을 확인할 수 있다.</p>
</blockquote>
<hr>
<h3 id="-cache-force-cache-"><code>{ cache: &quot;force-cache&quot; }</code></h3>
<ul>
<li>요청의 결과를 무조건 캐싱하며, 한 번 호출된 이후에는 다시 호출되지 않는다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/16fbd6a3-6005-4413-ac13-28c0d49c51b6/image.png" alt="캐시 저장 옵션 콘솔 결과"></p>
<blockquote>
<p>새로고침 후. 콘솔로 확인해보면 <code>cache hit</code> 즉, 캐싱된 데이터를 찾는다는 것을 알 수 있다.</p>
</blockquote>
<hr>
<h3 id="-next--revalidate-3--"><code>{ next: { revalidate: 3 } }</code></h3>
<ul>
<li>특정 시간을 주기로 캐시를 업데이트 하며, 마치 Page Router의 ISR 방식과 유사하다.</li>
</ul>
<blockquote>
<p>실행 후, 지정한 시간을 주기로 새로고침 되는  것을 확인할 수 있다.</p>
</blockquote>
<hr>
<h3 id="-next--tags-a--"><code>{ next: { tags: [&#39;a&#39;] } }</code></h3>
<ul>
<li>요청이 들어왔을 때 데이터를 최신화한다.(On-Demand Revalidate)</li>
</ul>
<hr>
<blockquote>
<p>추가로 데이터 페칭이 발생할 때마다 로그를 출력하는 설정은 아래와 같다.</p>
</blockquote>
<pre><code class="language-jsx">import type { NextConfig } from &#39;next&#39;;
&gt;
const nextConfig: NextConfig = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
};
&gt;
export default nextConfig;</code></pre>
<hr>
<h1 id="3-request-memoization">3. Request Memoization</h1>
<blockquote>
<p>“요청을 기억한다.” — 동일한 요청을 자동으로 한 번만 수행하도록 최적화하는 기능이다.</p>
</blockquote>
<p>서버 컴포넌트 구조상, 여러 컴포넌트가 같은 데이터를 요청할 수 있다. (ex. 각 서버 컴포넌트들이 자신들이 필요한 데이터를 알아서 불러오기 때문!)
이때 Next.js는 동일한 요청을 하나로 묶어 처리하고, 결과를 공유한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/1bdaba12-de76-4eb0-b9ab-f533e48c0cd9/image.png" alt="Request Memoization 동작 과정"></p>
<h2 id="데이터-캐시-vs-리퀘스트-메모이제이션">데이터 캐시 vs 리퀘스트 메모이제이션</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>데이터 캐시 (Data Cache)</th>
<th>요청 메모이제이션 (Request Memoization)</th>
</tr>
</thead>
<tbody><tr>
<td>목적</td>
<td>데이터를 오래 보관</td>
<td>동일한 요청을 중복 방지</td>
</tr>
<tr>
<td>지속 기간</td>
<td>서버 인스턴스가 유지되는 동안</td>
<td>렌더링 1회 주기 동안만</td>
</tr>
<tr>
<td>활용 예</td>
<td>API 결과 영구 저장</td>
<td>한 페이지 내 중복 요청 제거</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 4. App Router 시작하기(3)]]></title>
            <link>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B03</link>
            <guid>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B03</guid>
            <pubDate>Tue, 14 Oct 2025 05:58:45 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="app-router의-네비게이팅">App Router의 네비게이팅</h1>
<h2 id="1️⃣-페이지-이동의-기본-개념">1️⃣ 페이지 이동의 기본 개념</h2>
<p>Next.js의 페이지 이동은 Page Router 시절과 동일하게 <code>CSR(Client Side Rendering)</code> 방식으로 이루어진다.
즉, 한 번 페이지를 로드한 이후에는 <strong>브라우저 내에서 페이지 전환</strong>이 일어나며, 전체 새로고침 없이 부드럽게 이동한다.</p>
<p>하지만!
App Router에서는 <strong>&quot;서버 컴포넌트(Server Component)&quot;</strong>라는 개념이 추가되면서, Page Router 때와는 조금 다른 흐름이 생겼다.</p>
<hr>
<h2 id="2️⃣-app-router에서의-동작-방식">2️⃣ App Router에서의 동작 방식</h2>
<p>Page Router에서는 사용자가 페이지를 처음 방문하면, 서버가 JS Bundle(자바스크립트 묶음 파일) 을 보내준다.</p>
<p>그래서 만약 브라우저가 JS Bundle만 받게 된다면, 서버에서 렌더링된 내용(즉, 데이터)은 <strong>통째로 빠져</strong>버리게 된다.</p>
<blockquote>
<p>JS Bundle에는 컴포넌트만 포함되어 있기 떄문!</p>
</blockquote>
<p>이를 방지하기 위해서 Next.js는 <strong>JS Bundle과 함꼐 서버 컴포넌트의 결과물인 RSC Payload도 함께 전달</strong>한다.</p>
<blockquote>
<p>즉, App Router의 페이지 이동은 <code>JS Bundle + RSC Payload = 완성!</code>으로,
이 두 가지가 함께 있어야 완전한 페이지가 구성된다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/e418baec-9e8a-4636-a5c0-15305872e4ea/image.png" alt="App Router의 동작 방식"></p>
<hr>
<h2 id="3️⃣-실습-네비게이션바-구성하기">3️⃣ 실습: 네비게이션바 구성하기</h2>
<p>공통 네비게이션을 위해 Root Layout에 <code>header</code> 요소를 추가하고 실행해보자.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/3d31d430-fa77-4e00-a25c-cdf6b6445e52/image.png" alt="RSC Payload 확인하기"></p>
<blockquote>
<p>어떤 페이지로 이동하든 헤더가 고정된 채로 RSC Payload가 함께 전달되는 걸 확인할 수 있다.</p>
</blockquote>
<p>특히 검색 페이지(<code>/search</code>)에 들어가면 <strong>RSC Payload만 전달</strong>되고 JS Bundle은 없는 걸 볼 수 있는데,
이건 <code>/search</code> 페이지는 순수한 서버 컴포넌트로 구성되어 있기 떄문이다. (= 서버 컴포넌트로만 이루어져 있어서 클라이언트 컴포넌트를 전달하지 않는다.)</p>
<blockquote>
<p>App Router에서 페이지 이동은 Page Router 버전의 페이지 이동과 거의 동일하지만,  <strong>Server Component의 추가로</strong></p>
</blockquote>
<ul>
<li>Response에 서버 컴포넌트는 RSC Payload로</li>
<li>클라이언트 컴포넌트는 기존의 JS Bundle로 전달되는<blockquote>
</blockquote>
차이점을 기억하면 된다.</li>
</ul>
<hr>
<h2 id="4️⃣-app-router의-페이지-이동">4️⃣ App Router의 페이지 이동</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Page Router</th>
<th>App Router</th>
</tr>
</thead>
<tbody><tr>
<td>페이지 이동 방식</td>
<td>CSR (Client Side Rendering)</td>
<td>CSR (Client Side Rendering)</td>
</tr>
<tr>
<td>전송 데이터</td>
<td>JS Bundle</td>
<td>JS Bundle + RSC Payload</td>
</tr>
<tr>
<td>서버 컴포넌트 결과</td>
<td>❌ 포함 안 됨</td>
<td>✅ 함께 전송됨</td>
</tr>
</tbody></table>
<blockquote>
<p>App Router의 페이지 이동은 기존 CSR과 동일하지만, 서버 컴포넌트의 결과물이 추가로 포함된다!</p>
</blockquote>
<hr>
<h3 id="함수로-페이지-이동하기">함수로 페이지 이동하기</h3>
<p><code>&#39;next/navigation&#39;</code>의  <code>useRouter</code>를 사용하면, 클라이언트 컴포넌트에서 페이지 이동을 쉽게 제어할 수 있다.</p>
<pre><code class="language-jsx">&#39;use client&#39;;

import { useRouter } from &#39;next/navigation&#39;;
import React, { useState } from &#39;react&#39;;

export default function Searchbar() {
  const router = useRouter();
  const [search, setSearch] = useState(&#39;&#39;);

  const onChangeSearch = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    setSearch(e.target.value);
  };

  const onSubmit = () =&gt; {
    router.push(`/search?q=${search}`);
  };

  return (
    &lt;div&gt;
      &lt;input value={search} onChange={onChangeSearch} /&gt;
      &lt;button onClick={onSubmit}&gt;검색&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>버튼을 누를 때 <code>router.push()</code>로 이동하는 방식은 기존 <code>next/router</code>의 <code>useRouter</code>와 거의 동일하다!</p>
<hr>
<h2 id="5️⃣-정적-페이지와-동적-페이지의-응답-차이">5️⃣ 정적 페이지와 동적 페이지의 응답 차이</h2>
<p>App Router의 모든 페이지는 Static(정적) 또는 Dynamic(동적) 페이지로 분류된다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Static 페이지</strong></td>
<td>빌드 타임에 미리 생성되는 페이지</td>
<td><code>/about</code>, <code>/contact</code></td>
</tr>
<tr>
<td><strong>Dynamic 페이지</strong></td>
<td>요청 시점에 즉시 생성되는 페이지</td>
<td><code>/book/[id]</code>, <code>/search?q=...</code></td>
</tr>
</tbody></table>
<blockquote>
<p>Static 페이지는 Page Router 버전으로 SSG 방식.
Dynamic 페이지는 Page Router 버전으로 SSR 방식! 다시 기억하기</p>
</blockquote>
<hr>
<h3 id="차이점-1-음답-내용">차이점 1. 음답 내용</h3>
<p><strong>정적 페이지</strong></p>
<ul>
<li>RSC Payload + JS Bundle</li>
<li>프리패칭으로 빠르게 불러옴</li>
</ul>
<p><strong>동적 페이지</strong></p>
<ul>
<li>RSC Payload만 응답</li>
<li>JS Bundle은 데이터 변경 시점에서 로드됨</li>
</ul>
<hr>
<h3 id="차이점-2-빌드-결과-확인">차이점 2. 빌드 결과 확인</h3>
<p><code>npm run build</code>를 실행하면, Next.js가 어떤 페이지가 정적/동적인지 표시해준다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/d5027ac6-449f-4078-99a3-ba569c848420/image.png" alt="빌드 후 정적과 동적 비교하기"></p>
<p>URL 파라미터를 사용하는 <code>/book/[id]</code>와 Query String을 사용하는 <code>/search</code> 는 동적 페이지로 설정된 걸 확인할 수 있다.</p>
<p>이처럼 분류된 이유는, Queryn String이나 URL 파라미터를 꺼내는 등 <strong>빌드 타임에 생성하면 안 될 것 같은 동작을 수행하는 경우</strong>는 동적인 페이지(Dynamic 페이지)로 설정되기 떄문이다.</p>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li>App Router의 페이지 이동은 CSR 기반이다.</li>
<li>클라이언트 컴포넌트는 JS Bundle + RSC Payload가 함께 전달된다.</li>
<li>App Router에서 페이지의 데이터는 서버 컴포넌트 RSC Payload로 전달된다.</li>
<li>순수 서버 컴포넌트 페이지는 JS Bundle이 없다.</li>
<li>Query String 또는 URL Param을 사용하면 Dynamic 페이지로 분류된다.</li>
</ul>
<blockquote>
<ul>
<li>정적(Static) 페이지의 경우 데이터의 업데이트가 필요하지 않으니 프리패칭으로 RSC Payload와 JS Bundle을 둘 다 불러오고,</li>
</ul>
</blockquote>
<ul>
<li>동적(Dynamic) 페이지의 경우, 데이터의 업데이트가 필요할 수 있기 때문에 RSC Payload만 불러온다. JS Bundle의 경우 데이터 업데이트가 발생한 순간에 불러오게 된다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 4. App Router 시작하기(2)]]></title>
            <link>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B02</link>
            <guid>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B02</guid>
            <pubDate>Mon, 13 Oct 2025 07:22:16 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="1-리액트-서버-컴포넌트-이해하기">1. 리액트 서버 컴포넌트 이해하기</h1>
<h2 id="1️⃣-리액트-서버-컴포넌트란">1️⃣ 리액트 서버 컴포넌트란?</h2>
<blockquote>
<p><strong>React Server Component(RSC)</strong>
서버에서만 실행되는 컴포넌트로, 브라우저에서는 전혀 실행되지 않는다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/f48979b9-9c5d-4222-ac61-ddeb80cdd1db/image.png" alt="서버 컴포넌트 이해하기"></p>
<ul>
<li>즉, 서버 컴포넌트는 서버에서 HTML을 사전 렌더링할 때 딱 한 번만 실행되고, 브라우저에서는 이미 만들어진 HTML만 전달받는다.</li>
<li>반면, 클라이언트 컴포넌트는 사전 렌더링 때 한 번, 하이드레이션 때 한 번, 총 두 번 실행된다.</li>
</ul>
<hr>
<h2 id="2️⃣-서버-vs-클라이언트">2️⃣ 서버 vs 클라이언트</h2>
<p>Next.js의 <strong>App Router</strong>에서는 기본적으로 모든 컴포넌트가 <strong>서버 컴포넌트(Server Component)</strong>이다.</p>
<blockquote>
<p><strong>즉, 기본값은 서버 컴포넌트!</strong></p>
</blockquote>
<p>클라이언트 컴포넌트를 사용하고 싶다면, 컴포넌트 파일의 최상단에 <code>&#39;use client&#39;</code>를 선언해주면 된다.</p>
<pre><code class="language-tsx">&#39;use client&#39;;
import { useEffect } from &#39;react&#39;;
import styles from &#39;./page.module.css&#39;;

export default function Home() {
  useEffect(() =&gt; {}, []);
  return &lt;div className={styles.page}&gt;인덱스 페이지&lt;/div&gt;;
}</code></pre>
<hr>
<h2 id="3️⃣-클라이언트-컴포넌트는-언제-써야-할까">3️⃣ 클라이언트 컴포넌트는 언제 써야 할까?</h2>
<blockquote>
<p>브라우저에서만 가능한 동작이 있다면 클라이언트 컴포넌트로 만든다.</p>
</blockquote>
<ul>
<li>React의 <strong>Hook(<code>useState</code>, <code>useEffect</code>)</strong> 같은 것들은 브라우저 환경에서만 동작하는 것</li>
<li>또는 사용자의 입력, 클릭, 드래그 등과 같은 <strong>“상호작용(interaction)”</strong>이 필요한 부분</li>
</ul>
<p><strong>권장사항</strong></p>
<ul>
<li>페이지의 대부분을 서버 컴포넌트로 구성할 것</li>
<li>클라이언트 컴포넌트는 꼭 필요한 경우에만 사용할 것</li>
</ul>
<blockquote>
<p>구체적으로 구분하자면, <strong>“상호작용이 있어야 하면”</strong> 클라이언트 컴포넌트로 만들고 그렇지 않다면 서버 컴포넌트로 만들면 된다.</p>
</blockquote>
<hr>
<h2 id="4️⃣-검색창-만들기">4️⃣ 검색창 만들기</h2>
<p>검색창은 사용자가 검색어를 입력하면 <code>state</code>가 변하기 떄문에 대표적인 클라이언트 컴포넌트이다.</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import React, { useState } from &#39;react&#39;;

export default function Searchbar() {
  const [search, setSearch] = useState(&#39;&#39;);

  const onChangeSearch = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    setSearch(e.target.value);
  };

  return (
    &lt;div&gt;
      &lt;input value={search} onChange={onChangeSearch} /&gt;
      &lt;button&gt;검색&lt;/button&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>이렇게 <code>&#39;use client&#39;</code>를 선언하면 Next.js가 해당 파일을 브라우저용으로 번들링해서 클라이언트에서 동작하도록 만들어준다.</p>
<hr>
<h2 id="5️⃣-co-location">5️⃣ Co-Location</h2>
<blockquote>
<p>컴포넌트를 어디에 둬야 할까?</p>
</blockquote>
<p>App Router에서는 <code>page.tsx</code>, <code>layout.tsx</code>처럼 특별한 예약 파일명을 제외하면 그 외의 파일은 일반적인 JS/TS 파일로 간주한다.
그래서 <code>app</code> 폴더 안에서도 자유롭게 컴포넌트를 만들어둘 수 있다.</p>
<blockquote>
<p>이러한 패턴을 <strong>Co-Location</strong>이라고 부른다.</p>
</blockquote>
<p>즉, 페이지와 관련된 컴포넌트를 같은 위치에 함께 배치할 수 있다는 뜻이다! (ex. <code>app/(with-searchbar)/search/components/Searchbar.tsx</code> 등)
이러한 구조 덕분에 관련된 코드끼리 관리할 수 있어서 <strong>코드 가독성</strong>과 <strong>유지보수성</strong>에 도움이 된다.</p>
<hr>
<h1 id="2-리액트-서버-컴포넌트-주의-사항">2. 리액트 서버 컴포넌트 주의 사항</h1>
<h2 id="1️⃣-서버-컴포넌트에는-브라우저에서-실행될-코드가-포함되면-안된다">1️⃣ 서버 컴포넌트에는 브라우저에서 실행될 코드가 포함되면 안된다.</h2>
<p>서버 컴포넌트는 이름 그대로 서버에서만 실행된다.
그래서 아래와 같은 <strong>브라우저 전용 코드</strong>들은 포함될 수 없음!</p>
<ul>
<li>브라우저에서만 동작하는 React Hook(<code>useState</code>, <code>useEffect</code> 등)</li>
<li>클릭, 입력 같은 이벤트 핸들러</li>
<li>브라우저 전용 라이브러리 (예: DOM 조작 라이브러리)</li>
</ul>
<p>이런 코드는 반드시 <code>&#39;use client&#39;</code>를 선언한 클라이언트 컴포넌트 안에서만 사용해야 한다.</p>
<blockquote>
<p>서버 컴포넌트는 **&quot;렌더링 결과만 반환하는 순수한 UI 정의 영역&quot;</p>
</blockquote>
<hr>
<h2 id="2️⃣-클라이언트-컴포넌트는-클라이언트에서만-실행되지-않는다">2️⃣ 클라이언트 컴포넌트는 클라이언트에서만 실행되지 않는다.</h2>
<p>클라이언트 컴포넌트는 <strong>서버에서 한 번, 클라이언트에서 한 번</strong>, 총 두 번 실행된다.</p>
<table>
<thead>
<tr>
<th>실행 위치</th>
<th>실행 이유</th>
</tr>
</thead>
<tbody><tr>
<td>서버</td>
<td>사전 렌더링(Pre-rendering)을 위해</td>
</tr>
<tr>
<td>클라이언트</td>
<td>하이드레이션(Hydration)을 위해</td>
</tr>
</tbody></table>
<blockquote>
<p>즉, 서버와 브라우저 양쪽에서도 실행된다!</p>
</blockquote>
<hr>
<h2 id="3️⃣-클라이언트-컴포넌트에서-서버-컴포넌를-import-하면-안-된다">3️⃣ 클라이언트 컴포넌트에서 서버 컴포넌를 <code>import</code> 하면 안 된다.</h2>
<p>클라이언트 컴포넌트는 앞서 말했듯 서버와 브라우저 모두에서 실행된다.
그런데 이 컴포넌트 안에서 서버 컴포넌트를 <code>import</code> 하게 되면 어떻게 될까?</p>
<blockquote>
<p>❌ 브라우저에서도 서버 컴포넌트를 실행하려 하기 때문에 &quot;런타입 에러&quot;가 발생한다.</p>
</blockquote>
<p>Next.js는 이런 경우를 해결하기 위해, <strong>서버 컴포넌트를 자동으로 클라이언트 컴포넌트로 변환</strong>해버린다. 하지만 이건 일시적인 해결책으로 좋은 해결 방식은 아니다.</p>
<blockquote>
<p><strong>💡 권장 방식</strong>
클라이언트 컴포넌트에서 서버 컴포넌트를 직접 <code>import</code> 하지 말고,
<code>props</code>로 <code>ReactNode</code> 타입의 <code>children</code>을 받아 렌더링하는 방식을 사용한다.</p>
</blockquote>
<p>이 방식을 사용하면 서버 컴포넌트가 클라이언트 컴포넌트 안에서도 안전하게 렌더링된다.</p>
<hr>
<h2 id="4️⃣-서버-컴포넌트에서-클라이언트-컴포넌트에게-직렬화-불가능한-props를-전달하면-안된다">4️⃣ 서버 컴포넌트에서 클라이언트 컴포넌트에게 &quot;직렬화 불가능한 Props&quot;를 전달하면 안된다.</h2>
<p>서버 컴포넌트는 렌더링 결과를 <strong>직렬화(serialize)</strong>해서 클라이언트로 전달한다.
즉, 데이터는 문자열(JSON 형태)로 변환되어야 클라이언트 컴포넌트로 전달된다.</p>
<p>그런데 만약 <em><strong>직렬화 불가능한 데이터를 <code>props</code>로 전달한다면?</strong></em></p>
<ul>
<li>함수</li>
<li>클래스 인스턴스</li>
<li><code>Symbol</code></li>
<li>복잡한 클로저나 참조 타입</li>
</ul>
<blockquote>
<p>❌ <code>Error: A function cannot be passed to a Client Component from a Server Component.</code>
즉, 서버에서 <strong>&quot;브라우저가 이해할 수 없는 형태의 데이터&quot;</strong>를 넘기려 하면 에러가 발생한다.</p>
</blockquote>
<pre><code class="language-tsx">import ClientComponent from &#39;./ClientComponent&#39;;

export default function ServerComponent() {
  const handleClick = () =&gt; alert(&#39;Click!&#39;); // ❌ 함수는 직렬화 불가
  return &lt;ClientComponent onClick={handleClick} /&gt;;
}</code></pre>
<p>함수는 브라우저로 보낼 수 없기 때문에 에러가 발생한다.</p>
<blockquote>
<p><strong>✅ 해결 방법</strong>
브라우저에서 실행되어야 하는 로직이라면, 클라이언트 컴포넌트 내부에서 직접 정의애햐 한다.</p>
</blockquote>
<pre><code class="language-tsx">&#39;use client&#39;;
export default function ClientComponent() {
  const handleClick = () =&gt; alert(&#39;클라이언트에서 실행!&#39;);
  return &lt;button onClick={handleClick}&gt;클릭&lt;/button&gt;;
}</code></pre>
<hr>
<h3 id="💡rsc-payload란">💡RSC Payload란?</h3>
<blockquote>
<p>React Server Component의 순수한 결과물로, <strong>&quot;서버 컴포넌트를 직렬화한 데이터 덩어리&quot;</strong>로 볼 수 있다.</p>
</blockquote>
<p><code>RSC Payload</code>에는 서버 컴포넌트의 모든 데이터가 포함된다.</p>
<ol>
<li>서버 컴포넌트의 렌더링 결과<ul>
<li>서버에서 사전 렌더링된 HTML 정보</li>
</ul>
</li>
<li>연결된 클라이언트 컴포넌트의 위치<ul>
<li>어디에 어떤 클라이언트 컴포넌트가 들어갈지에 대한 매핑 정보</li>
</ul>
</li>
<li>클라이언트 컴포넌트로 전달할 <code>Props</code> 값<ul>
<li>상호작용을 위해 필요한 <code>props</code> 데이터</li>
</ul>
</li>
</ol>
<p>즉, 서버는 RSC Payload를 만들어 브라우저에게 보내고, 브라우저는 그골 벋어 <strong>하이드레이션을 진행</strong>하며, <strong>&quot;서버 + 클라이언트가 연결된 완전한 페이지&quot;</strong>를 구성하는 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 4. App Router 시작하기(1)]]></title>
            <link>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B01</link>
            <guid>https://velog.io/@ol_minje/Section-4.-App-Router-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B01</guid>
            <pubDate>Mon, 13 Oct 2025 07:09:16 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="1️⃣-프로젝트-생성하기">1️⃣ 프로젝트 생성하기</h1>
<p>먼저 아래 명령어로 새로운 Next.js 프로젝트를 만들어준다.</p>
<pre><code class="language-bash">npx create-next-app@latest section03</code></pre>
<p>설치 중에 나오는 질문 중</p>
<blockquote>
<p><strong>Would you like to use App Router?</strong></p>
</blockquote>
<p>👉 Yes 를 선택!</p>
<p>App Router를 사용하면, 기존에 있던 pages 폴더가 사라지고 app 폴더 구조로 전환된다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/ba292398-9097-470f-9fb7-94604e5e48ca/image.png" alt="app router 폴더 구조"></p>
<blockquote>
<p>✅ App Router는 Next.js 13부터 새롭게 도입된 방식으로, 서버 컴포넌트 기반의 훨씬 유연한 구조를 제공한다.</p>
</blockquote>
<hr>
<h1 id="2️⃣-페이지-라우팅-설정하기">2️⃣ 페이지 라우팅 설정하기</h1>
<blockquote>
<p>App Router에서 페이지 라우팅은 어떻게 다를까?</p>
</blockquote>
<p>기존 Page Router에서는 <code>index.tsx</code>를 만들어 페이지를 구성했지만, 이제는 파일 이름이 <code>page.tsx</code> 로 바뀌었다.
즉, 경로마다 <code>page.tsx</code> 파일을 만들어주면 그게 바로 “페이지”가 된다.</p>
<blockquote>
<p>App Router에서는 Query String이나 URL 파라미터 같이 경로 상에 포함되는 값은 <code>page.tsx</code> 컴포넌트의 <code>props</code>로 전달된다는 것을 기억하며 살펴보자!</p>
</blockquote>
<h2 id="1-url-파라미터-설정하기">(1) URL 파라미터 설정하기</h2>
<p>예전처럼 <code>[id].tsx</code> 파일을 만들지 않고, 이제는 <code>[id]</code>라는 폴더를 생성한 후, 그 안에 <code>page.tsx</code>를 만들어야 한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/58d8ca46-60f9-4e50-9ef1-a57225f2aee4/image.png" alt="URL 파라미터 폴더 구조"></p>
<pre><code class="language-tsx">export default function Page() {
  return &lt;div&gt;book/[id] page 입니다&lt;/div&gt;;
}</code></pre>
<p>실행하면 아래와 같은 결과를 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/54fff3d0-17b1-41da-aae4-84a74aecf581/image.png" alt="URL 파라미터 실행 결과"></p>
<blockquote>
<p>URL 파라미터 값을 꺼내서 사용해보자!</p>
</blockquote>
<pre><code class="language-tsx">export default async function Page({ params }: { params: { id: string } }) {
  const { id } = params;
  return &lt;div&gt;book/[id] page: {id}&lt;/div&gt;;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/c0bc18f5-52d1-413b-8139-02026f96bdd3/image.png" alt="URL 파라미터 값 꺼내기 결과"></p>
<blockquote>
<p><strong>✅ 정리하며</strong></p>
</blockquote>
<ul>
<li><code>params</code>는 URL 경로의 파라미터 값을 Promise 형태로 전달해준다.</li>
<li>세그먼트(<code>...id</code>)도 동일하게 사용할 수 있지만, 경로가 없을 수도 있다면 <code>[[...id]]</code> 로 설정해주는 게 안전하다.</li>
</ul>
<hr>
<h2 id="2-query-string-설정하기">(2) Query String 설정하기</h2>
<p><code>props</code>를 콘솔로 출력해보면, <code>params: Promise {...}, searchParams: Promise  {...}</code> 의 객체 형태의 값을 확인할 수 있다.</p>
<pre><code class="language-tsx">export default async function Page({ searchParams }: { searchParams: { q: string } }) {
  const { q } = searchParams;
  return &lt;div&gt;검색 페이지: {q}&lt;/div&gt;;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/69b63ef8-230f-4fe6-a415-681e8b224d4f/image.png" alt="쿼리 스트링 실행 결과"></p>
<blockquote>
<p>참고로 함수형 컴포넌트에 async를 붙일 수 있는 이유는, App Router의 컴포넌트가 서버 컴포넌트(Server Component) 로 동작하기 때문!</p>
</blockquote>
<hr>
<h1 id="3️⃣-레이아웃-설정하기">3️⃣ 레이아웃 설정하기</h1>
<p>App Router는 페이지마다 반복되는 UI를 효율적으로 관리할 수 있다.</p>
<h2 id="1-공통된-경로의-레이아웃-만들기">(1) 공통된 경로의 레이아웃 만들기</h2>
<p>특정 경로에 공통된 UI를 적용하려면, 해당 폴더에 <code>layout.tsx</code> 파일을 추가하면 된다.</p>
<pre><code class="language-tsx">export default function Layuout({ children }: { children: React.ReactNode }) {
  return (
    &lt;div&gt;
      &lt;div&gt;임시 서치바&lt;/div&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<blockquote>
<p><code>layout.tsx</code> → <code>page.tsx</code> 순으로 실행되며, 하위 경로에도 자동으로 적용됨.</p>
</blockquote>
<p>하위 폴더(<code>setting</code>)에도 <code>layout.tsx</code>를 추가하면, 두 개의 레이아웃이 중첩 적용된다.</p>
<pre><code class="language-tsx">export default function Page() {
  return &lt;div&gt;search/setting&lt;/div&gt;;
}</code></pre>
<pre><code class="language-tsx">export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    &lt;div&gt;
      &lt;div&gt;세팅 헤더&lt;/div&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/789e2a02-603a-4b06-8d75-251baaf848a1/image.png" alt="하위 폴더 레이아웃 설정 결과"></p>
<blockquote>
<p>중첩 레이아웃까지 적용된 것을 확인할 수 있다.</p>
</blockquote>
<ul>
<li>⚠️ 루트의 <code>layout.tsx</code>는 절대 삭제하면 안 된다.(앱 전체의 기본 구조를 담당)</li>
</ul>
<hr>
<h2 id="2-공통된-경로가-아닌-페이지끼리-레이아웃-공유하기">(2) 공통된 경로가 아닌 페이지끼리 레이아웃 공유하기</h2>
<p>이번엔 서로 다른 경로지만 공통 레이아웃을 적용하고 싶을 때!
이럴 땐 <strong>Route Group</strong> 기능을 사용한다.</p>
<blockquote>
<p><code>(폴더명)</code> 형태로 작성하면, 해당 폴더는 실제 URL 경로에는 포함되지 않음 👇
<img src="https://velog.velcdn.com/images/ol_minje/post/678fc0e2-c416-4aea-ac3e-f25063262e23/image.png" alt="라우트 그룹 폴더 설정 예시"></p>
</blockquote>
<ul>
<li>경로에 영향을 주지 않기 떄문에 경로가 정상적으로 작동한다. </li>
<li>공통 레이아웃이 필요한 페이지들을 생성한 폴더에 넣은 후, 레이아웃을 설정할 수 있다.<blockquote>
</blockquote>
<img src="https://velog.velcdn.com/images/ol_minje/post/6e64ee96-ea71-41f7-96bb-361a629b687d/image.png" alt="라우트 그룹 폴더내 파일 배치 예시"></li>
</ul>
<p>실행 후 확인해보자! 👇</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/bd034913-0cbe-442e-a140-d77d994ef3f3/image.png" alt="라우트 그룹 실행 결과 1번"></p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/80895cad-244d-445f-a14d-2862ebc4c373/image.png" alt="라우트 그룹 실행 결과 2번"></p>
<p>경로가 달라도 공통 레이아웃이 잘 적용됨! (Route Group 덕분에 경로는 깔끔하게, 구조는 유연하게 유지~)</p>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li>App Router는 <code>page.tsx</code>와 <code>layout.tsx</code>로 구성</li>
<li>URL 파라미터는 <code>params</code>, 쿼리 스트링은 <code>searchParams</code>로 접근</li>
<li>레이아웃은 폴더 구조 기반으로 자동 적용</li>
<li>Route Group으로 경로에 영향 없이 공통 UI 관리 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(끝)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC%EB%81%9D</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC%EB%81%9D</guid>
            <pubDate>Thu, 02 Oct 2025 07:34:48 GMT</pubDate>
            <description><![CDATA[<p>_인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강_\</p>
<h2 id="1-기본-특징">1. 기본 특징</h2>
<ul>
<li>Page Router는 <strong>파일 시스템 기반 라우팅</strong>을 제공하여 <code>pages</code> 폴더 안에 파일을 생성하면 자동으로 라우팅됨.<ul>
<li>예: <code>pages/index.tsx</code> → <code>/</code></li>
<li><code>pages/search.tsx</code> → <code>/search</code></li>
<li><code>pages/book/[id].tsx</code> → <code>/book/:id</code></li>
</ul>
</li>
<li><strong>장점</strong><ul>
<li>별도의 라우팅 코드가 거의 필요 없음.</li>
<li>폴더 구조만으로 라우팅을 자유롭게 설정 가능.</li>
<li>URL 파라미터와 다이나믹 라우팅(<code>.id</code>)도 쉽게 지원.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-사전-렌더링-방식">2. 사전 렌더링 방식</h2>
<p>Page Router는 두 가지 사전 렌더링 방식을 제공:</p>
<ol>
<li><strong>SSR (Server-Side Rendering)</strong><ul>
<li>매 요청마다 서버에서 HTML을 새로 생성.</li>
<li>항상 최신 데이터를 보장하지만 응답 속도가 느려질 수 있음.</li>
</ul>
</li>
<li><strong>SSG (Static Site Generation)</strong><ul>
<li>빌드 시점에 HTML을 미리 생성.</li>
<li>빠른 응답 가능, 그러나 최신 데이터 반영 어려움.</li>
</ul>
</li>
<li><strong>ISR (Incremental Static Regeneration)</strong><ul>
<li>SSG 페이지를 일정 시간마다 갱신하거나 <code>on-demand</code>로 재생성 가능.</li>
<li>최신성과 속도를 동시에 어느 정도 만족시킴.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="3-단점">3. 단점</h2>
<ul>
<li><strong>레이아웃 설정의 불편함</strong> <ul>
<li>페이지별 레이아웃을 설정하려면 <code>getLayout</code> 같은 패턴을 직접 구현해야 함.</li>
<li>코드 중복과 복잡성이 생기기 쉬움.</li>
</ul>
</li>
<li><strong>데이터 패칭 제약</strong><ul>
<li><code>getServerSideProps</code>, <code>getStaticProps</code> 등 특수 함수 사용 필요.</li>
<li>데이터 전달 구조가 props 기반이라 중첩 컴포넌트까지 데이터 전달 시 복잡해짐.</li>
</ul>
</li>
<li><strong>불필요한 컴포넌트 렌더링 문제(= 불필요한 컴포넌트들도 JS Bundle에 포함된다)</strong><ul>
<li>서버 사이드 렌더링 시 모든 컴포넌트가 한 번에 실행.</li>
<li>상호작용(인터랙션)이 필요 없는 단순 렌더링 컴포넌트도 불필요하게 포함되어 성능 저하 유발.</li>
<li>이로 인해 hydration 단계에서 브라우저에서 한 번 더 실행되어 비효율적.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-정리">4. 정리</h2>
<ul>
<li><p>Page Router는 <strong>간단하고 직관적인 파일 기반 라우팅</strong>을 제공하며,</p>
<p>  <strong>SSR, SSG, ISR</strong> 세 가지 렌더링 방식을 지원하여 다양한 케이스에 대응 가능.</p>
</li>
<li><p>그러나 레이아웃 관리, 데이터 전달 구조의 한계, 불필요한 렌더링 등의 단점이 존재.</p>
</li>
<li><p>이 한계들을 보완하기 위해 <strong>App Router</strong>가 새롭게 등장.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(8)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC8</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC8</guid>
            <pubDate>Thu, 02 Oct 2025 07:32:49 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="seo-설정하기">SEO 설정하기</h1>
<h2 id="1-기본-seo-설정">1. 기본 SEO 설정</h2>
<p>Next.js에서는 <code>next/head</code>에서 제공하는 <code>Head</code> 컴포넌트를 불러와서 SEO 설정을 해줄 수. 있다.</p>
<pre><code class="language-tsx">import Head from &#39;next/head&#39;;

export default function Home({ allBooks, randomBooks }) {
  return (
    &lt;Head&gt;
      {/* 페이지 이름 */}
      &lt;title&gt;한입북스&lt;/title&gt;

      {/* 오픈 그래프 태그 */}
      {/* 썸네일 이미지는 public 폴더 기준 경로 */}
      &lt;meta property=&quot;og:image&quot; content=&quot;/thumbnail.png&quot; /&gt;

      {/* 타이틀 */}
      &lt;meta property=&quot;og:title&quot; content=&quot;한입북스&quot; /&gt;

      {/* 설명 */}
      &lt;meta property=&quot;og:description&quot; content=&quot;한입북스에 등록된 도서들을 만나보세요&quot; /&gt;
    &lt;/Head&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/8ded8b55-9499-47d1-a62a-f0404716555a/image.png" alt="기본 SEO 설정하기"></p>
<p>✅ 요약하면,</p>
<ul>
<li><code>&lt;title&gt;</code> : 페이지 제목</li>
<li><code>&lt;meta property=&quot;og:image&quot;&gt;</code> : 미리보기 썸네일</li>
<li><code>&lt;meta property=&quot;og:title&quot;&gt;</code> : 공유될 때 보여줄 제목</li>
<li><code>&lt;meta property=&quot;og:description&quot;&gt;</code> : 공유될 때 보여줄 설명</li>
</ul>
<p>이렇게만 넣어도 기본적인 SEO는 끝!</p>
<hr>
<h2 id="2-상세-페이지에-seo-반영하기">2. 상세 페이지에 SEO 반영하기</h2>
<p>상세 페이지도 마찬가지다. 다만 책 데이터마다 내용이 다르니 동적으로 바꿔주는게 제일 좋다!</p>
<pre><code class="language-tsx">import { useRouter } from &#39;next/router&#39;;

export default function Page({ book }) {
  const router = useRouter();
  if (router.isFallback) return &#39;로딩 중입니다...&#39;;

  const { title, description, coverImgUrl } = book;

  return (
    &lt;Head&gt;
      &lt;title&gt;{title}&lt;/title&gt;
      &lt;meta property=&quot;og:image&quot; content={coverImgUrl} /&gt;
      &lt;meta property=&quot;og:title&quot; content={title} /&gt;
      &lt;meta property=&quot;og:description&quot; content={description} /&gt;
    &lt;/Head&gt;
  );
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/57b2e621-5bf8-4e3f-a4b5-201377528e2b/image.png" alt="상세 SEO 반영하기"></p>
<p>이렇게 하면 서버에서 받아온 데이터로, 책에 맞는 메타태그가 적용됨!</p>
<h3 id="⚠️-주의할-점-seo가-반영되지-않는-단점">⚠️ 주의할 점: SEO가 반영되지 않는 단점</h3>
<p>SSG(동적 경로) + <code>fallback: true</code> 옵션을 사용했을 때는 빌드 타임에 페이지가 미리 만들어지지 않아서, 페이지에 처음 접근하면 SEO 메타 태그가 누락될 수 있다.</p>
<blockquote>
<p>즉, 페이지 처음 요청 시엔 <code>&quot;로딩 중입니다...&quot;</code> 같은 상태만 보이고, 메타태그가 적용되지 않음.
  <img src="https://velog.velcdn.com/images/ol_minje/post/ac1e748c-362e-462f-9100-39a39b8d2acc/image.png" alt="누락된 SEO"></p>
</blockquote>
<p><strong>이럴 땐 <code>isFallback</code> 상태일 때도 기본 메타태그를 넣어주면 된다.</strong></p>
<pre><code class="language-tsx">if (router.isFallback) {
  return (
    &lt;&gt;
      &lt;Head&gt;
        &lt;title&gt;한입북스&lt;/title&gt;
        &lt;meta property=&quot;og:image&quot; content=&quot;/thumbnail.png&quot; /&gt;
        &lt;meta property=&quot;og:title&quot; content=&quot;한입북스&quot; /&gt;
        &lt;meta property=&quot;og:description&quot; content=&quot;한입북스에 등록된 도서들을 만나보세요&quot; /&gt;
      &lt;/Head&gt;
      &lt;div&gt;로딩중입니다...&lt;/div&gt;
    &lt;/&gt;
  );
}</code></pre>
<blockquote>
<p>이렇게 하면 로딩 상태일 때도 기본적인 SEO가 유지된다.</p>
</blockquote>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li><code>next/head</code>를 이용해 <code>&lt;title&gt;</code>과 <code>&lt;meta&gt;</code> 태그로 기본적인 SEO 설정 가능</li>
<li>오픈 그래프 태그(<code>og:image</code>, <code>og:title</code>, <code>og:description</code>) 활용하면 SNS 공유시 예쁘게 뜸</li>
<li><code>fallback</code>: <code>true</code>일 때는 SEO가 빠질 수 있으므로 <code>isFallback</code> 조건 처리로 보완</li>
</ul>
<blockquote>
<p>SEO도 &quot;페이지를 어떻게 보여줄까?&quot;를 제어하는 거라, 로딩 상태까지 처리해줘야 한다는 사실!을 처음 알았네요...</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(7)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC7</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC7</guid>
            <pubDate>Wed, 01 Oct 2025 04:46:19 GMT</pubDate>
            <description><![CDATA[<p>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</p>
<h1 id="isrincremental-static-regeneration">ISR(Incremental Static Regeneration)</h1>
<h2 id="isr이란">ISR이란?</h2>
<blockquote>
<p><strong>ISR(증분 정적 재생성)</strong>은 간단히 말해 SSG 방식으로 생성된 정적 페이지를 일정 시간 주기로 다시 생성하는 기술이다.</p>
</blockquote>
<p>즉, SSG처럼 미리 만들어진 페이지를 빠르게 응답해주면서, 주기적으로 새로운 데이터를 반영할 수 있는 것으로,
👉 <strong>SSG의 장점(빠른 속도) + SSR의 장점(최신 데이터 반영)</strong>까지 합친 방식!</p>
<h2 id="isr-동작-흐름">ISR 동작 흐름</h2>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/f2212dfe-1a29-465a-97d6-9e9835333cdc/image.png" alt="ISR 동작 흐름 예시 이미지 1번"></p>
<p>이미지 기준으로 </p>
<ul>
<li><strong>빌드 타임(0초):</strong> 기존 페이지(V1) 생성</li>
<li><strong>60초 이후:</strong> 브라우저 요청이 들어오면 <strong>새로운 페이지(V2) 재생성</strong></li>
<li><strong>이후 요청부터는 최신 버전(V2)</strong> 응답</li>
</ul>
<p>✅ 이미 만들어둔 페이지를 전달해주니 속도는 빠르고, 일정 주기마다 업데이트가 되니까 데이터도 최신으로 유지할 수 있다!</p>
<h3 id="실습-코드">실습 코드</h3>
<pre><code class="language-tsx">export const getStaticProps = async () =&gt; {
  const [allBooks, randomBooks] = await Promise.all([
    fetchBooks(),
    fetchRandomBooks()
  ]);
  return {
    props: {
      allBooks,
      randomBooks,
    },
    revalidate: 3,
  };
};</code></pre>
<p>여기에서 <code>revalidate: 3</code>을 추가, 이 값은 <strong>재생성할 주기(초 단위)</strong>를 의미한다.</p>
<p>빌드 후 로그를 보면,
<img src="https://velog.velcdn.com/images/ol_minje/post/67a189b3-94ff-4fbe-b6a4-e335218de02d/image.png" alt="ISR 빌드 로그"></p>
<pre><code class="language-bash">ISR: 3 Seconds</code></pre>
<p>3s마다 새롭게 데이터를 가져와 페이지를 업데이트한다는 뜻으로, 실제 실행 후 3초 주기로 새로고침하면 리스트 데이터가 달라지는 걸 확인할 수 있다.</p>
<hr>
<h1 id="주문형-재검증on-demand-isr">주문형 재검증(On-Demand ISR)</h1>
<p>하지만 위처럼 모든 페이지가 시간 단위로만 갱신되면 불편한 경우가 있다.</p>
<p>🥹: 사용하즤 특정 행동(데이터 수정, 글 작성 등)에 맞춰서 즉시 업데이트가 필요할 때는 어떻게 하나요?
😎: 이때 사용하는 게 <strong>On-Demand ISR</strong>입니다.</p>
<blockquote>
<p>브라우저 요청마다 페이지를 다시 생성하는 SSR은 부담이 크니, 개발자가 직접 트리거(Trigget)를 걸어 주는 방식이다.</p>
</blockquote>
<h2 id="isr-방식-구분하기">ISR 방식 구분하기</h2>
<ul>
<li><strong>시간 기반 ISR:</strong> 일정 시간마다 자동 업데이트</li>
<li><strong>요청 기반 ISR:</strong> 필요할 때마다 직접 페이지 재생성</li>
</ul>
<h3 id="실습-코드-1">실습 코드</h3>
<pre><code class="language-tsx">import { NextApiRequest, NextApiResponse } from &#39;next&#39;;

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    await res.revalidate(&#39;/&#39;); 
    return res.json({ revalidate: true });
  } catch (error) {
    console.error(error);
    res.status(500).send(&#39;Revalidation Failed&#39;);
  }
}</code></pre>
<p>여기서 핵심은 <code>res.revalidate()</code> 함수이다.
이 함수는 인수로 <strong>&quot;어떤 경로를 <code>revalidate</code> 할지</strong> 지정해주면 된다.
예시에서는 <code>/</code> 경로를 갱신하도록 진행!</p>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li>ISR = 정적 페이지 + 최신성 보장</li>
<li><code>revalidate</code> 옵션으로 일정 주기마다 페이지를 재생성</li>
<li>필요할 때는 On-Demand ISR로 개발자가 직접 트리거 가능!!</li>
</ul>
<blockquote>
<p>빠르면서도 최신 데이터를 유지하는 방식이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(6)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC6</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC6</guid>
            <pubDate>Tue, 30 Sep 2025 07:21:45 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="1-ssg란">1. SSG란?</h1>
<blockquote>
<p><strong>정적 사이트 생성(SSG)</strong>은 SSR의 단점을 보완하는 사전 렌더링 방식이다.
즉, 빌드 타임에 페이지를 미리 생성해두는 것!!</p>
</blockquote>
<p><strong>서버가 실행되기 전</strong>에 모든 페이지를 미리 만들어두는 방식이라, 사용자가 접속하면 서버는 그냥 미리 준비된 HTML을 <strong>&quot;빠르게&quot;</strong> 전달해준다.
이 방식은 빌드 시간이 오래 걸려도 <del>개발자만 겪는 고통</del>이라, 사용자 입장에서는 항상 빠르게 응답받을 수 있다.</p>
<hr>
<h2 id="ssg의-장점--단점">SSG의 장점 &amp; 단점</h2>
<h3 id="장점">장점</h3>
<ul>
<li>빠른 속도: 페이지를 미리 만들어두니 요청 즉시 응답이 가능하다.</li>
<li>안정성: 매번 서버 연산이 없어도 동일한 결과를 제공한다</li>
</ul>
<h3 id="단점">단점</h3>
<p>데이터 최신화가 어렵다.</p>
<ul>
<li>빌드 이후에는 페이지가 고정돼버려서, 새로운 요청이 와도 항상 같은 응답만 전달된다.</li>
<li>문제가 되는건 아니지만, <strong>최신 데이터를 반영하기 힘들다</strong>는게 아쉬운 점!</li>
</ul>
<hr>
<h1 id="2-ssg-적용해보기">2. SSG 적용해보기</h1>
<p>이전 게시글에서 다룬 <code>getServerSideProps</code>와 동일한 방식으로, SSG 전용 함수인 <strong><code>getStaticProps</code></strong>와 <strong><code>getStaticPaths</code></strong>를 사용하면 된다.</p>
<hr>
<h2 id="getstaticprops"><code>getStaticProps</code></h2>
<blockquote>
<p>빌드 타임에 필요한 데이터를 미리 불러와 페이지에 <code>props</code>로 전달해준다.</p>
</blockquote>
<pre><code class="language-tsx">//...
import { InferGetServerSidePropsType } from &#39;next&#39;;
import { ReactNode } from &#39;react&#39;;

export const getStaticProps = async () =&gt; {
  const [allBooks, randomBooks] = await Promise.all([fetchBooks(), fetchRandomBooks()]);
  return {
    props: {
      allBooks,
      randomBooks,
    },
  };
};

export default function Home({
  allBooks,
  randomBooks,
}: InferGetStaticPropsType&lt;typeof getStaticProps&gt;) {
//...</code></pre>
<p>여기에서 <code>InferGetStaticPropsType</code>의 역할은 <code>InferGetServerSidePropsType</code>와 동일하게 자동으로 타입을 추론해서 설정해준다.</p>
<hr>
<h3 id="빌드-타임에-딱-한-번-실행되는지-확인하기">빌드 타임에 딱 한 번 실행되는지 확인하기</h3>
<pre><code class="language-tsx">export const getStaticProps = async () =&gt; {
  console.log(&#39;인덱스 페이지&#39;);
  const [allBooks, randomBooks] = await Promise.all([fetchBooks(), fetchRandomBooks()]);
  return {
    props: {
      allBooks,
      randomBooks,
    },
  };
};</code></pre>
<p>해당 페이지를 새로고침 하면, 새로고침 할때마다 콘솔에 작성한 내용이 출력된다. </p>
<p>😎: 어라 한 번만 실행되야 하는거 아닌가요?
✍️: 개발 모드로 실행할 때는 수정 결과가 바로 반영되기 때문에, 렌더링 방식이 달라도 요청이 달라도 계속 사전 렌더링을 진행합니당! 그러니 Production(<code>npm run build</code>)로 실행해야 해용!</p>
<blockquote>
<p>SSG 설정을 적용한 뒤 <code>npm run build</code>를 실행하여 콘솔 확인해보기</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/e59b5287-1add-4377-bbfa-33143b1fd775/image.png" alt="npm run build로 역할 알아보기"></p>
<ul>
<li><code>Generating static pages</code> 👉 <code>getStaticProps</code>를 사용한 페이지들이 실제 정적 파일로 생성되는 걸 확인할 수 있음.</li>
<li>이때 “인덱스 페이지” 같은 콘솔 메시지가 보였다면, 작성했던 함수(<code>getStaticProps</code>)가 빌드 타임에 실행된 게 맞다는 증거!</li>
</ul>
<blockquote>
<h3 id="●-○-f-심볼의-의미">● ○ f 심볼의 의미</h3>
<p>빌드 로그 맨 아래를 보면 페이지마다 ●, ○, f 표시가 붙어 있는데, 이건 해당 페이지가 어떤 방식으로 사전 렌더링되었는지 알려주는 표식이다.</p>
<p><strong>● (Static)</strong></p>
<ul>
<li>그냥 정적 페이지. <code>getStaticProps</code> 없이도 기본적으로 제공되는 정적 페이지.</li>
</ul>
<p><strong>○ (SSG)</strong></p>
<ul>
<li>getStaticProps를 사용해 SSG로 생성된 페이지.</li>
<li>빌드 타임에 데이터 패칭까지 끝내고 정적으로 만들어진 페이지.</li>
</ul>
<p><strong>ƒ (Dynamic)</strong></p>
<ul>
<li>동적으로 서버 사이드 렌더링(SSR)되는 페이지.</li>
<li>주문형(On demand)으로, 브라우저 요청이 들어올 때마다 페이지가 새로 사전 렌더링 됨.</li>
</ul>
<hr>
<h3 id="api-routes의-기본-동작">API Routes의 기본 동작</h3>
<p>여기에서 <code>API Routes</code>는 자동으로 SSG 동작한다. 즉, API 라우트에 대해서는 매 요청마다 서버에서 새롭게 실행된다는 점!</p>
<hr>
<h3 id="production-모드에서-확인하기-🚀">Production 모드에서 확인하기 🚀</h3>
<p><code>npm run build</code> 후 <code>npm run start</code>로 production 모드를 실행하면, SSG, SSR로 설정한 기본 정적 페이지들이 각각 어떤 방식으로 동작하는지 브라우저에서 직접 확인할 수 있다.</p>
</blockquote>
<hr>
<h3 id="🚨-ssg에서-query-string은-왜-못-쓸까">🚨 SSG에서 Query String은 왜 못 쓸까?</h3>
<ul>
<li><code>Query String</code>(?q=검색어)이라는 건 브라우저가 요청을 보낼 때 URL에 붙여서 전달하는 값</li>
<li>그런데 SSG의 <code>getStaticProps</code>는 빌드 타임에 실행된다. </li>
</ul>
<p><em>빌드 타임에는 브라우저 요청이 아예 없으니<code>query</code>를 알 수가 없어서, <code>GetStaticPathsContext</code> 타입에 <code>query</code>가 아예 정의되어 있지 않음</em></p>
<blockquote>
<p>이를 해결하기 위해서는, 빌드 타임에서 <code>query</code> 값을 알아야 하는데 이건 불가능하다.</p>
</blockquote>
<p>😎: 정적인 페이지를 생성할 때 <code>query string</code>을 활용할 수 없으면 어떻게 동작시켜요?
✍️: 사전 렌더링이 끝난 뒤, 클라이언트 측에서 직접 <code>query</code>를 읽고 처리해야 한다. (아래의 방식처럼 해결하면 된다!)</p>
<pre><code class="language-tsx">//...
export default function Page() {
  const [books, setBooks] = useState&lt;BookData[]&gt;([]);

  const router = useRouter();
  const q = router.query.q as string;

  const fetchSearchResult = async () =&gt; {
    const data = await fetchBooks(q);
    setBooks(data);
  };

  useEffect(() =&gt; {
    if (q) {
      fetchSearchResult();
    }
  }, [q]);

//...</code></pre>
<ol>
<li>SSG로 기본 HTML만 내려주고, </li>
<li>브라우저가 켜진 뒤 <code>useRouter</code> 훅을 이용해 <code>router.query</code>에서 값을 꺼내와서 </li>
<li>렌더링하는 식으로 처리</li>
</ol>
<p>데이터 관련 부분을 제외하고 나머지 부분만 렌더링해서 브라우저에게 보내주는 걸 확인할 수 있으며,</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/9d72b0d9-ee85-4139-8c6f-006e7734096e/image.png" alt="결과 확인"></p>
<p>사진 속 결과처럼 <code>useEffect</code>를 통해서 서버에게 데이터를 직접 요청하는 방식인 SSR으로 동작하는 걸 확인할 수 있다.</p>
<hr>
<h2 id="getstaticpaths"><code>getStaticPaths</code></h2>
<blockquote>
<p>동적 라우팅 페이지(<code>/posts/[id]</code> 같은)에서 어떤 경로를 미리 빌드할지 정해줄 수 있다.</p>
</blockquote>
<pre><code class="language-tsx">//...
import type { GetStaticPathsContext, InferGetStaticPropsType } from &#39;next&#39;;
import { ReactNode } from &#39;react&#39;;

export const getStaticProps = async (context: GetStaticPathsContext) =&gt; {
  const q = context.query.q as string;
  const books = await fetchBooks(q);
  return {
    props: {
      books,
    },
  };
};

export default function Page({ books }: InferGetStaticPropsType&lt;typeof getStaticProps&gt;) {
</code></pre>
<p>이때 같은 방식으로 적용하고 실행하면 아래와 같은 오류가 발생한다.</p>
<pre><code class="language-bash">Error: getStaticPaths is required for dynamic SSG pages 
and is missing for &#39;/book/[id]&#39;.</code></pre>
<p>이는 SSG 방식이 &quot;어떤 경로들을 빌드 타임에 생성할지&quot;를 미리 알아야 하는데, <code>getSTataicPaths</code>가 없으니 Next.js가 어떤 페이지를 만들어야 하는지 몰라서 발생한 오류이다.</p>
<h3 id="✅-해결-방법-getstaticpaths-작성">✅ 해결 방법: getStaticPaths 작성</h3>
<pre><code class="language-tsx">export const getStaticPaths = () =&gt; {
  return {
    paths: [
      { params: { id: &#39;1&#39; } },
      { params: { id: &#39;2&#39; } },
      { params: { id: &#39;3&#39; } },
    ],
    fallback: false,
  };
};</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/db067796-fe12-40e6-8d1a-25132b1be0df/image.png" alt="getStaticPaths 과정"></p>
<ul>
<li><code>paths</code>: 빌드 타임에 미리 렌더링할 경로들을 배열로 작성(여기서는 /book/1 ~ /book/3)</li>
<li><code>params</code>: URL 파라미터를 객체 형태로 지정 (<strong>⚠️파라미터 값은 꼭 문자열로!</strong>)</li>
<li><code>fallback</code>: 설정하지 않은 경로로 접근했을 때 어떻게 처리할지 결정</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/d2baa0f9-288c-4aa1-acf4-1feb5fc24f01/image.png" alt="getStaticPaths 빌드 결과"></p>
<p>👉 이렇게 작성하면 <code>/book/1 ~ /book/6</code>은 빌드 타임에 정적 페이지로 만들어지고, 그 외 경로는 404가 뜨게 된다.</p>
<hr>
<h2 id="fallback-옵션"><code>fallback</code> 옵션</h2>
<p>위에서 사용한 <code>fallback</code> 옵션을 <code>false</code> 이외에도 유연하게 설정할 수 있다.</p>
<h3 id="fallback-false">fallback: false</h3>
<ul>
<li>설정하지 않은 경로 = 404 Not Found</li>
<li>아예 빌드 타임에 지정한 <code>path</code>만 접근 가능</li>
</ul>
<h3 id="fallback-blocking">fallback: &quot;blocking&quot;</h3>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/b3835bc6-4b36-4965-af8b-c8b41d30d902/image.png" alt="fallback의 blocking"></p>
<ul>
<li>지정하는 않은 경로에 접근하면, 서버에서 해당 페이지를 즉시 생성</li>
<li>생성이 완료된 후에야 브라우저에 전달됨</li>
<li>SSR처럼 동작하지만, 생성된 결과는 캐싱되어 이후에는 정적으로 제공한다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/ce42673f-1be3-42e5-aff4-de1a86a37b09/image.png" alt="fallback의 blocking 실행 결과"></p>
<blockquote>
<p><strong>⚠️ 주의할 점은</strong>
사전 렌더링 하는 시간이 길어지는 경우 브라우저에서 오랜 시간 기다려야 하는 상황이 발생할 수 있다. 이럴 때는 <code>true</code> 로 설정하면 된다.</p>
</blockquote>
<h3 id="fallback-true">fallback: true</h3>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/c43da2c9-17ba-4468-8577-26f7085a1abd/image.png" alt="fallback의 true 과정"></p>
<ul>
<li>지정하지 않은 경로 접근하는 경우, <strong>우선 “빈 페이지”를 보여주고</strong></li>
<li>백그라운드에서 데이터를 불러와 <strong>페이지를 완성한 뒤 교체</strong> (Hydration 느낌!)</li>
<li>즉시 응답이 가능하지만, 초기엔 <strong>“로딩 중” 같은 처리가 필요</strong></li>
</ul>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/661ba1df-3a6a-4b11-891f-5555fb084b33/image.png" alt="fallback의 true 실행 결과"></p>
<blockquote>
<p><strong><em>🔥 예외처리을 더 개선해자!</em></strong></p>
</blockquote>
<pre><code class="language-tsx">//...
export const getStaticProps = async (context: GetStaticPropsContext) =&gt; {
  const id = context.params!.id;
  const book = await fetchOneBook(Number(id));
&gt;
  if (!book) {
    return {
      notFound: true,
    };
  }
&gt;
  return {
    props: {
      book,
    },
  };
};
&gt;
export default function Page({ book }: InferGetStaticPropsType&lt;typeof getStaticProps&gt;) {
  const router = useRouter();
&gt;
  if (router.isFallback) return &#39;로딩 중입니다.&#39;;
&gt;
  const { title, subTitle, description, author, publisher, coverImgUrl } = book;
&gt;
  return (
 //...</code></pre>
<ul>
<li><code>useRouter</code>의 속성 <code>isFallback</code>으로 <code>fallback</code> 상태를 확인하여, <code>로딩 중</code>과 <code>404</code>를 구분해 사용자에게 보여줄 수 있다.
<img src="https://velog.velcdn.com/images/ol_minje/post/5a0d99cc-5a52-4dd7-9e2b-5c4b8f0739f0/image.png" alt="로딩 중 출력"></li>
</ul>
<hr>
<ul>
<li><code>404</code>인 경우에 <code>Not Found</code>로 이동할 수 있게 <code>noFound</code> 속성을 <code>true</code>로 설정해준다.
(존재하지 않은 페이지에 접근하면 이래의 이미지의 결과를 확인할 수 있다.)
<img src="https://velog.velcdn.com/images/ol_minje/post/944fb9ff-5f24-4f0c-9c02-338c39f10bda/image.png" alt="404 출력"></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(5)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC5</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC5</guid>
            <pubDate>Mon, 29 Sep 2025 06:25:53 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="1-사전-렌더링과-데이터-패칭">1. 사전 렌더링과 데이터 패칭</h1>
<p>리액트 앱에서는 보통 아래와 같이 데이터를 패칭한다.</p>
<pre><code class="language-tsx">export default function Page() {
  const [state, setState] = useState();

  const fetchData = async () =&gt; {
    const response = await fetch(&quot;...&quot;);
    const data = await response.json();
    setState(data);
  };

  useEffect(() =&gt; {
    fetchData();
  }, []);

  if (!state) return &quot;Loading...&quot;;

  return &lt;div&gt;...&lt;/div&gt;;
}</code></pre>
<h3 id="동작-순서">동작 순서</h3>
<ol>
<li><code>state</code> 생성</li>
<li>데이터 패칭 함수 정의</li>
<li>컴포넌트 마운트 시점에 호출</li>
<li>로딩 중이면 예외처리</li>
</ol>
<blockquote>
<p>👉 문제는 컴포넌트가 마운트된 이후에 호출되기 때문에, 데이터가 화면에 뜨기까지 시간이 오래 걸린다는 점.
<strong>즉, 리액트의 느린 FCP(First Contentful Paint) 문제</strong></p>
</blockquote>
<hr>
<h2 id="nextjs로-느린-fcp-해결하기">Next.js로 느린 FCP 해결하기</h2>
<p>Next.js는 <strong>사전 렌더링(Pre-Rendering)</strong>을 통해 느린 FCP 문제를 해결한다.
<img src="https://velog.velcdn.com/images/ol_minje/post/bd96a53e-a270-4c08-a3a9-c19580b16b1a/image.png" alt="사전 렌더링 과정 다시 살펴보기"></p>
<ul>
<li>초기 요청 시 서버가 HTML을 완성해 전달</li>
<li>이때 필요한 데이터를 서버에서 미리 불러와 함께 전달 가능</li>
</ul>
<p>덕분에 화면 로딩이 훨씬 빨라짐!</p>
<p>😎: 서버 요청 자체가 오래 걸리면 사전 렌더링도 그만큼 지연되지 않나유?
✍️: 넥스트는 이러한 점까지 고려하여, 빌트 타임에 미리 렌더링하거나, 서버 요청 시점에 렌더링하도록 설정할 수 있게 도와준다. (아래에서 알아보자!)</p>
<hr>
<h2 id="사전-렌더링의-종류">사전 렌더링의 종류</h2>
<h3 id="1-ssrserver-side-rendering">1. SSR(Server Side Rendering)</h3>
<ul>
<li>요청이 들어올 때마다 서버에서 렌더링</li>
<li>가장 기본적인 방식<h3 id="2-ssgstatic-site-generation">2. SSG(Static Site Generation)</h3>
</li>
<li>빌드 타임에 미리 렌더링</li>
<li>요청 시에는 완성된 정적 페이지 제공<h3 id="3-isrincremental-static-regeneration">3. ISR(Incremental Static Regeneration)</h3>
추후 강의에서 진행한다고 함!</li>
<li>SSG + 주기적 갱신</li>
</ul>
<hr>
<h1 id="2-ssr-실습-getserversideprops">2. SSR 실습: <code>getServerSideProps</code></h1>
<blockquote>
<p>Next.js는 페이지 안에 약속된 이름의 <code>getServerSideProps</code>라는 함수를 작성하고 <code>export</code>하면,  자동으로 SSR 모드로 전환된다.</p>
</blockquote>
<h2 id="getserversideprops-함수"><code>getServerSideProps</code> 함수</h2>
<ul>
<li>페이지 컴포넌트보다 먼저 실행되어, 필요한 데이터를 미리 불러올 수 있다.</li>
<li>이때, 반환 객체 안에서 반드시 <code>props</code> 객체가 포함되여야 한다.</li>
</ul>
<pre><code class="language-tsx">// pages/index.tsx
import type { ReactNode } from &#39;react&#39;;
import SearchableLayout from &#39;@/components/searchable-layout&#39;;

export const getServerSideProps = () =&gt; {
  const data = &quot;hello&quot;;
  return {
    props: { data },
  };
};

export default function Home({ data }: { data: string }) {
  console.log(data)
//...</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/9fe7f6d3-c0d7-4c62-849a-7bfd717b3b17/image.png" alt="getServerSideProps 콘솔 확인"></p>
<p>콘솔을 확인해보면 <code>props</code>로 전달받은 데이터가 잘 출력되는 걸 확인할 수 있다.</p>
<hr>
<h2 id="⚠️-서버-전용-코드-주의">⚠️ 서버 전용 코드 주의</h2>
<p><code>getServerSideProps</code>는 서버에서 실행되므로 <code>window</code> 같은 브라우저 객체는 사용이 불가하다.</p>
<pre><code class="language-tsx">// pages/index.tsx
//...
export const getServerSideProps = () =&gt; {
  const data = &#39;hello&#39;;
  window.location;
  return {
    props: {
      data,
    },
  };
};
//..</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/9b59d767-34ba-4d8c-9836-66aba7033f2e/image.png" alt="getServerSideProps 서버 오류"></p>
<p>추가로 페이지 컴포넌트의 경우에는 서버에서 한 번, 브라우저에서 한 번 총 2번이 실행되기 때문에, 위의 window 객체를 똑같이 페이지 컴포넌트에서 사용해보면 동일한 오류가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/96180669-70dc-4356-b830-d956362b4184/image.png" alt="getServerSideProps 브라우저 오류"></p>
<p>😎: 그러면 브라우저에서만 실행되는 건 어떻게 해야하나요~?
✍️: 만약 브라우저 전용 코드를 사용해야 한다면, <strong>페이지 컴포넌트의 <code>useEffect</code>에 넣어서 사용해야 한다</strong>. 
컴포넌트 마운트 시점 이후에 출력 즉, <strong>사전 렌더링 과정 중 서버에서 실행되지 않는 코드</strong>이기 때문에 <code>ReferenceError: window is not defined</code>가 발생하지 않는다.</p>
<pre><code class="language-tsx">// pages/index.tsx
//...
export default function Home({ data }: any) {
  useEffect(() =&gt; {
    console.log(window.location);
  }, []);
//...</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/d3135e76-2828-4793-ba25-624542bcbf73/image.png" alt="getServerSideProps 오류 해결"></p>
<hr>
<h2 id="타입-안전하게-사용하기-infergetserversidepropstype">타입 안전하게 사용하기: <code>InferGetServerSidePropsType</code></h2>
<p>Next.js는 <code>InferGetServerSidePropsType</code>으로 반환 타입을 자동 추론할 수 있다.</p>
<pre><code class="language-tsx">//...
export const getServerSideProps = () =&gt; {
  const data = &#39;hello&#39;;
  return {
    props: {
      data,
    },
  };
};

export default function Home({ data }: InferGetServerSidePropsType&lt;typeof getServerSideProps&gt;) {
  useEffect(() =&gt; {
    console.log(window.location);
  }, []);
//...</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/2d456ff1-c467-4bb0-8663-5f779c6993f8/image.png" alt="InferGetServerSidePropsType 타입 추론"></p>
<p>타입이 잘 추론된 것을 확인할 수 있다.</p>
<hr>
<h2 id="ssr--api-연동">SSR + API 연동</h2>
<p>API 호출 파일을 따로 관리하기 위해 <code>src</code>에 <code>lib</code>라는 폴더를 생성해준다.</p>
<pre><code class="language-tsx">// /src/lib/fetch-books.ts
import { BookData } from &#39;@/types&#39;;

export default async function fetchBooks(): Promise&lt;BookData[]&gt; {
  const url = `http://localhost:12345/book`;

  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error();
    }
    return await response.json();
  } catch (err) {
    console.error(err);
    return [];
  }
}</code></pre>
<p><code>getServerSideProps</code>에서 호출은 아래와 같다.</p>
<pre><code class="language-tsx">//...
export const getServerSideProps = async () =&gt; {
  const allBooks = await fetchBooks();
  return {
    props: {
      allBooks,
    },
  };
};

export default function Home({ allBooks }: InferGetServerSidePropsType&lt;typeof getServerSideProps&gt;) {
//...
            &lt;section&gt;
        &lt;h3&gt;등록된 모든 도서&lt;/h3&gt;
        {allBooks.map((book) =&gt; (
          &lt;BookItem key={book.id} {...book} /&gt;
        ))}
      &lt;/section&gt;
//...</code></pre>
<p>이때, 여러 API를 동시에 호출하려면 <code>Promise.all</code>을 활용하면 된다!(당연)</p>
<pre><code class="language-tsx">export const getServerSideProps = async () =&gt; {
  const [allBooks, randomBooks] = await Promise.all([fetchBooks(), fetchRandomBooks()]);
  return {
    props: {
      allBooks,
      randomBooks,
    },
  };
};</code></pre>
<hr>
<h2 id="쿼리와-url-파라미터-사용하기">쿼리와 URL 파라미터 사용하기</h2>
<h3 id="쿼리-사용하기">쿼리 사용하기</h3>
<p><code>getServerSideProps</code>의 인수로 <code>context</code>를 받아 사용할 수 있다.</p>
<pre><code class="language-tsx">export const getServerSideProps = async (context: GetServerSidePropsContext) =&gt; {
  return {
    props: {},
  };
};</code></pre>
<p>이때 <code>context</code>라는 매개변수에 Next.js가 제공하는 <code>GetServerSidePropsContext</code>를 사용한다.</p>
<blockquote>
<p><code>context</code>라는 매개변수는 현재 브라우저로부터 받은 요청에 대한 모든 정보가 다 포함되어 있다. 콘솔로 확인해보자!</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/77c76f0e-af43-45ea-a967-d6574d11ed9b/image.png" alt="context 콘솔 확인"></p>
<p><code>context</code>에는 전달한 <code>query</code> 값이 있기 때문에 꺼내서 사용햐면 된다.</p>
<pre><code class="language-tsx">import type { GetServerSidePropsContext } from &#39;next&#39;;

export const getServerSideProps = async (context: GetServerSidePropsContext) =&gt; {
  const q = context.query.q;
  return {
    props: {
      q,
    },
  };
};</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/a556ffc5-669d-433a-aea9-bad93c9545dc/image.png" alt="쿼리 사용 실행결과"></p>
<hr>
<h2 id="url-파라미터-사용하기">URL 파라미터 사용하기</h2>
<p>마찬가지로 <code>context</code>의 <code>params</code>를 이용하여 가져오면 된다.</p>
<pre><code class="language-tsx">export const getServerSideProps = async (context: GetServerSidePropsContext) =&gt; {
  const id = context.params!.id;
  return {
    props: {},
  };
};</code></pre>
<hr>
<h1 id="✅-정리하며">✅ 정리하며</h1>
<ul>
<li>CSR의 느린 데이터 로딩 문제를 서버에서 미리 해결할 수. 있다.</li>
<li><code>getServerSideProps</code> 덕분에 데이터 + UI를 한번에 전달 가능하다.</li>
<li>브라우저 전용 코드는 <code>useEffect</code>에 넣기!</li>
<li>타입 추론은 <code>InferGetServerSidePropsType</code>으로 안전하게 처리 가능하다.</li>
<li>쿼리/파라미터를 쉽게 활용 가능하다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(4)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC4</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC4</guid>
            <pubDate>Sun, 28 Sep 2025 16:10:21 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h2 id="글로벌-레이아웃-설정하기">글로벌 레이아웃 설정하기</h2>
<p>글로벌 레이아웃은 앱의 모든 페이지를 감싸는 루트 컴포넌트에 적용한다. <code>Page Router</code> 기준으로 실습 파일의 루트는 <code>_app.tsx</code>다.</p>
<pre><code class="language-tsx">// pages/_app.tsx
import &#39;@/styles/globals.css&#39;;
import type { AppProps } from &#39;next/app&#39;;

export default function App({ Component, pageProps }: AppProps) {
  return (
    &lt;div&gt;
      &lt;header&gt;header&lt;/header&gt;
      &lt;main&gt;
        &lt;Component {...pageProps} /&gt;
      &lt;/main&gt;
      &lt;footer&gt;footer&lt;/footer&gt;
    &lt;/div&gt;
  );
}</code></pre>
<ul>
<li>루트 컴포넌트가 페이지 컴포넌트를 포함하도록 구조를 잡아준다.</li>
</ul>
<blockquote>
<p>이제 레이아웃을 컴포넌트로 분리해보자. </p>
</blockquote>
<p><code>src/components/global-layout.tsx</code>를 만들고 아래처럼 작성한다.</p>
<pre><code class="language-tsx">// src/components/global-layout.tsx
import { ReactNode } from &#39;react&#39;;

export default function GlobalLayout({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;header&gt;header&lt;/header&gt;
      &lt;main&gt;{children}&lt;/main&gt;
      &lt;footer&gt;footer&lt;/footer&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p>그리고 <code>_app.tsx</code>에서 사용한다.</p>
<pre><code class="language-tsx">// pages/_app.tsx
import GlobalLayout from &#39;@/components/global-layout&#39;;
import &#39;@/styles/globals.css&#39;;
import type { AppProps } from &#39;next/app&#39;;

export default function App({ Component, pageProps }: AppProps) {
  return (
    &lt;GlobalLayout&gt;
      &lt;Component {...pageProps} /&gt;
    &lt;/GlobalLayout&gt;
  );
}</code></pre>
<hr>
<h2 id="페이지별-레이아웃-설정하기">페이지별 레이아웃 설정하기</h2>
<p>특정 페이지군에만 검색 UI를 붙이고 싶다면, 전역이 아닌 페이지별 레이아웃을 만든다.</p>
<pre><code class="language-tsx">// src/components/searchable-layout.tsx
import { ReactNode } from &#39;react&#39;;

export default function SearchableLayout({ children }: { children: ReactNode }) {
  return (
    &lt;div&gt;
      &lt;div&gt;임시 서치바&lt;/div&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<p>이걸 전역 <code>_app.tsx</code>에 바로 끼우면 모든 페이지에 서치 바가 생긴다. </p>
<blockquote>
<p>필요한 페이지에서만 쓰려면 <code>per-page layout</code> 패턴을 쓰자.</p>
</blockquote>
<pre><code class="language-tsx">// pages/index.tsx
import SearchableLayout from &#39;@/components/searchable-layout&#39;;
import type { ReactElement } from &#39;react&#39;;
import styles from &#39;./index.module.css&#39;;

export default function Home() {
  return (
    &lt;&gt;
      &lt;h1 className={styles.h1}&gt;인덱스&lt;/h1&gt;
      &lt;h2 className={styles.h2}&gt;&lt;/h2&gt;
    &lt;/&gt;
  );
}

// getLayout: 페이지 컴포넌트에 메서드로 선언
Home.getLayout = (page: ReactElement) =&gt; {
  return &lt;SearchableLayout&gt;{page}&lt;/SearchableLayout&gt;;
};</code></pre>
<p><code>getLayout</code> 메서드는 페이지 렌더링 시 호출되어, 해당 페이지를 원하는 레이아웃으로 감싼다. 
그리고 <code>_app.tsx</code>에서 이 메서드를 인식하도록 처리하면 된다.</p>
<pre><code class="language-tsx">// pages/_app.tsx
import GlobalLayout from &#39;@/components/global-layout&#39;;
import &#39;@/styles/globals.css&#39;;
import type { AppProps, NextPage } from &#39;next&#39;;
import type { ReactElement, ReactNode } from &#39;react&#39;;

export type NextPageWithLayout&lt;P = {}, IP = P&gt; = NextPage&lt;P, IP&gt; &amp; {
  getLayout?: (page: ReactElement) =&gt; ReactNode;
};

type AppPropsWithLayout = AppProps &amp; {
  Component: NextPageWithLayout;
};

export default function App({ Component, pageProps }: AppPropsWithLayout) {
  // getLayout이 없으면 기본적으로 &quot;그냥 페이지&quot;를 반환
  const getLayout = Component.getLayout ?? ((page: ReactElement) =&gt; page);
  return &lt;GlobalLayout&gt;{getLayout(&lt;Component {...pageProps} /&gt;)}&lt;/GlobalLayout&gt;;
}</code></pre>
<p>이렇게 하면 <code>getLayout</code>이 없는 페이지도 타입 오류 없이 안전하게 렌더링된다.</p>
<hr>
<h2 id="검색-기능이-있는-레이아웃-구현하기">검색 기능이 있는 레이아웃 구현하기</h2>
<p>검색 입력 <code>/search?q=...</code>로 이동하는 간단한 예시에</p>
<ul>
<li>검토용 <code>trim()</code></li>
<li>안전한 인코딩 <code>encodeURIComponent</code></li>
</ul>
<p>을 추가하여 구현하기</p>
<pre><code class="language-tsx">// src/components/searchable-layout.tsx
import { useRouter } from &#39;next/router&#39;;
import { ChangeEvent, KeyboardEvent, ReactNode, useEffect, useState } from &#39;react&#39;;

export default function SearchableLayout({ children }: { children: ReactNode }) {
  const router = useRouter();
  const [search, setSearch] = useState(&#39;&#39;);

  // 현재 URL의 쿼리(q)와 상태를 동기화
  const q = (router.query.q as string) ?? &#39;&#39;;

  useEffect(() =&gt; {
    setSearch(q);
  }, [q]);

  const onChangeSearch = (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    setSearch(e.target.value);
  };

  const submit = () =&gt; {
    const next = search.trim();
    if (!next || next === q) return;
    router.push(`/search?q=${encodeURIComponent(next)}`);
  };

  const onKeyDown = (e: KeyboardEvent&lt;HTMLInputElement&gt;) =&gt; {
    if (e.key === &#39;Enter&#39;) submit();
  };

  return (
    &lt;div&gt;
      &lt;form
        onSubmit={(e) =&gt; {
          e.preventDefault();
          submit();
        }}
      &gt;
        &lt;input
          value={search}
          onChange={onChangeSearch}
          onKeyDown={onKeyDown}
          placeholder=&quot;검색어를 입력해주세요...&quot;
          aria-label=&quot;검색어&quot;
        /&gt;
        &lt;button type=&quot;submit&quot;&gt;검색&lt;/button&gt;
      &lt;/form&gt;
      {children}
    &lt;/div&gt;
  );
}</code></pre>
<hr>
<h2 id="✅-정리">✅ 정리</h2>
<ul>
<li>Next.js는 페이지 컴포넌트를 <code>_app.tsx</code>의 <code>Component</code>로 전달하고, 우리가 임의로 부착한 <code>getLayout</code> 메서드도 함께 전달할 수 있다.</li>
<li><code>_app.tsx</code>에서 <code>GlobalLayout</code>으로 한 번 감싸고, 페이지에 <code>getLayout</code>이 있으면 추가로 원하는 레이아웃으로 감싸 최종 UI를 만들 수 있다.</li>
</ul>
<pre><code class="language-tsx">// pages/_app.tsx
const getLayout = Component.getLayout ?? ((page) =&gt; page);
return &lt;GlobalLayout&gt;{getLayout(&lt;Component {...pageProps} /&gt;)}&lt;/GlobalLayout&gt;;
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(3)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC3</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC3</guid>
            <pubDate>Thu, 25 Sep 2025 07:36:26 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="스타일링">스타일링</h1>
<p>Next.js에서 스타일링하는 방법을 알아보자!</p>
<p>먼저 간단히 <code>src &gt; pages &gt; index.tsx</code> 안에서 인라인 스타일을 적용해볼 수 있다.</p>
<pre><code class="language-tsx">export default function Home() {
  return &lt;h1 style={{ color: &#39;red&#39; }}&gt;인덱스&lt;/h1&gt;;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/03dcea5c-8c60-49ac-bc79-4e461b86ba58/image.png" alt="인라인 스타일 예시"></p>
<p>하지만 인라인 방식은 코드가 지저분해지고, 재사용성이 떨어진다는 단점이 있다.
그래서 <strong>보통은 CSS 파일을 따로 만들어 관리</strong>한다.</p>
<hr>
<h2 id="❌-잘못된-방식-전역-css를-직접-import">❌ 잘못된 방식: 전역 CSS를 직접 import</h2>
<pre><code class="language-tsx">import &#39;./index.css&#39;;

export default function Home() {
  return &lt;h1 className=&quot;title&quot;&gt;인덱스&lt;/h1&gt;;
}</code></pre>
<p>이렇게 작성하면 <code>Build Error</code>가 발생한다.</p>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/974b9fd2-bcee-41c6-ae46-ef23b0dc67dc/image.png" alt="Build Error"></p>
<blockquote>
<p>Next.js에서는 전역 CSS를 <code>pages/_app.tsx</code>에서만 가져올 수 있고, 일반 페이지 컴포넌트에서는 전역 CSS 파일을 바로 <code>import</code>하는 것을 금지하고 있기 때문!</p>
</blockquote>
<p><strong>⚠️ 이유</strong></p>
<ul>
<li>중복된 스타일링으로 인한 충돌 방지</li>
<li>CSS 관리의 일관성 확보</li>
</ul>
<hr>
<h2 id="✅-권장-방식-css-module-사용하기">✅ 권장 방식: CSS Module 사용하기</h2>
<blockquote>
<p>페이지 단위에서 개별 스타일을 적용하고 싶다면 CSS Module을 활용해야 한다.</p>
</blockquote>
<ul>
<li>파일명은 반드시 <code>*.module.css</code> 형태</li>
</ul>
<pre><code class="language-tsx">import style from &#39;./index.module.css&#39;;

export default function Home() {
  return &lt;h1 className={style.h1}&gt;인덱스&lt;/h1&gt;;
}</code></pre>
<p><code>index.module.css</code></p>
<pre><code class="language-tsx">.h1 {
  color: red;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/a0997fde-3f46-4b67-b400-7519982d4550/image.png" alt="CSS Module 적용 예시"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Section 3. Page Router 핵심 정리(2)]]></title>
            <link>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC2</link>
            <guid>https://velog.io/@ol_minje/Section-3.-Page-Router-%ED%95%B5%EC%8B%AC-%EC%A0%95%EB%A6%AC2</guid>
            <pubDate>Thu, 25 Sep 2025 07:29:04 GMT</pubDate>
            <description><![CDATA[<p><em>인프런 &quot;한 입 크기로 잘라먹는 Next.js&quot; 수강</em></p>
<h1 id="api-routes">API Routes</h1>
<p>Next.js에서는 별도의 백엔드 서버 없이도 간단한 API 엔드포인트를 만들 수 있다. <code>pages/api</code> 폴더 하위에 파일을 만들면, 파일 이름이 곧 API 경로가 된다.</p>
<p>예) <code>pages/api/hello.ts</code> → <code>/api/hello</code></p>
<blockquote>
<p>API Routes는 서버 사이드에서 실행된다. 덕분에 브라우저에 노출되지 않으며, DB 접근/비밀키 사용/외부 API 호출 등의 서버 로직을 넣기에 적합하다!</p>
</blockquote>
<h3 id="가장-기본-예제-apihello">가장 기본 예제: <code>/api/hello</code></h3>
<pre><code class="language-tsx">// pages/api/hello.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from &quot;next&quot;;

type Data = { name: string };

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse&lt;Data&gt;
) {
  res.status(200).json({ name: &quot;John Doe&quot; });
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/9a2459a8-af18-4903-9628-af1c74022f25/image.png" alt="hello 실행 결과"></p>
<ul>
<li><code>handler</code> 함수가 요청을 받아 응답을 보낸다.</li>
<li><code>res.status(200).json(...)</code> 처럼 상태 코드와 JSON 응답을 보낼 수 있다.</li>
</ul>
<h3 id="⏱️-간단한-api-추가-apitime">⏱️ 간단한 API 추가: <code>/api/time</code></h3>
<pre><code class="language-tsx">// pages/api/time.ts
import type { NextApiRequest, NextApiResponse } from &quot;next&quot;;

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const date = new Date();
  res.status(200).json({ time: date.toLocaleDateString() });
}</code></pre>
<p><img src="https://velog.velcdn.com/images/ol_minje/post/2d060f66-3065-45c7-a3ec-a4849dceee02/image.png" alt="time 실행 결과"></p>
<p>브라우저에서 <code>http://localhost:3000/api/time</code>으로 접속하면 날짜가 JSON으로 반환된다.</p>
<h3 id="🧭-요청-메서드-분기-getpost-등">🧭 요청 메서드 분기 (GET/POST 등)</h3>
<p>하나의 파일 안에서 HTTP 메서드별로 분기해 다양한 동작을 구현할 수 있다.</p>
<pre><code class="language-tsx">// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from &quot;next&quot;;

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === &quot;GET&quot;) {
    return res.status(200).json({ users: [&quot;alice&quot;, &quot;bob&quot;] });
  }
  if (req.method === &quot;POST&quot;) {
    // req.body 사용 가능 (예: JSON 파싱)
    return res.status(201).json({ ok: true });
  }
  res.setHeader(&quot;Allow&quot;, [&quot;GET&quot;, &quot;POST&quot;]);
  return res.status(405).end(&quot;Method Not Allowed&quot;);
}</code></pre>
]]></description>
        </item>
    </channel>
</rss>