지난번엔 next.js pages router 공식문서 정리글을 올렸다. 이번에는 최근에 stable이 된 app router 공식문서의 핵심 내용에 대해 번역 및 정리해보았다.
next.js 13버전에서 beta이던 app router가 13.4에서 stable에 들어서게 되었다. turbopack 또한 alpha에서 beta로 변경되었고 server action이라는 새로운 개념도 등장했다.
먼저 app router를 보기 앞서 Server component에 대해 이해할 필요가 있다. Server component는 React 18에서 새로 등장한 개념으로 Client component와 구별해서 이해할 필요가 있다. Next.js app router는 Server component를 적용할 수 있게 구현이 되어있으므로 이를 잘 살펴보자.
Server component를 통해 전체 어플리케이션을 클라이언트에서 렌더링하는 것이 아닌 의도에따라 어디에서 컴포넌트를 렌더링할지 정할 수 있다.
예를 들어 위 그림에서 Navbar, Sidebar, Main 컴포넌트는 interactive한 부분이 없는 정적인 컴포넌트이기 때문에 서버에서 Server component로 렌더링할 수 있다. 나머지 interactive한 UI는 Client component로 렌더링하게 된다. 이를 Next.js에선 Server-first 접근법이라 한다.
그래서 이걸 왜 쓰냐? 장점이 뭔데? 라고 할 수 있다.
Server components를 통해 개발자는 서버 인프라를 더욱 효율적으로 사용할 수 있다. 이전에 클라이언트 자바스크립트 번들 사이즈에 영향을 주던 큰 의존성을 모두 서버에서 처리할 수 있게 된다.
따라서 Server components를 통해 초기 페이지 로드 속도가 향상되고 클라이언트 JS 번들 사이즈는 감소한다. 기존의 client 측 런타임은 캐시할 수 있고 크기를 예측 가능하며 어플리케이션이 커져도 증가하지 않는다. 추가적인 JS는 단지 어플리케이션에서 사용되는 Client components를 통해 추가된다.
내가 느끼기엔 마치 페이지 단위로 SSR을 할 수 있었던 것을 컴포넌트 단위로 세분화해서 할 수 있게 된 느낌이다.
App router에서는 기본적으로 모든 컴포넌트가 Server component가 default이다. Client component로 사용하고 싶다면 use client
선언문을 통해 변경할 수 있다.
Client component는 어플리케이션에 client측 interactivity를 추가할 수 있게 한다. Next.js에선 이 컴포넌트들은 먼저 서버에서 pre-render 된 후 클라이언트에서 hydrate(JS가 실행되면서 interactive하게 됨)된다.
Client component를 쓸려면 컴포넌트 파일 가장 상단에(import문 위) use client
선언문을 선언해준다. use client
뭔가 use strict
같은 느낌적인 느낌? 문법이 예쁘진 않은것 같다.
'use client'; // 요런식으로 선언해주면 여기서부턴 client 컴포넌트임
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
use client
를 선언함과 동시에 해당 파일안에서 의존하고 있는 다른 모듈과 child components는 client bundle에 포함된다. 기본적으로 server component가 default이기 때문에 module 그래프의 모든 컴포넌트는 use client
선언문이 없다면 Server component에 해당한다.
use client
는 모든 파일에 선언할 필요가 없다. client module 경계는 한번만 선언하면 되기 때문 한번 선언되고 나선 의존관계에 있는 모든 모듈이 client component로 동작한다.next.js에선 결정하기 전에 먼저 Server component를 사용하고 Client component가 필요할 때 사용하라 한다.
client component는 기본적으로 가능하다면 트리상에서 리프 노드에 두는 것을 추천한다고 한다. 이는 client component가 되고 나선 그 아래부턴 모두 client component가 되기 때문에 사용할 곳을 최소화 하기 위함이다. 따라서 interactive한 UI만을 리프노드로써 client component로 만들자.
Server와 Client 컴포넌트는 동일한 컴포넌트 트리상에서 결합될 수 있다. 리액트는 다음과 같이 동작한다.
다음 패턴은 잘못되었다. Server 컴포넌트는 Client 컴포넌트 내에서 import 할 수 없다.
'use client';
// This pattern will **not** work!
// You cannot import a Server Component into a Client Component.
import ExampleServerComponent from './example-server-component';
export default function ExampleClientComponent({
children,
}: {
children: React.ReactNode;
}) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ExampleServerComponent />
</>
);
}
따라서 권장되는 패턴은 Server 컴포넌트를 Client 컴포넌트의 props로 전달하는 방법이다. 이렇게 하면 Server 컴포넌트는 서버에서 렌더링 되고 Client 컴포넌트가 클라이언트에서 렌더링 되었을 때 props로 전달된 Server 컴포넌트가 Server 컴포넌트의 렌더링된 결과로 표시된다.
보통은 children props를 사용하라고 한다. 물론 다른 props를 써도 문제는 없다.
Server에서 Client로 컴포넌트로 전달하는 props는 serilization을 해야 한다고 한다. Date, 함수, 등의 값들은 직접적으로 전달할 수 없다. 따라서 JSON.stringify로 직렬화 하라는 것 같다.
자바스크립트 모듈은 서버랑 클라이언트 컴포넌트 모두에서 공유될 수 있는데 서버에서 실행하기만 의도했던 코드를 클라이언트에서 실행할 수 도 있다. 이는 보안적으로 민감한 정보가 있을 때 문제가 될 수 있다.
예를 들어 API_KEY를 환경변수로 사용한다고 했을 때 NEXT_PUBLIC prefix가 없는 환경변수를 사용하는 코드가 있다면 Server에서만 접근할 수 있는 private한 변수일 것이다. 클라이언트에서 이 코드를 사용하게 되면 이런 민감한 정보가 노출될 우려가 존재한다. 또한 클라이언트에서 제대로 동작하지도 않을것이다.
그래서 이런 상황을 방지하기 위해 server-only
라는 패키지를 사용할 수 있다. 이건 검사용 패키지이다. 만약 이 패키지를 import한 코드를 클라이언트 측에서 사용하면 빌드타임 오류가 발생한다. 개발자의 실수를 방지해주기 때문에 유용할 것으로 보인다.
import 'server-only'; // 뭐 이런식임
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
data를 client 컴포넌트에서 fetch할 수 있지만 특별한 이유가 없다면 더 나은 성능과 사용자 경험을 위해 Server에서 하는걸 권장한다고 한다.
Sever 컴포넌트가 새로 나온것이라 third-party 패키지들은 클라이언에서만 사용할 수 있는 feature를 사용한다면 use client
문을 상단에 추가해야 한다. 하지만 지금 많은 npm 패키지에 이게 적용안되어 있어서 선언이 안되어 있으면 client 컴포넌트 내에서 사용하면 client로 동작하겠지만 server컴포넌트 내에서는 정상적으로 동작 안 할 수 가 있다.
그래서 이런 경우에는 third-party 컴포넌트를 client 컴포넌트로 만들어서 사용하면 된다.
'use client';
// 요렇게 하면 client 컴포넌트가 되는것임
import { AcmeCarousel } from 'acme-carousel';
export default AcmeCarousel;
next 13에서 Server 컴포넌트 내에선 context를 사용할 수 없다고 한다. 당연한게 state를 못 쓰는데 context를 쓸 수 있을리가 없다. 그래서 얘들이 Server component내에서도 데이터를 공유할 수 있게 하기 위해 대안점을 찾고 있다고 한다.
💡 NoteProvider 는 최대한 트리 깊숙한 곳에 위치시키라 한다. 결국 Provider부터 client 컴포넌트니까 최대한 아래에 위치시켜야 최적화가 가능하다.
Provider도 위에 처럼 third-party 지원하는것중에 안되는거 있으면 저렇게 따로 생성해서 use client
선언해서 쓰라한다. 14버전 될때쯤이면 다들 지원할려나..
Sever component에선 context를 못쓰니깐 singleton처럼 모듈하나 만들어서 모든 파일에서 하나의 인스턴스에 접근할 수 있게 쓰라고 한다.
app dir의 꽃이다. pages directory에서 완전 새롭게 변경되었다. 공유 레이아웃, 중첩 라우팅, 로딩 상태, 에러 핸들링 등 유용한것들을 지원한다.
app directory 안에 있는 컴포넌트들은 모두 default로 Server 컴포넌트이다.
여기서 UI를 구성하기 위한 주요 file들이 있다.
notFound
함수가 route segment에서 throw됐을때나 URL에 일치하는 route가 존재하지 않을때 표시한다.위 파일들이 이런 구조로 되어 있는것이다.
중첩구조면 이런 구조가 중첩되어 있는것으로 보면된다.
pages 디렉토리는 client-side routing을 하는데 app router는 server-centric routing을 해서 server component 및 server에서의 data fetching과 일치시킨다.
server-centric routing을 통해 client는 route map을 다운로드할 필요가 없으며 server 컴포넌트에 대한 동일한 요청을 통해 라우트를 검색할 수 있다. 이런 최적화는 모든 어플리케이션에 유용하지만 route가 굉장히 많을때 엄청난 효과를 발휘한다고 되어있다.
라우팅이 server-centric이어도 라우터는 Link 컴포넌트를 통해 SPA처럼 client-side navigation을 사용한다고 한다. 즉, 사용자가 새로운 라우트로 이동할 때 브라우저는 페이지를 리로드하지 않는다. 대신, URL이 갱신되고 변화가 있는 부분만이 렌더링 된다.
게다가 사용자가 앱을 탐색할 때 라우터는 React Server 컴포넌트 페이로드의 결과를 클라이언트 측 캐시에 저장한다. 캐시는 route segements로 쪼개져있어 모든 수준에서 무효화할 수 있으며 React의 동시성 렌더링 간 일관성을 보장한다. 이는 특정한 경우 이전에 가져온 세그먼트의 캐시를 사용해 성능을 향상시킬 수 있음을 말한다.
이 부분은 아직 잘 와닿지가 않는다. 어렵다..
형제 관계인 route 사이에서의 이동시에 next.js는 변화하는 route안의 page와 layout만을 fetch하고 렌더링 한다. 이외에 서브트리 세그먼트 위의 것들은 그대로다. refetch도 rerender도 하지 않는다. 아주 아름답다 재사용성 측면에서 굉장히 효율적인것 같다.
app 디렉토리 내의 폴더는 route를 정의하기 위해 사용된다. 각 폴더가 결국 route의 한 부분을 나타낸다. 중첩 route를 만들려면 폴더도 중첩으로 만들면 된다. 굉장히 쉽다. 직관적이다. 근데 route가 너무 많으면 폴더가 점점 deep해져서 블랙홀처럼 되는 문제가 생길 수도 있을것 같다.
route를 나타내는 폴더에서 page.js
파일은 라우트 segment를 접근가능하게 만든다.
이 사진을 보면 analytics에는 page.js 파일이 없기 때문에 접근이 불가능하다는 의미다. 이런 폴더들은 단순히 컴포넌트, 스타일시트, 이미지나 테스트 등 다른 파일들을 저장하는데 사용할수도 있다.
page는 route에 대한 unique한 UI이다. page.js파일에서 컴포넌트를 export해서 정의할 수 있다.
그렇다면 layout은 여러 페이지 사이에서 공유하는 UI이다. route 이동시 layout은 상태를 보존하고, interactive한 상태로 남아있는다. 또한 리렌더도 하지 않는다.
레이아웃을 만들려면 layout.js파일을 만들면 된다. layout.js는 child layout이나 page를 감싸기 위해 children props를 설정해주어야 한다.
useSelectedLayoutSegement
나 useSelectedLayoutSegements
훅을 client component에서 사용할 수 있다.Root layout은 app 디렉토리 가장 상단에 정의되어 있고 모든 route에 적용된다. 이 레이아웃을 통해 서버에서 반환되는 초기 HTML의 형태를 수정할 수 있다.
export default function RootLayout({
children,
}: {
children: React.ReactNode; // 요게 페이지나 레이아웃임
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Layout도 페이지처럼 폴더구조 내에서 중첩으로 선언할 수 있다. layout은 계층관계를 통해 부모 자식관계로써 부모가 자식을 감싸는 형태로 만들어진다.
Templates는 레이아웃과 유사하다. layout과 다른점은 layout은 route이동 간에 상태를 보존하나 template은 이동 시에 자식에 대해 새로운 인스턴스를 생성한다. 즉, route간 이동 시 새로운 컴포넌트 인스턴스가 mount 된다.(DOM element가 새로 생성되고, 상태가 보존되지 않으며, 효과들이 다시 동기화 된다.
템플릿은 다음의 경우에 필요할 수 있다고 한다.
<Layout>
{/* 템플릿엔 unique key를 전달해야 하는것 같다. */}
<Template key={routeParam}>{children}</Template>
</Layout>
<head/>
title 태그나 meta태그 같은 head 태그 내 HTML 엘리먼트는 내장 SEO 지원을 통해 변경할 수 있다.
Metadata는 레이아웃이나 페이지 파일에서 metadata
object나 generateMetadata
함수를 export 해서 정의할 수 있다.
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Next.js',
};
export default function Page() {
return '...';
}
이 때 직접 head태그로 선언하면 안된다고 한다. Metadata API를 사용해야 streaming과 head 엘리먼트에 대한 중복제거가 자동으로 된다고 한다.
Next.js 라우터는 client-side 이동과 함께 server-centric routing을 사용한다. 이렇게 하면 즉시 로딩 상태와 동시 렌더링을 지원한다. 뭔 뜻이냐면 이동할때 클라이언트 상태를 유지하고 비싼 리렌더링을 방지하며 방해가 되지 않고 race condition을 일으키지 않는다는 뜻이다.
그래서 routing하는 방법은 두가지가 있다.
<Link>
컴포넌트Link랑 useRouter는 이전 pages directory와 내용이 같아 정리하지 않았다.
app 폴더의 계층은 URL 경로로 직접 매핑된다. 하지만 route group을 생성하면 이러한 패턴을 벗어날 수 있다. 라우트 그룹은 다음의 경우에 사용된다.
route group을 사용할려면 폴더명을 괄호 ()
로 묶는다. ex) (folderName)
라우트 그룹을 통해 관련된 라우트들을 함께 유지할 수 있다. 괄호 안 폴더는 URL에서 제외된다.
(marketing)과 (shop)이 같은 URL 계층을 공유하고 있지만 route group 폴더에 layout.js 파일을 생성해 각기 다른 layout을 적용할 수 있다.
특정한 라우트를 레이아웃으로 선택하려면 새 route group을 생성하고 같은 레이아웃을 그룹에서 공유하게 한다. group밖의 route는 레이아웃을 공유하지 않을 것이다.
root layout을 여러개 생성하려면 루트의 layout.js 파일을 삭제하고 각각의 route group 폴더에 layout.js 파일을 생성한다. 이렇게 하면 어플리케이션을 완전히 다른 UI와 경험을 가지는 영역으로 쪼갤 수 있다. 각 root layout은 html, body 태그를 정의해주어야 한다.
이 때 multiple root layout 간 이동은 full page reload가 적용된다.
미리 정확한 route segment명을 알 수 없고 동적인 데이터로 route를 생성하고 싶을 때 Dynamic segments를 사용할 수 있다. pages directory에서 사용했던 거랑 동일한것 같다.
Dynamic segment는 폴더명을 square 괄호로 감싸서 생성할 수 있다.
예를 들어 app/blog/[slug]/page.js 파일이 있으면 아래 코드의 params값에 slug 부분의 값을 동적으로 전달한다.
export default function Page({ params }) {
return <div>My Post</div>;
}
generateStaticParams
함수는 요청에 on-demand로 생성하는 대신 빌드시간에 정적으로 라우트를 생성하기 위해 dynamic route와 함께 사용할 수 있다.
pages dir의 getStaticPaths
와 유사한것 같다.
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json());
return posts.map((post) => ({
slug: post.slug,
}));
}
이 함수를 사용하면 fetch 요청을 사용해 generateStaticParams
함수 내에서 콘텐츠를 가져오면 요청이 자동으로 중복제거된다. 즉, 여러개의 generateStaticParams
, Layout과 Pages에서의 동일한 인수를 가진 요청이 한번만 실행되므로 빌드시간이 단축된다.
[...folderName]
형식으로 폴더명을 설정하면 여러개의 route segment에 대응할 수 있는 catch-all segement를 설정할 수 있다.
이건 catch-all이랑 비슷한데 옵셔널이라서 아무것도 전달하지 않은것도 커버가 된다. 문법은 [[...folderName]]
loading.js
파일은 React Suspense와 함께 로딩 UI를 생성할 수 있게 도와준다. 이를 통해 route segment의 콘텐츠가 로드 되는동안 서버로부터 즉각적인 로딩 상태를 보여줄 수 있게 된다. 렌더링이 완료되면 새 콘텐츠가 자동으로 swap-in 된다.
instant loading state는 탐색 시에 즉시 표시되는 fallback UI이다. 스켈레톤이나 스피너같은 로딩 UI를 pre-render해서 보여줄 수 있다. 이렇게 하면 사용자가 앱이 응답하고 있는 이해할 수 있게 도와주기 때문에 더 나은 사용자경험을 제공할 수 있다. loading UI를 추가할려면 폴더 내에 loading.js
를 추가하자.
loading.js
는 layout.js
파일에 감싸지게 되며 자동으로 page.js
파일과 자식들을 Suspense boundary로 감싼다.
loading.js
이외에도 UI 컴포넌트에 대한 Suspense boundary를 수동으로 만들 수도 있다. App router는 Node.js와 Edge 런타임에 대해 Suspense를 통한 Streaming을 지원한다.
SSR은 사용자가 페이지를 보고 상호작용할 수 있게 될 때까지 몇가지의 단계가 필요하다.
이러한 단계는 순차적이고 blocking 될 수 있다. 즉, 서버는 모든 데이터가 fetch되어야 페이지에 대한 HTML을 렌더링할 수 있다. 그리고 클라이언트에서는 모든 컴포넌트에 대한 코드가 다운로드 되어야만 UI를 hydrate할 수 있다.
React와 Next.js가 포함된 SSR은 가능한 한 빨리 사용자에게 non-interactive한 페이지를 표시해 인지되는 로딩 성능을 향상시킨다. 하지만 여전히 페이지가 사용자에게 보여지기 전 모든 데이터를 fetching하는 시간때문에 느릴 수 있다.
Streaming은 page의 HTML을 작은 chunk단위로 나누어 점진적으로 서버에서 클라이언트로 전달할 수 있게 해준다.
이렇게 하면 UI를 렌더링하기전에 모든 데이터가 로드될 때까지 기다리지 않고 페이지 일부분을 더 빨리 표시할 수 있다.
Streaming은 React component가 각각 chunk로 간주되어 잘 작동한다. 더 높은 우선순위를 가지거나 데이터에 의존하지 않는 컴포넌트는 먼저 전송되어 리액트가 hydration을 더 일찍 시작할 수 있게 된다. 낮은 우선순위를 가지는 컴포넌트는 동일한 서버 요청에서 데이터가 fetch되고 난 후에 전송된다.
Streaming은 TTFB 및 FCP를 줄일 수 있기 때문에 긴 데이터 요청이 페이지 렌더링을 차단하지 않도록 할려면 유용하다. 또한 특히 느린 장치에서 TTI를 개선하는데 도움이 된다.
import { Suspense } from 'react';
import { PostFeed, Weather } from './Components';
export default function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather...</p>}>
<Weather />
</Suspense>
</section>
);
}
이렇게 Suspense를 사용함으로써 점진적으로 HTML을 서버로부터 클라이언트에게 렌더링할 수 있다. 또한 사용자 인터랙션에 기반해 리액트가 어떤 컴포넌트를 먼저 interactive하게 만들어야 하는지 우선순위를 정할 수 있다.
loading.js
는 로딩 UI를 담당했다면 error.js
는 error UI를 담당한다.
error.js
는 route segment와 자식들을 React Error boundary로 감싼다.'use client'; // 에러 컴포넌트는 무조건 client component여야 한다.
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void; // 사용자가 recover할 수 있게 error boundary내 콘텐츠를 re-render하는 함수를 제공한다.
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}
주의할 점은 error.js는 Layout의 child이기 때문에 동일한 segment내의 layout의 오류는 catch할 수 없다. 이런 경우 상위 segment에서 error.js를 작성해 catch해야 한다.
만약 root layout이나 template의 오류를 잡기 위해선 global-error.js
파일을 작성해서 사용하면 된다. 이 때 global-error.js
는 가장 root 엘리먼트가 되기 때문에 html, body 태그를 꼭 포함해야 한다.
병렬 라우팅은 동일 레이아웃에서 하나 이상의 페이지를 동시 또는 조건부로 렌더링할 수 있게 해준다. 대시보드나 소셜 사이트의 피드같은 앱에서 매우 동적인 부분은 복잡한 라우팅 패턴을 구현하기 위해 병렬 라우팅을 사용할 수 있다고 한다.
이런식으로 동시에 두가지 페이지를 보여줄 수 있다. 또한 각각에 대해 독립적인 error와 loading 상태를 정의할 수 있다 이 때 각각 독립적으로 stream된다.
병렬 라우팅은 조건부로 slot을 특정한 상태(인증 상태같은)에 렌더링할 수 있게 해준다.
병렬 라우트 폴더는 @folderName
로 이름을 짓는다. 그리고 같은 레벨의 layout에 props로 전달되게 된다.
위 폴더구조에서 layout.js
는 @analytics
와 @team
슬롯을 props로 전달받아 children과 함께 렌더링할 수 있게 된다.
export default function Layout(props: {
children: React.ReactNode;
// 이런식으로 slot들을 전달받는다.
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<>
{props.children}
{props.team}
{props.analytics}
</>
);
}
기본적으로 slot의 콘텐츠는 현재 URL에 일치하게 된다.
default.js
default.js
파일을 정의하면 현재 URL에 기반해 slot의 활성화 상태를 회복할 수 없는 경우 fallback으로 보여줄 UI를 정의할 수 있다.여기 이해가 잘 안된다.. 나중에 다시 봐야할것같다.
useSelectedLayoutSegement
와 useSelectedLayoutSegements
훅은 해당 슬롯 내 활성화된 라우트 segment를 읽을 수 있게 해주는 parallelRoutesKey
를 사용할 수 있다.
'use client';
import { useSelectedLayoutSegment } from 'next/navigation';
export default async function Layout(props: {
//...
authModal: React.ReactNode;
}) {
// 유저가 @authModal/login이나 /login URL로 이동 시 loginSegements의 값이 "login"이 된다.
const loginSegments = useSelectedLayoutSegment('authModal');
// ...
}
병렬 라우팅은 모달을 렌더링하기 위해 사용할 수 있다.
@auth
slot은 특정 route에서 모달 컴포넌트를 렌더링한다. 예를 들어 위 예시에서 /login
경로일 시 app/@auth/login/page.js
를 렌더링하게 된다.
모달이 활성화 상태가 아닐 때 렌더링 하지 않기 위해서 default.js
파일을 생성해서 null을 반환할 수 있다.
// app/@auth/login/default.tsx
export default function Default() {
return null;
}
모달이 client 이동을 통해 표시되었기 때문에 router.back()
이나 Link 컴포넌트를 통해 모달을 해제할 수 있다.
만약 다른곳으로 이동했을 때 모달을 해제하기 위해서 catch-all route 또한 사용할 수 있다. catch-all 폴더 내의 page.js에서 return null을 하면 된다.
intercepting route는 현재 페이지의 문맥을 유지하면서 현재 레이아웃 내에 route를 로드할 수 있게 해준다. 특정 경로를 차단해 다른 경로를 표시하려는 경우 유용하다.
예를 들어 피드내 사진을 클릭했을 때 피드의 사진과 함께 모달이 표시 피드위에 표시되어야 한다면 Next.js는 /feed
route를 가로채서 URL을 mask하여 /photo/123
을 대신 표시한다.
만약 이 기법을 사용하지않으면 모달 대신 전체 photo 페이지가 렌더링 될 것이다.
이것도 컨벤션이 있다. 컨벤션이 너무 많은게 아닌가 싶기도 하다?.. 아무튼 (..)
이렇게 작성할 수 있다. 상대경로와 비슷하다.
(.)
같은 레벨의 segment에 매치된다(..)
한 레벨 위의 segement에 매치된다(..)(..)
두 레벨 위다.(...)
root의 segement랑 매치된다.흠... 조금 지저분한거같다. 문법이 마음에 안든다.
예를 들어 photo segement를 feed segment 내에 intercept하기 위해서 (..)photo
디렉토리를 생성한다.
intercepting routes는 parallel route랑 함께 모달을 만들기 위해 사용할 수 있다. 이렇게 하면 다음의 challenge들을 극복할 수 있다고 한다.
주로 모달에서 쓰이는듯하다. 직접 구현해보아야 감이 잡힐것 같다.
Route handler는 Web Request와 Response API를 사용해서 특정 route에 대해 custom 요청 핸들러를 작성할 수 있게 해준다.
pages dir의 api routes와 동등한것이라 한다. route handler는 app dir내에서만 사용할 수 있다.
Route handler는 app directory내에 route.js|ts
로 정의한다.
// app/api/route.ts
export async function GET(request: Request) {}
route handler도 page나 layout처럼 중첩해서 작성가능하다. 하지만 같은 route segment레벨 내에 page.js
와 함께 위치할 수 없다.
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
의 HTTP 메소드를 지원한다. 만약 지원되지않는 HTTP 메소드를 호출하면 Next.js가 405 Method Not Allowed
응답을 한다.
Route handler는 GET 메소드와 Response 객체를 함께 사용할 때 정적으로 평가된다.
import { NextResponse } from 'next/server';
export async function GET() {
const res = await fetch('https://data.mongodb-api.com/...', {
headers: {
'Content-Type': 'application/json',
'API-Key': process.env.DATA_API_KEY,
},
});
const data = await res.json();
// Response.json()도 가능한데 이렇게하면 Type 오류가 난다고 한다.
return NextResponse.json({ data });
}
다음의 경우에 route handler는 동적으로 평가된다고 한다.
cookies
나 headers
같은 Dynamic 함수를 사용할 때미들웨어는 이전과 다른게 없어서 pages dir 정리한 내용에서 다시 보면 되겠다.
https://velog.io/@asdf99245/Next.js-pages-router-%EC%A0%95%EB%A6%AC
어플리케이션 코드가 렌더되는 환경은 클라이언트와 서버로 두 가지다.
client side와 server side에서 React component를 렌더링하는것 외에도 Next.js는 서버에서 Static과 Dynamic 렌더링을 통해 렌더링을 최적화할 수 있는 옵션을 제공한다.
서버에는 페이지를 렌더링할 수 있는 두 가지 런타임이 존재한다.
두가지 모두 streaming을 지원한다.
Next.js에서 route는 static or dynamic하게 렌더링 될 수 있다고 했다.
static rendering은 모든 렌더링 작업을 미리 수행하고 지리적으로 사용자와 가까운 CDN에 제공되기 때문에 성능적으로 매우 좋다.
layout이나 page에서 동적 기능이나 동적 데이터 fetching을 통해 동적 렌더링을 사용할 수 있다. 이렇게 하면 Next.js는 요청 시간에 전체 route를 동적으로 렌더링한다.
Next.js는 default로 캐시 동작을 특별히 해제하지 않는 fetch()
요청의 결과를 캐시한다. 즉, 캐시 옵션을 설정하지 않은 fetch 요청은 force-cache옵션을 사용한다.
만약 fetching 요청중에 revalidate
옵션을 사용한다면 route는 revalidation중에 정적으로 리렌더링 될 것이다.
정적렌더링중 dynamic function이나 dynamic fetch()
요청이 발견되면 Next.js는 전체 라우트를 요청시간에 렌더링하는 dynamically 렌더링으로 전환한다. 모든 캐시된 데이터 요청은 dynamic rendering중에 재사용 가능하다.
dynamic function은 요청시간에만 알 수 있는 쿠키나 요청 헤더, URL의 검색 파라미터등의 정보에 기반한다.
dynamic data fetch는 fetch()
요청에서 caching 옵션을 no-store
로 설정하거나 revalidate
를 0으로 설정했을 때이다.
segement config 옵션으로도 캐시옵션을 설정할 수 있다고 한다.
App router에서 data fetching 시스템이 단순하게 바꼈다. getServerSideProps
나 getStaticProps
를 사용하지 않고 fetch()
로 모두 통합하였다.
fetch()
API이름을 보면 익숙하다. 새로운 fetching system은 native fetch()
Web API 를 기반으로 만들어졌다. 이를 통해 Server component에서 async await문법을 사용할 수 있다.
fetch
를 확장한다.가능하면 Server component에서 데이터 fetching을 하라고 한다. 이는 다음의 이점이 있다.
여전히 client component에서도 가능하며 SWR이나 React Query를 사용하기를 권장한다고 한다.
App router에선 layout, page, component 모두에서 data를 fetch할 수 있다. Streaming과 Suspense와 호환 또한 가능하다.
Layout은 부모 레이아웃과 자식 컴포넌트 사이에서 데이터 전달이 불가능하다고 한다. 그냥 필요한 layout에서 data fetch하라고 하는데 어짜피 next.js가 중복된 데이터 요청이어도 알아서 캐시하고 중복요청 제거를 해준다고 한다. Wow
data fetching 패턴에 두 가지가 있다.
fetch()
Request Deduping만약 트리 내 다수의 컴포넌트에서 동일한 데이터를 fetch해야 된다면 Next.js가 동일한 input을 가지는 fetch요청(GET만 인듯?)을 자동으로 캐시한다. 이는 렌더링 시 동일한 데이터가 fetch되는 상황을 방지한다.
generateMetadata
와 generateStaticParams
에서 수행되는 fetch 요청에 적용된다.데이터는 두가지 종류가 존재한다.
기본적으로 next.js는 static fetch를 한다. 즉, data는 빌드타임에 fetch되어 각 요청마다 재사용된다. pages dir의 SSG같은거다. 개발자는 static data가 어떻게 캐시되고 revalidate될지 제어할 수 있다.
static data의 이점은 다음과 같다.
하지만 데이터가 개인화되어야하고 최신 데이터를 항상 fetch하고 싶다면 dynamic fetch를 하면 된다. pages dir의 SSR같은거다.
Next.js cache는 글로벌로 배포할 수 있는 영구적인 HTTP cache이다. 즉 플랫폼에 따라 캐시를 자동으로 확장하고 여러 지역에서 공유할 수 있다.
Next.js는 fetch()
함수의 옵션 객체를 확장하여 서버의 각 요청이 고유한 영구 캐싱 동작을 설정할 수 있도록 한다. 컴포넌트 수준의 data fetching을 사용하면 데이터가 사용되는 어플리케이션 코드 내에서 직접 캐싱을 구성할 수 있다.
서버렌더링 중 next.js가 fetch를 발견하면 캐시를 먼저 확인해 데이터를 사용할 수 있는지 확인한다. 이 경우 캐시된 데이터가 반환되고 그렇지 않다면 나중에 요청할 데이터를 가져와 캐시에 저장한다.
Revalidation은 캐시를 삭제하고 최신 데이터를 가져오는 프로세스이다. 이는 데이터가 변경되어 전체 응용 프로그램을 재구성하지 않고 어플리케이션에 최신 버전이 표시되게 하려는 경우 사용할 수 있다.
두가지 revalidate 방법을 제공한다.
Streaming과 Suspense가 있기 때문에 사용자는 전체 페이지가 로드되는 것을 기다리지 않고도 상호작용을 할 수 있게 된다. 데이터 fetching을 하는 일부분은 loading UI를 보여주게 된다.
async
and await
in Server components제안된 React RFC로 async await 문법을 데이터를 요청하기 위해 Server 컴포넌트에서 사용할 수 있다.
async function getData() {
const res = await fetch('https://api.example.com/...');
// 반환된 값은 직렬화되어있지 않다.
// You can return Date, Map, Set, etc.
// Recommendation: handle errors
if (!res.ok) {
// 가장 가까운 error boundary가 catch한다.
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function Page() {
const data = await getData();
return <main></main>;
}
{/* @ts-expect-error Async Server Component */}
일단 요거를 위에다가 선언해서 오류를 없애라고 한다.use
in Client 컴포넌트use
는 await와 개념적으로 유사한 promise를 받아들이는 새로운 리액트 함수다. use
는 컴포넌트, 훅, Suspense와 호환가능한 방법으로 함수에서 반환되는 promise를 처리한다.
fetch
를 use
에 감싸는 방법은 현재는 client 컴포넌트에서 권장되지 않고 다수의 리렌더링을 발생시킬 수 있다. 따라서 client 컴포넌트에서 당장은 third-party 라이브러리인 SWR이나 React-query(Tanstack-query)사용을 권장한다고 한다.
fetch
는 기본적으로 자동으로 데이터를 무제한으로 fetch하고 캐시한다. force-cache
가 default
특정 시간 내에 캐시된 데이터를 revalidate하려면 next.revalidate
옵션을 사용할 수 있다.
// cache의 lifetime을 설정한다 (초 단위)
fetch('https://...', { next: { revalidate: 10 } });
데이터를 매 요청마다 최신의 것으로 가져오고싶다면 cache: 'no-store'
옵션을 사용할 수 있다.
// 캐시를 사용하지 않는다.
fetch('https://...', { cache: 'no-store' })
clinet-server waterfall을 최소화하기 위해 이 패턴을 권장한다고 한다.
import Albums from './albums';
async function getArtist(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}`);
return res.json();
}
async function getArtistAlbums(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}/albums`);
return res.json();
}
export default async function Page({
params: { username },
}: {
params: { username: string };
}) {
// 각 요청을 병렬로 초기화
const artistData = getArtist(username);
const albumsData = getArtistAlbums(username);
// Promise.all로 병렬로 처리
const [artist, albums] = await Promise.all([artistData, albumsData]);
return (
<>
<h1>{artist.name}</h1>
<Albums list={albums}></Albums>
</>
);
}
Server 컴포넌트에서 fetch를 await
호출전에 fetch를 시작하면, 각 요청이 동시에 요청을 가져오기 시작할 수 있다. 이렇게 하면 컴포넌트가 설정되어 waterfall을 피할 수 있다.
두 요청을 동시에 시작하면 시간을 절약할 수 있으나 사용자는 promise가 모두 resolve되기 이전까지 렌더링된 결과를 볼 수 없다.
사용자 경험을 향상시키기 위해 suspense boundary를 추가해서 렌더링 작업을 분할하고 가능한 한 빨리 결과의 일부를 표시할 수 있다.
import { getArtist, getArtistAlbums, type Album } from './api';
export default async function Page({
params: { username },
}: {
params: { username: string };
}) {
// 두 요청을 병렬로 초기화한다.
const artistData = getArtist(username);
const albumData = getArtistAlbums(username);
// 먼저 artist의 promise가 resolve되기를 기다린다.
const artist = await artistData;
return (
<>
<h1>{artist.name}</h1>
{/* 먼저 artist 정보를 전송하고
albums는 susnpense boundary로 감싼다.
이렇게 렌더링 작업을 분할한다. */}
<Suspense fallback={<div>Loading...</div>}>
<Albums promise={albumData} />
</Suspense>
</>
);
}
// Albums Component
async function Albums({ promise }: { promise: Promise<Album[]> }) {
// album promise가 resolve되기를 기다린다.
const albums = await promise;
return (
<ul>
{albums.map((album) => (
<li key={album.id}>{album.name}</li>
))}
</ul>
);
}
데이터를 순차적으로 가져오기 위해서 필요한 컴포넌트에서 직접적으로 fetch를 사용하거나 await
를 사용할 수 있다.
// ...
async function Playlists({ artistID }: { artistID: string }) {
// Wait for the playlists
const playlists = await getArtistPlaylists(artistID);
return (
<ul>
{playlists.map((playlist) => (
<li key={playlist.id}>{playlist.name}</li>
))}
</ul>
);
}
export default async function Page({
params: { username },
}: {
params: { username: string };
}) {
// Wait for the artist
const artist = await getArtist(username);
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<div>Loading...</div>}>
<Playlists artistID={artist.id} />
</Suspense>
</>
);
}
차이점은 초기에 병렬로 초기화하지 않는 부분인것 같다.
layout에서 데이터를 요청하면 모든 아래에 있는 route segment에 대한 렌더링은 데이터 로드가 완료된 후에만 시작할 수 있다.
페이지 디렉토리에서 서버 렌더링을 사용하는 페이지는 getServerSideProps
가 완료될때까지 브라우저 로딩 스피너를 표시한 다음 해당 페이지에 대한 컴포넌트를 표시한다. 이는 “전체 or 없음” 데이터 fetch로 설명되는데 전체 데이터가 있거나 없거나이다.
app directory에서는 몇가지 옵션이 있다.
loading.js
를 사용하여 data fetching 의 결과로 streaming하는 동안 서버에서 즉시 로드상태를 표시할 수 있다.
data fetching을 컴포넌트 트리의 하단으로 위치시켜 필요한 페이지의 부분에서만 렌더링이 블록되게 한다. 예를 들어 루트 레이아웃보다 특정한 컴포넌트에 data fetch를 위치시킨다.
이렇게하면 전체 페이지가 blocking 되지 않고 특정 페이지의 부분만 로딩 상태를 보여주게 된다.
fetch()
ORM이나 데이터베이스 클라이언트 등 third-party 라이브러리를 사용해서 fetch
를 사용할 수 없을 수도 있다.
이런 경우 여전히 캐싱과 revalidating 동작을 제어하고싶다면 segment의 default caching behavior이나 segement cache configuration을 사용할 수 있다.
fetch
를 사용하지 않는 data fetching 라이브러리는 route의 캐싱에 영향을 주지않을 것이고 route segement에 의존해 정적 or 동적이 될 것이다.
만약 segment가 정적(기본값)이면 요청의 결과가 세그먼트의 나머지 부분과 함께 캐시되고 revalidate될 것이다. 만약 동적이면 요청의 결과가 캐시되지 않고 렌더링될때마다 다시 가져온다.
cookies()
, headers()
등이 route segement를 동적으로 만든다.일시적인 해결법으로 third-party query의 캐싱 동작이 설정될 때까지 전체 segment에 대해 segment configuration을 사용해서 캐시 동작을 커스텀 수 있다.
import prisma from './lib/prisma';
export const revalidate = 3600; // revalidate를 매 시간마다 한다.
async function getPosts() {
const posts = await prisma.post.findMany();
return posts;
}
export default async function Page() {
const posts = await getPosts();
// ...
}
fetch()
기본적으로 모든 fetch()
요청은 캐시되고 자동으로 중복요청이 제거된다. 즉, 만약 동일한 요청을 두번하면 두번째 요청은 캐시된 요청을 재사용할 것이다.
async function getComments() {
const res = await fetch('https://...'); // 요게 캐시됨
return res.json();
}
// 두번 호출되어도 한번만 fetch 된다.
const comments = await getComments(); // cache MISS
// 두번째 호출은 어플리케이션 내 어디든 위치할 수 있다.
const comments = await getComments(); // cache HIT
다음의 경우 캐시되지 않는다.
next/headers
, export const POST
같은 것들)가 사용되었을 때와 fetch가 POST
요청일 경우fetchCache
가 default로 cache를 스킵하게 설정되어있다면revalidate: 0
이나 cache: 'no-store'
가 fetch에 설정되어 있을 때fetch
를 사용한 요청은 revalidation 빈도를 제어하기 위해 revalidate
옵션을 설정할 수 있다.
export default async function Page() {
// 10초마다 revalidate
const res = await fetch('https://...', { next: { revalidate: 10 } });
const data = res.json();
// ...
}
cache()
React는 cache()
를 통해 요청 중복을 제거하고 함수 호출의 결과를 메모이징 할 수 있게 해준다. 동일한 인자에 대한 함수 호출은 캐시되어 재사용된다.
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
const user = await db.user.findUnique({ id });
return user;
});
fetch()
는 기본적으로 요청을 캐시하기 때문에 fetch()
를 사용하는 함수에서 cache()
를 사용할 필요는 없다.server-only
패키지로 client에서 사용되지 않을 data fetch는 서버에서 하도록 보장하라고 한다.cache()
패턴으로써 data fetching을 하는 유틸리티나 컴포넌트에서 preload()
를 export 하기를 권장한다고 한다.
import { getUser } from '@utils/getUser';
// 함수명은 preload말고 아무거나 다른걸로 해도됨
export const preload = (id: string) => {
// void evaluates the given expression and returns undefined
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/void
void getUser(id);
};
export default async function User({ id }: { id: string }) {
const result = await getUser(id);
// ...
}
// app/user/[id]/page.tsx
import User, { preload } from '@components/User';
export default async function Page({
params: { id },
}: {
params: { id: string };
}) {
// preload를 호출하여 필요한 데이터를 prefetch 할 수 있다.
preload(id); // starting loading the user data now
const condition = await fetchCondition();
return condition ? <User id={id} /> : null;
}
Segement-level 캐싱은 route segements에서 사용될 데이터를 캐시하고 revalidate 할 수 있게 한다.
이 메커니즘은 route의 다른 segment가 전체 route의 캐시 수명을 제어할 수 있다. route 계층의 각 page.tsx와 layout.tsx는 revalidate 시간을 설정하는 revalidate 값을 export할 수 있다.
// app/page.tsx
export const revalidate = 60; // revalidate this segment every 60 seconds
fetchCache
를 ‘only-cache’
나 'force-cache'
로 설정하면 모든 fetch 요청이 캐시로 선택되지만 개별 fetch 요청에 의해 revalidation 빈도가 낮아질 수 있다.next.js는 Revalidation(ISR)을 통해 정적 route를 전체 페이지를 재생성하지 않고도 갱신할 수 있게 해준다.
캐시된 데이터를 특정 시간마다 revalidate하고싶으면 fetch()
의 next.revalidate
를 사용하면 된다. 앞에서도 봤었던 그대로다.
fetch('https://...', { next: { revalidate: 60 } }); //60초마다 revalidate
만약 fetch를 사용하지 않을 때 revalidate하고싶다면 route segment config를 사용하면 된다.
// 이걸 page에서 export
export const revalidate = 60; // revalidate this page every 60 seconds
만약 revalidate
를 60초로 해놓았다면 모든 방문자는 1분동안 같은 버전의 사이트를 보게 될것이다. 캐시를 재검증하기 위한 방법은 누군가가 1분후에 페이지를 방문하는 방법 밖에 없다.
Next.js App router는 route와 cache 태그에 기반해 필요할 때 content를 revalidating할 수 있게 지원한다. 이는 수동으로 next.js 캐시를 제거하고 사이트를 더 쉽게 갱신할 수 있게 해준다.
데이터는 path(revalidatePath
)나 cache tag(revalidateTag
)를 통해 필요할 때 revalidate 할 수 있다.
export default async function Page() {
// tag로 collection을 설정
const res = await fetch('https://...', { next: { tags: ['collection'] } });
const data = await res.json();
// ...
}
이 캐시된 데이터는 route handler에서 revalidateTag
를 통해 on-demand revalidate된다.
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function GET(request: NextRequest) {
const tag = request.nextUrl.searchParams.get('tag');
revalidateTag(tag);
return NextResponse.json({ revalidated: true, now: Date.now() });
}
깔끔한 정리 정말 감사합니다!