한 입 Next.js v15에 대한 정리

한 입 크기로 잘라먹는 Next.js v15 강의를 들으면서 학습한 내용에 대한 정리 포스트

22
단어: 1,826
게시글 썸네일
정보

해당 포스트는 인프런 - 한 입 크기로 잘라먹는 Next.js v15 강의를 들으면서 학습한 내용에 대한 정리 포스트에요.
Page Router에 대해서는 완벽은 아니지만 어느정도 알고 있어서 스킵하고 App Router에 대한 부분만 학습했어요.

유용한 팁

Next.js의 각 기능들에 대한 예시 코드를 보고 싶다면 App Router Playground를 참고하시면 좋아요.

🤔 강의에 개인적인 생각

해당 강의가 특별한 기법이나 프로젝트를 만드는 강의는 아니에요.
공식문서에 대한 내용들에 대해 체계적으로 정리해주고 시각자료와 편집을 통해서 청자를 최대한 배려해주는 강의라고 느껴졌어요.

강의를 통해서 애매했던 개념에 대한 확신을 얻을 수 있었고, 공식문서를 읽는 시간을 줄일 수 있었어요.
어떻게 보면 돈으로 시간과 개념에 대한 확신을 사는 강의라고 생각해서 매우 만족스러웠어요.

⏳ 서버 컴포넌트와 클라이언트 컴포넌트

유용한 팁

직렬화가 가능한 값만 서버 컴포넌트에서 클라이언트 컴포넌트로 전달할 수 있어요.
함수같은 경우는 같은 코드라도 생성하는 시점에 따라 결과가 달라지기 때문에 직렬화할 수 없는 값이라 전달할 수 없어요.

가능한 서버 컴포넌트를 사용하는것을 권장해요
상호작용이 있다면 클라이언트 컴포넌트, 이외에는 모두 서버 컴포넌트를 사용하는것이 좋아요.
( 상호작용이란 훅이나 이벤트가 있는 컴포넌트를 의미해요. )

0️⃣ 서버 컴포넌트

서버 컴포넌트란 서버에서만 실행되는 컴포넌트를 의미해요.
즉, 서버에서 컴포넌트인 함수를 실행하고 그 결과인 return 값인 HTML을 클라이언트로 전달해요.

  1. js를 전달하지 않기 때문에 번들에 포함되지 않음 ( 페이지 로드 속도 빠름 )
  2. 내부에서 DB에 접근하거나 숨겨야하는 Key를 사용해도 무방
  3. 서버 컴포넌트에서 패치를 하는 경우 서버측에서 캐싱이 가능
  4. 서버에서 만들어진채로 받기 때문에 SEO에 유리
  5. 스트리밍 가능
  6. 상호작용 불가능 ( 훅, 이벤트 핸들러 사용 불가 )

1️⃣ 클라이언트 컴포넌트

유용한 팁

Hydration이란 뼈대에 살을 붙이는 행위로 HTMLJavaScript가 적용되는 행위를 의미해요.
useStateonClick, onChange등의 이벤트 핸들러가 HTML에 붙는 것을 의미해요.

서버 컴포넌트의 개념이 나오기전에 존재했던 컴포넌트들 모두 클라이언트 컴포넌트에요.
현재는 use client라는 지시자가 있는 컴포넌트를 의미해요.

  1. 번들에 포함되며 Hydration이 발생
  2. 사전 렌더링에서 서버측에서도 실행됨 ( 클라이언트에서만 실행되지 않음 )

2️⃣ 사전 렌더링 과정

정보

사전 렌더링이란 서버에서 미리 실행하는것을 의미해요.

  1. 서버 컴포넌트 먼저 실행
  2. RSC Payload 생성 (RSC를 실행한 결과물 -> 서버 컴포넌트 렌더링 결과, 연결된 클라이언트 컴포넌트 위치, 클라이언트 컴포넌트에 전달하는 props)
  3. 클라이언트 컴포넌트 실행
  4. 클라이언트 컴포넌트 결과물과 RSC Payload를 합쳐서 HTML 생성 ( 서버 컴포넌트의 결과물이 들어갈 자리를 마킹해둠 )

3️⃣ 서버 컴포넌트와 클라이언트 컴포넌트의 관계

