한 입 Next.js v15에 대한 정리
한 입 크기로 잘라먹는 Next.js v15 강의를 들으면서 학습한 내용에 대한 정리 포스트
정보
해당 포스트는 인프런 - 한 입 크기로 잘라먹는 Next.js v15 강의를 들으면서 학습한 내용에 대한 정리 포스트에요.
Page Router
에 대해서는 완벽은 아니지만 어느정도 알고 있어서 스킵하고App Router
에 대한 부분만 학습했어요.
유용한 팁
Next.js
의 각 기능들에 대한 예시 코드를 보고 싶다면 App Router Playground를 참고하시면 좋아요.
🤔 강의에 개인적인 생각
해당 강의가 특별한 기법이나 프로젝트를 만드는 강의는 아니에요.
공식문서에 대한 내용들에 대해 체계적으로 정리해주고 시각자료와 편집을 통해서 청자를 최대한 배려해주는 강의라고 느껴졌어요.
강의를 통해서 애매했던 개념에 대한 확신을 얻을 수 있었고, 공식문서를 읽는 시간을 줄일 수 있었어요.
어떻게 보면 돈으로 시간과 개념에 대한 확신을 사는 강의라고 생각해서 매우 만족스러웠어요.
⏳ 서버 컴포넌트와 클라이언트 컴포넌트
유용한 팁직렬화가 가능한 값만 서버 컴포넌트에서 클라이언트 컴포넌트로 전달할 수 있어요.
함수같은 경우는 같은 코드라도 생성하는 시점에 따라 결과가 달라지기 때문에 직렬화할 수 없는 값이라 전달할 수 없어요.
가능한 서버 컴포넌트를 사용하는것을 권장해요
상호작용이 있다면 클라이언트 컴포넌트, 이외에는 모두 서버 컴포넌트를 사용하는것이 좋아요.
( 상호작용이란 훅이나 이벤트가 있는 컴포넌트를 의미해요. )
0️⃣ 서버 컴포넌트
서버 컴포넌트란 서버에서만 실행되는 컴포넌트를 의미해요.
즉, 서버에서 컴포넌트인 함수를 실행하고 그 결과인 return
값인 HTML
을 클라이언트로 전달해요.
js
를 전달하지 않기 때문에 번들에 포함되지 않음 ( 페이지 로드 속도 빠름 )- 내부에서
DB
에 접근하거나 숨겨야하는Key
를 사용해도 무방 - 서버 컴포넌트에서 패치를 하는 경우 서버측에서 캐싱이 가능
- 서버에서 만들어진채로 받기 때문에
SEO
에 유리 - 스트리밍 가능
- 상호작용 불가능 ( 훅, 이벤트 핸들러 사용 불가 )
1️⃣ 클라이언트 컴포넌트
유용한 팁
Hydration
이란 뼈대에 살을 붙이는 행위로HTML
에JavaScript
가 적용되는 행위를 의미해요.
useState
나onClick
,onChange
등의 이벤트 핸들러가HTML
에 붙는 것을 의미해요.
서버 컴포넌트의 개념이 나오기전에 존재했던 컴포넌트들 모두 클라이언트 컴포넌트에요.
현재는 use client
라는 지시자가 있는 컴포넌트를 의미해요.
- 번들에 포함되며
Hydration
이 발생 - 사전 렌더링에서 서버측에서도 실행됨 ( 클라이언트에서만 실행되지 않음 )
2️⃣ 사전 렌더링 과정
정보
사전 렌더링이란 서버에서 미리 실행하는것을 의미해요.
- 서버 컴포넌트 먼저 실행
RSC Payload
생성 (RSC
를 실행한 결과물 -> 서버 컴포넌트 렌더링 결과, 연결된 클라이언트 컴포넌트 위치, 클라이언트 컴포넌트에 전달하는props
)- 클라이언트 컴포넌트 실행
- 클라이언트 컴포넌트 결과물과
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 Memoization
은Next.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 요청
이 아닌 경우React
의cache
를 사용해서 캐싱할 수 있어요.
제 블로그는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 Router
의 SSG
처럼 빌드 시점에 만들어진 페이지 )
- 정적 페이지 조건
- 데이터 캐시 사용 (
cache: "force-cache"
) - 동적 함수 사용하지 않음 (
useSearchParams
,cookies
,headers
등 )
- 데이터 캐시 사용 (
유용한 팁
Full Route Cache
에서도revalidate
를 사용할 수 있어요.
정적인 페이지가 빌드 시 처음 생성되고, 이후revalidate
시간에 따라서 재생성돼요.
(Page Router
의ISR
처럼 빌드 시점에 만들어진 페이지를 재검증 시간에 따라 재생성 )
3️⃣ 클라이언트 라우터 캐시
각 페이지, 레이아웃, 로딩 상태, route segment
등의 RSC Payload
를 클라이언트 인메모리에 캐싱해서 사용하는 것을 의미해요.
페이지마다 공유하는 layout.tsx
나 loading.tsx
등의 컴포넌트는 페이지를 이동할때마다 재호출하는게 비효율적이니까 재사용할 수 있는 데이터를 캐싱해서 사용해요.
테스트로 아래처럼 layout.tsx
에 시간값을 넣고 soft routing
으로 페이지를 이동해보면 시간값이 변경되지 않는 것을 확인할 수 있어요.
클라이언트 인메모리에 layout.tsx
의 RSC Payload
를 캐싱해두고 사용하기 때문에 변화가 없는 것이고, 새로고침하면 최신화되는걸 볼 수 있어요.
// layout.tsx const Layout: React.FC<React.PropsWithChildren> = ({ children }) => { return ( <div> <time>{new Date().toLocaleString()}</time> {children} </div> ); };
4️⃣ 라우트 세그먼트 옵션
정보
상세한 내용은 공식문서 - Route Segment Config 참고해주세요.
dynamicParams
:generateStaticParams
에서 반환하지 않는 값을 어떻게 처리할지에 대한 옵션dynamic
: 페이지 유형을 강제로 설정하는 옵션 (error
는 올바르지 않는 형태일때 빌드 시 에러 발생 )
🪜 스트리밍과 에러 핸들링
0️⃣ 스트리밍
정보
데이터를 나눠서 연속적으로 보내는 기술을 의미해요.
0️⃣-0️⃣ 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>
로 감싸면 돼요.
아래 예시처럼 동기 컴포넌트와 비동기 컴포넌트가 섞여있는 페이지인 경우<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로 error
와 reset
을 전달해줘요.
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
📮 레퍼런스
성공연관된 포스트가 없어서 랜덤한 포스트로 대체합니다.