일반적으로 서버 컴포넌트 하위에 클라이언트 컴포넌트를 사용하는것은 가능하지만, 그 반대는 불가능해요.
왜냐하면 클라이언트 컴포넌트의 실행 시점은 서버와 브라우저 두군데에서 실행되는데 서버 컴포넌트는 브라우저에서 실행이 불가능하기 때문이죠.
( 엄밀하게 말하면 불가능은 아닌게 서버 컴포넌트를 클라이언트 컴포넌트에서 사용하는경우 오류를 발생시키지않고 자동으로 클라이언트 컴포넌트로 변환돼요. )

// 정상적인 코드 const Component: React.FC = () => { return ( <ServerComponent> <ClientComponent /> </ServerComponent> ); }; // 비정상적인 코드 const Component: React.FC = () => { return ( <ClientComponent> <ServerComponent /> </ClientComponent> ); };

불가능은 아니더라도 정상적인 코드는 아니기때문에 우회하는 방법을 사용해야하는데 바로 children으로 전달하는 방법이에요
아래처럼 사용하면 결국 <ServerComponent />가 실행되는 시점은 <Component />가 실행되는 시점과 동일해요.
즉, 서버측에서 실행되고 그 결과물이 props로 <ClientComponent />에 전달되기 때문에 정상적인 코드가 되는거죠.

// ServerComponent.tsx const ServerComponent: React.FC = () => { return <div>Server Component</div>; }; // ClientComponent.tsx "use client" const ClientComponent: React.FC<React.PropsWithChildren> = ({ children }) => { const onClick = () => alert("clicked"); return ( <section> {children} <button type="button" onClick={onClick}> Click me </button> </section> ); }; // Component.tsx const Component: React.FC = () => { return ( <ClientComponent> <ServerComponent /> </ClientComponent> ); };

📦 데이터 패칭

Page Router에서는 서버 사이드에서 데이터를 패치하려면 정적으로 만들어진 페이지거나 getServerSideProps를 이용해야 했어요.
근데 getServerSideProps는 페이지를 불러올때 데이터를 패치하기 때문에 모든 패치를 페이지 컴포넌트에서 실행해서 props로 전달하는 문제와 먼저 완료된 일부분만 렌더링할 수 없는 단점이 있었어요.

서버 컴포넌트는 컴포넌트 단위로 데이터를 패치할 수 있기 때문에 이전에 발생했던 불편함과 문제를 해소할 수 있어요.

0️⃣ 리퀘스트 메모이제이션

정보

Request MemoizationNext.js의 기능이 아닌 React의 기능이에요.

데이터 캐시

Request Memoization은 동일한 요청을 여러번 보내도 캐시된 응답값을 반환하는 기능이에요.
즉, 여러 컴포넌트에서 같은 엔드포인트의 fetch를 보내더라도 실제로 서버에 요청이 가는 것은 한번만 일어나요.
no-store 옵션을 사용하더라도 캐시된 데이터를 반환하고 렌더링이 끝나는 시점에 캐시된 데이터를 제거해요.

fetch이외에 generateMetadata, generateStaticParams함수에서 사용하는것도 동일하게 캐싱돼요.

// (home)/page.tsx export const generateMetadata = async () => { const data = await fetch("~/api/posts", { cache: "no-store" }); } const Page: NextPage = async () => { const data = await fetch("~/api/posts", { cache: "no-store" }); return <div>{data.title}</div>; } // blog/[id]/page.tsx interface Props { params: Promise<{ id: string }>; } export const generateStaticParams = async ({ params }: Props) => { const posts = await fetch('~/api/posts').then((res) => res.json()) return posts.map((post) => ({ id: post.id })) } export const generateMetadata = async ({ params }: Props) => { const data = await fetch(`~/api/posts/${params.id}`); } const Page: NextPage<Props> = async ({ params }) => { const data = await fetch(`~/api/posts/${params.id}`); }

위 예시에서 /로 접근 시 3, 7라인의 fetch()중 하나만 호출되고 나머지 하나는 캐싱된 데이터를 사용해요.
( cache: "no-store" 옵션과는 다른 종류의 캐시이기 때문에 영향이 없어요. )

/blog/1로 접근 시 24, 28라인의 fetch()가 한번만 호출되고, 18번은 이전에 캐싱되었지만 페이지가 렌더링된후 캐시가 제거되었기 때문에 다시 호출 후 캐싱돼요.

유용한 팁

API 요청이 아닌 경우 Reactcache를 사용해서 캐싱할 수 있어요.
제 블로그는 Database를 사용하지 않고, mdx파일 자체를 서버 사이드에서 가져와서 파싱하기 때문에 cache를 사용했어요.

1️⃣ 데이터 캐시

정보

fetch를 몽키패치 했기때문에 서버 컴포넌트에서는 반드시 fetch를 사용해야만 정상적인 결과물을 볼 수 있어요

유용한 팁

.next > fetch-cache에 캐싱된 데이터가 보관돼요.

데이터 캐시

fetch()를 이용한 캐싱에 대한 내용이에요.

1️⃣-0️⃣ no-store

결괏값을 캐싱하지 않음

const data = await fetch("~/api/posts/random", { cache: "no-store" }); // 이후에 다른곳에서 같은 요청을 보내면 서버에 요청을 보내서 새로운 데이터를 얻음

1️⃣-1️⃣ force-cache

결괏값을 영구적으로 캐싱
( 서버가 재시작되거나 tags 등의 특별한 동작이 없는 이상 데이터를 영구적으로 캐싱 )

const data = await fetch("~/api/posts/random", { cache: "force-cache" }); // 이후에 다른곳에서 같은 요청을 보내면 캐시된 데이터를 반환하기 때문에 이전과 동일한 데이터를 받음

1️⃣-2️⃣ revalidate

결괏값을 설정한 시간마다 재검증
시간이 지나면 stale된 데이터를 가지고 있다가 다음 요청이 들어오면 stale된 데이터를 반환하고 신선한 데이터를 요청해서 캐시를 갱신해요.
즉, 재검증 시간이 지나는 순간 API 요청을 보내는것이 아니라 재검증 시간이 지나고나서 요청이 들어오면 첫 요청은 기존 데이터(stale)를 반환 후 요청을 보내고 새로운 데이터를 받아서 캐싱해요.

const data = await fetch("~/api/posts/random", { next: { revalidate: 5 } }); // 5분 후 요청 시 1라인과 같은 데이터 반환 const data = await fetch("~/api/posts/random", { next: { revalidate: 5 } }); // 이후 요청 시 5분동안은 새로운 데이터 반환 const data = await fetch("~/api/posts/random", { next: { revalidate: 5 } });

1️⃣-3️⃣ tags

결괏값을 태그를 이용해서 재검증

요청이 들어오면 데이터를 제거하고 패치 요청이 들어오면 신선한 데이터를 요청 후 캐싱 및 반환

  • 재검증하지 않는다면 캐시된 데이터가 계속 유지됨

2️⃣ 풀 라우트 캐시

풀라우트 캐시

빌드 시점에 만들 수 있는 정적인 페이지를 캐싱하고 반환하는 것을 의미해요.
( Page RouterSSG처럼 빌드 시점에 만들어진 페이지 )

  • 정적 페이지 조건
    1. 데이터 캐시 사용 ( cache: "force-cache" )
    2. 동적 함수 사용하지 않음 ( useSearchParams, cookies, headers 등 )
유용한 팁

Full Route Cache에서도 revalidate를 사용할 수 있어요.
정적인 페이지가 빌드 시 처음 생성되고, 이후 revalidate 시간에 따라서 재생성돼요.
( Page RouterISR처럼 빌드 시점에 만들어진 페이지를 재검증 시간에 따라 재생성 )

3️⃣ 클라이언트 라우터 캐시

각 페이지, 레이아웃, 로딩 상태, route segment등의 RSC Payload를 클라이언트 인메모리에 캐싱해서 사용하는 것을 의미해요.
페이지마다 공유하는 layout.tsxloading.tsx 등의 컴포넌트는 페이지를 이동할때마다 재호출하는게 비효율적이니까 재사용할 수 있는 데이터를 캐싱해서 사용해요.

테스트로 아래처럼 layout.tsx에 시간값을 넣고 soft routing으로 페이지를 이동해보면 시간값이 변경되지 않는 것을 확인할 수 있어요.
클라이언트 인메모리에 layout.tsxRSC Payload를 캐싱해두고 사용하기 때문에 변화가 없는 것이고, 새로고침하면 최신화되는걸 볼 수 있어요.

// layout.tsx const Layout: React.FC<React.PropsWithChildren> = ({ children }) => { return ( <div> <time>{new Date().toLocaleString()}</time> {children} </div> ); };

4️⃣ 라우트 세그먼트 옵션

정보

상세한 내용은 공식문서 - Route Segment Config 참고해주세요.

  1. dynamicParams: generateStaticParams에서 반환하지 않는 값을 어떻게 처리할지에 대한 옵션
  2. dynamic: 페이지 유형을 강제로 설정하는 옵션 ( error는 올바르지 않는 형태일때 빌드 시 에러 발생 )

🪜 스트리밍과 에러 핸들링

0️⃣ 스트리밍

정보

데이터를 나눠서 연속적으로 보내는 기술을 의미해요.

0️⃣-0️⃣ loading.tsx

유용한 팁

loading.tsx는 같은 뎁스에 없다면 상위 레이아웃을 그대로 상속받고, 같은 뎁스에 있다면 자신의 레이아웃을 상속받아요.
상속을 막는 방법은 없기때문에 만약 사용하고 싶지 않다면 그룹 라우팅으로 분리하면 돼요.

loading.tsx

페이지 컴포넌트와 같은 뎁스에 loading.tsx를 만들면 페이지 컴포넌트 자체가 <Suspense>로 감싸져있는것과 동일하게 동작해요.
즉, 페이지 자체를 스트리밍할 수 있어요.

아래와 같은 코드가 있다면 최초 3초동안은 Loading...이 보이고 3초 후에 Home이 보여요.
그리고 Layout은 스트리밍과 관계없이 항상 렌더링되어 있어요.

// /(home)/loading.tsx const Loading: React.FC = () => { return <div>Loading...</div>; }; // /(home)/page.tsx const Page: NextPage = async () => { await new Promise((resolve) => setTimeout(resolve, 3000)); return <div>Home</div>; }; // layout.tsx const Layout: React.FC<React.PropsWithChildren> = ({ children }) => { return ( <div> <div>Layout</div> {children} </div> ); };

0️⃣-1️⃣ Suspense

유용한 팁

Suspense는 최초 1회만 동작하고 이후 내부의 변화가 있더라도 다시 동작하지 않아요.
만약 특정 조건에 의해서 다시 동작하게 하고 싶다면 key라는 props를 사용해서 동작하게 할 수 있어요.

Suspense

페이지가 아닌 특정 컴포넌트를 스트리밍하고 싶다면 <Suspense>로 감싸면 돼요.

아래 예시처럼 동기 컴포넌트와 비동기 컴포넌트가 섞여있는 페이지인 경우<SyncComponent />는 레이아웃과 같이 화면에 렌더링되고 <AsyncComponent />는 응답이 올때까지 로딩상태를 보게돼요.
만약 loading.tsx를 사용했다면 3초를 기다린 후 <SyncComponent />가 렌더링 되었을거에요.
( 추가로 여러 API를 동시에 처리하는 경우 각각의 응답속도가 다르기때문에 <Suspense />를 사용하면 순차적으로 성공한것부터 렌더링할 수 있어요 )

const AsyncComponent: React.FC = async () => { const data = await new Promise((resolve) => setTimeout(resolve, 3000)); return <div>Async Component</div>; }; const SyncComponent: React.FC = () => { return <div>Sync Component</div>; }; const Page: NextPage = () => { return ( <> <SyncComponent /> <Suspense fallback={<div>Loading...</div>}> <AsyncComponent /> </Suspense> </> ); };

1️⃣ 에러 핸들링

1️⃣-0️⃣ error.tsx

서버측과 클라이언트측 어디서라도 발생할 수 있기 때문에 "use client" 지시자를 반드시 사용해야함

loading.tsx과 같은 형식으로 사용하면 돼요.
그리고 자동적으로 props로 errorreset을 전달해줘요.

  • reset: 에러 상태 초기화 및 클라이언트 컴포넌트를 갱신
  • router.refresh: 서버 컴포넌트(RSC Payload)를 갱신
"use client" import { startTransition } from "react"; import { useRouter } from "next/navigation"; // /(home)/error.tsx interface IProps { error: Error; reset: () => void; } const Error: React.FC<IProps> = ({ error, reset }) => { const router = useRouter(); const retry = () => { // "startTransition"을 사용해야 순차적으로 실행되어 오류 발생 시 오류 발생 전 상태로 롤백 가능 startTransition(() => { router.refresh(); reset(); }); } return ( <div> <h1>에러 발생</h1> <p>{error.message}</p> <button onClick={retry}>다시 시도</button> </div> ); };

1️⃣-1️⃣ ErrorBoundary

아래 라이브러리를 설치하고 <Suspense />와 같은 형식으로 사용하면 부분적으로 에러 핸들링을 할 수 있어요.

pnpm i react-error-boundary

📮 레퍼런스

  1. 인프런 - 한 입 크기로 잘라먹는 Next.js v15
  2. App Router Playground
  3. cache
  4. 공식문서 - Route Segment Config
  5. 그룹 라우팅
성공

연관된 포스트가 없어서 랜덤한 포스트로 대체합니다.