Next.js app router 공식문서 정리

정윤규·2023년 7월 4일
23

next.js

목록 보기
2/2
post-thumbnail

지난번엔 next.js pages router 공식문서 정리글을 올렸다. 이번에는 최근에 stable이 된 app router 공식문서의 핵심 내용에 대해 번역 및 정리해보았다.

next.js 13버전에서 beta이던 app router가 13.4에서 stable에 들어서게 되었다. turbopack 또한 alpha에서 beta로 변경되었고 server action이라는 새로운 개념도 등장했다.

Server components

먼저 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 접근법이라 한다.

Why Server components?

그래서 이걸 왜 쓰냐? 장점이 뭔데? 라고 할 수 있다.

Server components를 통해 개발자는 서버 인프라를 더욱 효율적으로 사용할 수 있다. 이전에 클라이언트 자바스크립트 번들 사이즈에 영향을 주던 큰 의존성을 모두 서버에서 처리할 수 있게 된다.

따라서 Server components를 통해 초기 페이지 로드 속도가 향상되고 클라이언트 JS 번들 사이즈는 감소한다. 기존의 client 측 런타임은 캐시할 수 있고 크기를 예측 가능하며 어플리케이션이 커져도 증가하지 않는다. 추가적인 JS는 단지 어플리케이션에서 사용되는 Client components를 통해 추가된다.

내가 느끼기엔 마치 페이지 단위로 SSR을 할 수 있었던 것을 컴포넌트 단위로 세분화해서 할 수 있게 된 느낌이다.

App router에서는 기본적으로 모든 컴포넌트가 Server component가 default이다. Client component로 사용하고 싶다면 use client 선언문을 통해 변경할 수 있다.

Client components

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에 해당한다.

💡 Good to Know
  • use client 는 모든 파일에 선언할 필요가 없다. client module 경계는 한번만 선언하면 되기 때문 한번 선언되고 나선 의존관계에 있는 모든 모듈이 client component로 동작한다.

Server, Client 언제 써야해?

next.js에선 결정하기 전에 먼저 Server component를 사용하고 Client component가 필요할 때 사용하라 한다.

  • Server component
    • 데이터 fetching
    • 백엔드 자원에(직접적으로) 접근
    • 민감한 정보를 서버에서 유지(JWT, API Key 등등)
    • large dependencies를 서버에서 유지/클라이언트 JS 번들 사이즈 감소
  • Client component
    • interactivity, event listener(onClick 등)가 필요할때
    • state 및 라이프사이클이 필요할 때
    • browser-only API 사용
    • custom hook depend on state or browser-only API 사용

패턴

Client 컴포넌트는 리프노드에 두자

client component는 기본적으로 가능하다면 트리상에서 리프 노드에 두는 것을 추천한다고 한다. 이는 client component가 되고 나선 그 아래부턴 모두 client component가 되기 때문에 사용할 곳을 최소화 하기 위함이다. 따라서 interactive한 UI만을 리프노드로써 client component로 만들자.

Client와 Server Component 함께쓰기

Server와 Client 컴포넌트는 동일한 컴포넌트 트리상에서 결합될 수 있다. 리액트는 다음과 같이 동작한다.

  • 서버에서 클라이언트에 결과를 전달하기 전에 모든 Server 컴포넌트를 렌더링한다.
    • Client 컴포넌트 안에 중첩되어 있는 Server 컴포넌트 포함
    • Client 컴포넌트를 만나면 스킵한다.
  • 클라이언트에서 리액트는 Client 컴포넌트와 slot(이게 뭐지? Server Components 렌더링된 결과라고 한다.)를 렌더링 한다. 즉, 서버와 클라이언트 상의 작업을 합친다.
    • client 컴포넌트 안에 중첩되어 있는 Server 컴포넌트는 렌더된 내용이 Client 컴포넌트 내에 위치하게 된다? 이거 이해 잘안됨..

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 전달하기

Server에서 Client로 컴포넌트로 전달하는 props는 serilization을 해야 한다고 한다. Date, 함수, 등의 값들은 직접적으로 전달할 수 없다. 따라서 JSON.stringify로 직렬화 하라는 것 같다.

Server-only 코드를 Client 컴포넌트 바깥에서 유지하자

자바스크립트 모듈은 서버랑 클라이언트 컴포넌트 모두에서 공유될 수 있는데 서버에서 실행하기만 의도했던 코드를 클라이언트에서 실행할 수 도 있다. 이는 보안적으로 민감한 정보가 있을 때 문제가 될 수 있다.

예를 들어 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 fetching

data를 client 컴포넌트에서 fetch할 수 있지만 특별한 이유가 없다면 더 나은 성능과 사용자 경험을 위해 Server에서 하는걸 권장한다고 한다.

Third-party 패키지

Sever 컴포넌트가 새로 나온것이라 third-party 패키지들은 클라이언에서만 사용할 수 있는 feature를 사용한다면 use client 문을 상단에 추가해야 한다. 하지만 지금 많은 npm 패키지에 이게 적용안되어 있어서 선언이 안되어 있으면 client 컴포넌트 내에서 사용하면 client로 동작하겠지만 server컴포넌트 내에서는 정상적으로 동작 안 할 수 가 있다.

그래서 이런 경우에는 third-party 컴포넌트를 client 컴포넌트로 만들어서 사용하면 된다.

'use client';
// 요렇게 하면 client 컴포넌트가 되는것임
 
import { AcmeCarousel } from 'acme-carousel';
 
export default AcmeCarousel;

Context

next 13에서 Server 컴포넌트 내에선 context를 사용할 수 없다고 한다. 당연한게 state를 못 쓰는데 context를 쓸 수 있을리가 없다. 그래서 얘들이 Server component내에서도 데이터를 공유할 수 있게 하기 위해 대안점을 찾고 있다고 한다.

💡 Note

Provider 는 최대한 트리 깊숙한 곳에 위치시키라 한다. 결국 Provider부터 client 컴포넌트니까 최대한 아래에 위치시켜야 최적화가 가능하다.

Provider도 위에 처럼 third-party 지원하는것중에 안되는거 있으면 저렇게 따로 생성해서 use client 선언해서 쓰라한다. 14버전 될때쯤이면 다들 지원할려나..

Sever component에선 context를 못쓰니깐 singleton처럼 모듈하나 만들어서 모든 파일에서 하나의 인스턴스에 접근할 수 있게 쓰라고 한다.

Routing

Routing Fundamental

app dir의 꽃이다. pages directory에서 완전 새롭게 변경되었다. 공유 레이아웃, 중첩 라우팅, 로딩 상태, 에러 핸들링 등 유용한것들을 지원한다.

app directory 안에 있는 컴포넌트들은 모두 default로 Server 컴포넌트이다.

  • Folder: 폴더는 route를 정의하기 위해 사용된다. 즉 route는 root부터 leaf까지의 중첩된 폴더들(page.js 파일이 포함되어 있음)로 이루어진 path를 의미한다.
  • 결국 폴더를 중첩하면 하나의 route를 생성할 수 있다.

File convention

여기서 UI를 구성하기 위한 주요 file들이 있다.

  • page.js: route에 대한 UI를 생성하고 접근가능하게 한다.
    • route.js: route에 대해 server-side API 엔드포인트를 생성한다.
  • layout.js: segment와 children들에 대해 공통 UI를 생성한다. layout이 page나 child를 감싸는 형태
    • template.js: layout.js랑 비슷한데 새로운 component instance가 이동시 mount 된다고 한다.(언제 필요한거지?) 이게 필요없으면 layout쓰면 됨
  • loading.js: segment와 children들에 대해 로딩 UI를 생성한다. 요건 Suspense랑 세트다. Suspense Boundary내에서 page나 child를 감싸고 로드 중에 로딩 UI를 보여준다.
  • error.js: 로딩이랑 똑같다. error UI를 생성한다. 이건 Error Boundary랑 세트다. 에러 발생시 이걸 보여준다.
    • global-error.js: 이건 error랑 똑같은데 root의 layout.js에 대한 에러를 catch하기 위해서 사용한다.
  • not-found.js: notFound 함수가 route segment에서 throw됐을때나 URL에 일치하는 route가 존재하지 않을때 표시한다.

Component Hierarchy

위 파일들이 이런 구조로 되어 있는것이다.

중첩구조면 이런 구조가 중첩되어 있는것으로 보면된다.

Server-Centric Routing

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도 하지 않는다. 아주 아름답다 재사용성 측면에서 굉장히 효율적인것 같다.

Defining Routes

app 디렉토리 내의 폴더는 route를 정의하기 위해 사용된다. 각 폴더가 결국 route의 한 부분을 나타낸다. 중첩 route를 만들려면 폴더도 중첩으로 만들면 된다. 굉장히 쉽다. 직관적이다. 근데 route가 너무 많으면 폴더가 점점 deep해져서 블랙홀처럼 되는 문제가 생길 수도 있을것 같다.

route를 나타내는 폴더에서 page.js파일은 라우트 segment를 접근가능하게 만든다.

이 사진을 보면 analytics에는 page.js 파일이 없기 때문에 접근이 불가능하다는 의미다. 이런 폴더들은 단순히 컴포넌트, 스타일시트, 이미지나 테스트 등 다른 파일들을 저장하는데 사용할수도 있다.

Pages and Layouts

page는 route에 대한 unique한 UI이다. page.js파일에서 컴포넌트를 export해서 정의할 수 있다.

  • page는 항상 route sub tree의 리프노드이다.
  • page는 default로 Server 컴포넌트이나 client 컴포넌트로 만들수도 있다.
  • page는 데이터 fetch가 가능하다.

그렇다면 layout은 여러 페이지 사이에서 공유하는 UI이다. route 이동시 layout은 상태를 보존하고, interactive한 상태로 남아있는다. 또한 리렌더도 하지 않는다.

레이아웃을 만들려면 layout.js파일을 만들면 된다. layout.js는 child layout이나 page를 감싸기 위해 children props를 설정해주어야 한다.

  • 가장 상단의 레이아웃을 root layout이라 한다. 이 layout은 어플리케이션 전체에서 공유된다. root layout은 html과 body 태그를 가져야 한다.
  • 어떤 route segment든 layout을 정의할 수 있다.
  • route내의 layout은 default로 중첩된다. 각 부모 레이아웃은 자식 레이아웃을 감싸는 형태로 되어있다.
  • layout도 data fetching이 가능하다.
  • 부모와 자식 layout간 데이터 전달은 불가능하다. 하지만 같은 데이터를 route에서 한번 더 요청하면 리액트가 자동적으로 성능에 영향이 가지않게 중복 요청을 제거해준다고 한다.
  • 레이아웃은 현재 route segment에 접근할 수 없다. 접근할려면 useSelectedLayoutSegementuseSelectedLayoutSegements 훅을 client component에서 사용할 수 있다.

Root Layout

Root layout은 app 디렉토리 가장 상단에 정의되어 있고 모든 route에 적용된다. 이 레이아웃을 통해 서버에서 반환되는 초기 HTML의 형태를 수정할 수 있다.

export default function RootLayout({
  children,
}: {
  children: React.ReactNode; // 요게 페이지나 레이아웃임
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
  • app directory는 root layout을 무조건 선언해야 한다.
  • root layout에서 html과 body태그를 정의해야 한다. next.js가 자동으로 생성해주지 않는다.
  • 여기서 head태그를 관리하기 위한 내장 SEO 지원을 사용할 수 있다고 한다.
  • route group으로 다수의 root layout도 생성할 수 있다고 한다.
  • root layout은 Server 컴포넌트이고 Client 컴포넌트로 설정할 수 없다. 서버에서 pre-render되는 초기 html문서의 root이기 때문인거같다.

중첩 Layouts

Layout도 페이지처럼 폴더구조 내에서 중첩으로 선언할 수 있다. layout은 계층관계를 통해 부모 자식관계로써 부모가 자식을 감싸는 형태로 만들어진다.

Templates

Templates는 레이아웃과 유사하다. layout과 다른점은 layout은 route이동 간에 상태를 보존하나 template은 이동 시에 자식에 대해 새로운 인스턴스를 생성한다. 즉, route간 이동 시 새로운 컴포넌트 인스턴스가 mount 된다.(DOM element가 새로 생성되고, 상태가 보존되지 않으며, 효과들이 다시 동기화 된다.

템플릿은 다음의 경우에 필요할 수 있다고 한다.

  • CSS나 animation library로 enter/exit 애니메이션을 구현할 때
  • useEffect(page views를 로깅)나 useState(페이지별 피드백 폼)에 의존하는 기능
  • default framework 동작을 변경해야할 때 예를 들어 Suspense내 레이아웃은 Layout이 로드되는 처음에만 fallback UI롤 보여주는데 페이지 이동 시에도 보여주고 싶을 때 사용할 수 있다.
  • template을 쓸 특별한 이유가 없으면 layout을 쓰면 된다.
<Layout>
  {/* 템플릿엔 unique key를 전달해야 하는것 같다. */}
  <Template key={routeParam}>{children}</Template>
</Layout>

Modifying <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 엘리먼트에 대한 중복제거가 자동으로 된다고 한다.

Linking and Navigating

Next.js 라우터는 client-side 이동과 함께 server-centric routing을 사용한다. 이렇게 하면 즉시 로딩 상태와 동시 렌더링을 지원한다. 뭔 뜻이냐면 이동할때 클라이언트 상태를 유지하고 비싼 리렌더링을 방지하며 방해가 되지 않고 race condition을 일으키지 않는다는 뜻이다.

그래서 routing하는 방법은 두가지가 있다.

  • <Link> 컴포넌트
  • useRouter 훅

Link랑 useRouter는 이전 pages directory와 내용이 같아 정리하지 않았다.

Route Groups

app 폴더의 계층은 URL 경로로 직접 매핑된다. 하지만 route group을 생성하면 이러한 패턴을 벗어날 수 있다. 라우트 그룹은 다음의 경우에 사용된다.

  • URL 구조에 영향을 주지않고 라우트를 조직화할 때
  • 특정 route segment를 레이아웃에 삽입할 때
  • 어플리케이션을 분할해서 다수의 root layout을 생성할 때

route group을 사용할려면 폴더명을 괄호 () 로 묶는다. ex) (folderName)

URL 구조에 영향을 주지않고 라우트를 조직화할 때

라우트 그룹을 통해 관련된 라우트들을 함께 유지할 수 있다. 괄호 안 폴더는 URL에서 제외된다.

(marketing)과 (shop)이 같은 URL 계층을 공유하고 있지만 route group 폴더에 layout.js 파일을 생성해 각기 다른 layout을 적용할 수 있다.

특정 route segment를 레이아웃에 삽입할 때

특정한 라우트를 레이아웃으로 선택하려면 새 route group을 생성하고 같은 레이아웃을 그룹에서 공유하게 한다. group밖의 route는 레이아웃을 공유하지 않을 것이다.

어플리케이션을 분할해서 다수의 root layout을 생성할 때

root layout을 여러개 생성하려면 루트의 layout.js 파일을 삭제하고 각각의 route group 폴더에 layout.js 파일을 생성한다. 이렇게 하면 어플리케이션을 완전히 다른 UI와 경험을 가지는 영역으로 쪼갤 수 있다. 각 root layout은 html, body 태그를 정의해주어야 한다.

이 때 multiple root layout 간 이동은 full page reload가 적용된다.

Dynamic Routes

미리 정확한 route segment명을 알 수 없고 동적인 데이터로 route를 생성하고 싶을 때 Dynamic segments를 사용할 수 있다. pages directory에서 사용했던 거랑 동일한것 같다.

Convention

Dynamic segment는 폴더명을 square 괄호로 감싸서 생성할 수 있다.

  • Ex) [folderName], [id], [slug]
  • dynamic segment는 params prop으로 layout, page, route와 generateMetadata 함수에 전달된다.

예를 들어 app/blog/[slug]/page.js 파일이 있으면 아래 코드의 params값에 slug 부분의 값을 동적으로 전달한다.

export default function Page({ params }) {
  return <div>My Post</div>;
}

Generating Static Params

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에서의 동일한 인수를 가진 요청이 한번만 실행되므로 빌드시간이 단축된다.

Catch-all segements

[...folderName] 형식으로 폴더명을 설정하면 여러개의 route segment에 대응할 수 있는 catch-all segement를 설정할 수 있다.

  • Ex) app/shop/[…slug]/page.js → shop/clothes or shop/clothes/tops 에도 매칭

Optional Catch-all segmenets

이건 catch-all이랑 비슷한데 옵셔널이라서 아무것도 전달하지 않은것도 커버가 된다. 문법은 [[...folderName]]

  • Ex) app/shop/[[…slug]]/page.js 가 /shop에도 매칭된다.

Loading UI와 Streaming

loading.js 파일은 React Suspense와 함께 로딩 UI를 생성할 수 있게 도와준다. 이를 통해 route segment의 콘텐츠가 로드 되는동안 서버로부터 즉각적인 로딩 상태를 보여줄 수 있게 된다. 렌더링이 완료되면 새 콘텐츠가 자동으로 swap-in 된다.

Instant Loading states

instant loading state는 탐색 시에 즉시 표시되는 fallback UI이다. 스켈레톤이나 스피너같은 로딩 UI를 pre-render해서 보여줄 수 있다. 이렇게 하면 사용자가 앱이 응답하고 있는 이해할 수 있게 도와주기 때문에 더 나은 사용자경험을 제공할 수 있다. loading UI를 추가할려면 폴더 내에 loading.js 를 추가하자.

loading.jslayout.js 파일에 감싸지게 되며 자동으로 page.js 파일과 자식들을 Suspense boundary로 감싼다.

Streaming with Suspense

loading.js 이외에도 UI 컴포넌트에 대한 Suspense boundary를 수동으로 만들 수도 있다. App router는 Node.js와 Edge 런타임에 대해 Suspense를 통한 Streaming을 지원한다.

Streaming이 뭐야?

SSR은 사용자가 페이지를 보고 상호작용할 수 있게 될 때까지 몇가지의 단계가 필요하다.

  1. 먼저 페이지에 대한 모든 데이터가 서버에서 요청된다.
  2. 서버는 HTML 문서를 렌더링한다.
  3. 페이지에 대한 HTML, CSS, JS 파일들이 클라이언트에 전송된다.
  4. HTML과 CSS로 생성된 아직 non-interactive한 UI가 보여진다.
  5. React가 Hydrate되고 UI가 interactive하게 동작한다.

이러한 단계는 순차적이고 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하게 만들어야 하는지 우선순위를 정할 수 있다.

Error handling

loading.js 는 로딩 UI를 담당했다면 error.js 는 error UI를 담당한다.

  • error.js는 route segment와 자식들을 React Error boundary로 감싼다.
  • 파일 시스템 계층을 이용해 특정 segment에 맞게 조정된 error UI를 생성한다.
  • app의 나머지 부분을 동작하게 하면서 영향을 받는 segment에 대한 error를 격리한다.
  • full page reload없이 에러로부터 회복할 수 있는 기능을 제공한다.

'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가 어떻게 작동하나?

  • error.js는 자동으로 page와 child segment를 감싸는 React Error boundary를 생성한다.
  • error.js는 에러시 보여지는 fallback UI가 된다.
  • error 발생시 error boundary 상위의 레이아웃들은 상태를 유지하고 interactive한 상태로유지된다. 또한 error component는 에러로부터 회복할 수 있는 기능을 제공할 수 있다.

주의할 점은 error.js는 Layout의 child이기 때문에 동일한 segment내의 layout의 오류는 catch할 수 없다. 이런 경우 상위 segment에서 error.js를 작성해 catch해야 한다.

만약 root layout이나 template의 오류를 잡기 위해선 global-error.js 파일을 작성해서 사용하면 된다. 이 때 global-error.js 는 가장 root 엘리먼트가 되기 때문에 html, body 태그를 꼭 포함해야 한다.

Parallel Routes

병렬 라우팅은 동일 레이아웃에서 하나 이상의 페이지를 동시 또는 조건부로 렌더링할 수 있게 해준다. 대시보드나 소셜 사이트의 피드같은 앱에서 매우 동적인 부분은 복잡한 라우팅 패턴을 구현하기 위해 병렬 라우팅을 사용할 수 있다고 한다.

이런식으로 동시에 두가지 페이지를 보여줄 수 있다. 또한 각각에 대해 독립적인 error와 loading 상태를 정의할 수 있다 이 때 각각 독립적으로 stream된다.

병렬 라우팅은 조건부로 slot을 특정한 상태(인증 상태같은)에 렌더링할 수 있게 해준다.

Convention

병렬 라우트 폴더는 @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}
    </>
  );
}

Unmatched routes

기본적으로 slot의 콘텐츠는 현재 URL에 일치하게 된다.

  • default.js default.js 파일을 정의하면 현재 URL에 기반해 slot의 활성화 상태를 회복할 수 없는 경우 fallback으로 보여줄 UI를 정의할 수 있다.

여기 이해가 잘 안된다.. 나중에 다시 봐야할것같다.

useSelectedLayoutSegements

useSelectedLayoutSegementuseSelectedLayoutSegements 훅은 해당 슬롯 내 활성화된 라우트 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');
  // ...
}

Example

병렬 라우팅은 모달을 렌더링하기 위해 사용할 수 있다.

@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 routes

intercepting route는 현재 페이지의 문맥을 유지하면서 현재 레이아웃 내에 route를 로드할 수 있게 해준다. 특정 경로를 차단해 다른 경로를 표시하려는 경우 유용하다.

예를 들어 피드내 사진을 클릭했을 때 피드의 사진과 함께 모달이 표시 피드위에 표시되어야 한다면 Next.js는 /feed route를 가로채서 URL을 mask하여 /photo/123을 대신 표시한다.

만약 이 기법을 사용하지않으면 모달 대신 전체 photo 페이지가 렌더링 될 것이다.

Convention

이것도 컨벤션이 있다. 컨벤션이 너무 많은게 아닌가 싶기도 하다?.. 아무튼 (..) 이렇게 작성할 수 있다. 상대경로와 비슷하다.

  • (.) 같은 레벨의 segment에 매치된다
  • (..) 한 레벨 위의 segement에 매치된다
  • (..)(..) 두 레벨 위다.
  • (...) root의 segement랑 매치된다.

흠... 조금 지저분한거같다. 문법이 마음에 안든다.

예를 들어 photo segement를 feed segment 내에 intercept하기 위해서 (..)photo 디렉토리를 생성한다.

Modals

intercepting routes는 parallel route랑 함께 모달을 만들기 위해 사용할 수 있다. 이렇게 하면 다음의 challenge들을 극복할 수 있다고 한다.

  • 모달 content를 URL을 통해 공유가능하게 한다.
  • modal을 닫지 않고 page가 새로고침되었을 때 문맥을 유지할 수 있다.
  • route이동이 아닌 뒤로 가기를 통해 모달 닫기가 가능하다.
  • 앞으로 가기를 통해 모달을 다시 켤 수 있다.

주로 모달에서 쓰이는듯하다. 직접 구현해보아야 감이 잡힐것 같다.

Route handlers

Route handler는 Web Request와 Response API를 사용해서 특정 route에 대해 custom 요청 핸들러를 작성할 수 있게 해준다.

pages dir의 api routes와 동등한것이라 한다. route handler는 app dir내에서만 사용할 수 있다.

Convention

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 와 함께 위치할 수 없다.

Supported HTTP Methods

GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS 의 HTTP 메소드를 지원한다. 만약 지원되지않는 HTTP 메소드를 호출하면 Next.js가 405 Method Not Allowed 응답을 한다.

Static Route Handlers

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 });
}

Dynamic Route handlers

다음의 경우에 route handler는 동적으로 평가된다고 한다.

  • Request객체랑 GET 메소드를 사용할 때
  • GET이외에 다른 메소드를 사용할 때
  • cookiesheaders 같은 Dynamic 함수를 사용할 때
  • Segement config options로 수동으로 dynamic mode 지정할 때

Middleware

미들웨어는 이전과 다른게 없어서 pages dir 정리한 내용에서 다시 보면 되겠다.

https://velog.io/@asdf99245/Next.js-pages-router-%EC%A0%95%EB%A6%AC

Rendering

Rendering Environments

어플리케이션 코드가 렌더되는 환경은 클라이언트와 서버로 두 가지다.

  • 클라이언트는 어플리케이션 코드에 대한 요청을 서버로 보내는 사용자 장치의 브라우저를 말한다. 그런 다음 서버의 응답을 사용자가 상호작용할 수 있는 UI로 변환한다.
  • 서버는 어플리케이션 코드를 저장하고 클라이언트로부터 요청을 받고 계산을 수행한 후 적절한 응답을 전송하는 데이터 센터의 컴퓨터를 말한다.

Static and Dynamic Rendering on the server

client side와 server side에서 React component를 렌더링하는것 외에도 Next.js는 서버에서 Static과 Dynamic 렌더링을 통해 렌더링을 최적화할 수 있는 옵션을 제공한다.

  • Static Rendering Static Rendering을 통해 Server와 Client component는 서버에서 빌드타임에 pre-render 된다. 동작의 결과는 캐시되고 추후 요청에 재사용되어 성능상 효율적이다. 캐시는 무효화할 수 있다. pages dir의 SSG와 ISR과 같다고 한다.
    • Client 컴포넌트는 서버에서 HTML과 JSON을 prerender하고 캐시한다. 그 후 캐시된 결과가 hydrate를 위해 클라이언트로 전송된다.
    • Server 컴포넌트는 React에 의해 서버에서 렌더링 되고 payload는 HTML을 생성하기 위해 사용된다. 동일한 렌더링된 payload는 클라이언트의 컴포넌트를 hydrate하는데에도 사용되므로 클라이언트에 Javascript가 필요하지 않다.
  • Dynamic Rendering dynamic rendering을 사용하면 Server와 Client 컴포넌트 두가지 모두 요청 시간에 서버에서 렌더링된다. 작업의 결과는 캐시되지 않는다. pages dir의 SSR과 같다고 한다.

Edge and Node.js Runtime

서버에는 페이지를 렌더링할 수 있는 두 가지 런타임이 존재한다.

  • Node.js Runtime 모든 node.js API와 생태계에 호환가능한 패키지들을 사용가능하다.
  • Edge Runtime Web API에 기반해있다.

두가지 모두 streaming을 지원한다.

Static and Dynamic Rendering

Next.js에서 route는 static or dynamic하게 렌더링 될 수 있다고 했다.

Static Rendering(default)

static rendering은 모든 렌더링 작업을 미리 수행하고 지리적으로 사용자와 가까운 CDN에 제공되기 때문에 성능적으로 매우 좋다.

layout이나 page에서 동적 기능이나 동적 데이터 fetching을 통해 동적 렌더링을 사용할 수 있다. 이렇게 하면 Next.js는 요청 시간에 전체 route를 동적으로 렌더링한다.

Static Data Fetching(default)

Next.js는 default로 캐시 동작을 특별히 해제하지 않는 fetch()요청의 결과를 캐시한다. 즉, 캐시 옵션을 설정하지 않은 fetch 요청은 force-cache옵션을 사용한다.

만약 fetching 요청중에 revalidate 옵션을 사용한다면 route는 revalidation중에 정적으로 리렌더링 될 것이다.

Dynamic Rendering

정적렌더링중 dynamic function이나 dynamic fetch() 요청이 발견되면 Next.js는 전체 라우트를 요청시간에 렌더링하는 dynamically 렌더링으로 전환한다. 모든 캐시된 데이터 요청은 dynamic rendering중에 재사용 가능하다.

dynamic function은 요청시간에만 알 수 있는 쿠키나 요청 헤더, URL의 검색 파라미터등의 정보에 기반한다.

  • cookies(), headers(), useSearchParams() 등을 썼을 때

dynamic data fetch는 fetch() 요청에서 caching 옵션을 no-store 로 설정하거나 revalidate를 0으로 설정했을 때이다.

segement config 옵션으로도 캐시옵션을 설정할 수 있다고 한다.

Data fetching

App router에서 data fetching 시스템이 단순하게 바꼈다. getServerSidePropsgetStaticProps 를 사용하지 않고 fetch() 로 모두 통합하였다.

fetch() API

이름을 보면 익숙하다. 새로운 fetching system은 native fetch() Web API 를 기반으로 만들어졌다. 이를 통해 Server component에서 async await문법을 사용할 수 있다.

  • React는 자동요청 중복 제거를 위해 fetch를 확장한다.
  • Next.js는 각 요청이 자체 캐싱과 재검증 규칙을 설정할 수 있게 fetch 옵션 객체를 확장한다.

Fetching Data on the Server

가능하면 Server component에서 데이터 fetching을 하라고 한다. 이는 다음의 이점이 있다.

  • backend data 자원에 직접적으로 접근할 수 있다.
  • 민감한 정보가 클라이언트에서 노출되는것을 방지해 어플리케이션을 더욱 안전하게 유지할 수 있다.
  • data fetch와 render를 동일한 환경에서 한다. 이는 클라이언트와 서버간 통신을 줄이며 클라이언트의 메인 스레드 작업도 줄어든다.
  • 클라이언트에서 여러 개별 요청을 하는 대신 한번의 왕복으로 여러개의 data fetching을 수행한다.
  • client-server waterfall을 줄인다.
  • 지역에 따라 data fetching이 데이터 자원 가까이에서 수행돼 지연시간을 줄이고 성능을 향상시킨다.

여전히 client component에서도 가능하며 SWR이나 React Query를 사용하기를 권장한다고 한다.

Fetching Data at the component level

App router에선 layout, page, component 모두에서 data를 fetch할 수 있다. Streaming과 Suspense와 호환 또한 가능하다.

Layout은 부모 레이아웃과 자식 컴포넌트 사이에서 데이터 전달이 불가능하다고 한다. 그냥 필요한 layout에서 data fetch하라고 하는데 어짜피 next.js가 중복된 데이터 요청이어도 알아서 캐시하고 중복요청 제거를 해준다고 한다. Wow

Parallel and Sequential Data Fetching

data fetching 패턴에 두 가지가 있다.

  • Parallel
  • Sequential

  • parallel data fetching은 route의 요청이 시작되고 동시에 데이터를 로드한다. 이는 client-server waterfall과 로드에 소요되는 총 시간을 줄인다.
  • sequential data fetching은 route의 요청이 각각 따로 수행되고 waterfall을 만든다. 만약 fetch가 서로 의존관계를 가지고 있거나 자원을 절약하기 위해 다음 fetch전에 조건이 충족되기를 원하는 경우에는 이러한 패턴이 필요할 수 있다. 하지만 이런 동작이 의도하지 않은 것일 수도 있으며 로드시간이 길어질 수 있다.

Automatic fetch() Request Deduping

만약 트리 내 다수의 컴포넌트에서 동일한 데이터를 fetch해야 된다면 Next.js가 동일한 input을 가지는 fetch요청(GET만 인듯?)을 자동으로 캐시한다. 이는 렌더링 시 동일한 데이터가 fetch되는 상황을 방지한다.

  • 서버에서 캐시는 렌더링 프로세스가 완료되기까지 서버 요청의 수명을 유지한다.
    • 이 최적화는 레이아웃, 페이지, 서버 컴포넌트, generateMetadatagenerateStaticParams 에서 수행되는 fetch 요청에 적용된다.
    • static generation중에도 적용된다고 한다.
  • 클라이언트에서 캐시는 full page reload 전 세션 기간(여러 클라이언트 측 리렌더링을 포함할 수 있다)을 유지한다.

Static and Dynamic Data Fetching

데이터는 두가지 종류가 존재한다.

  • Static data: 자주 변화하지 않는 정적인 데이터이다.
  • Dynamic data: 자주 변화하고 유저에 특정할 수 있다.

기본적으로 next.js는 static fetch를 한다. 즉, data는 빌드타임에 fetch되어 각 요청마다 재사용된다. pages dir의 SSG같은거다. 개발자는 static data가 어떻게 캐시되고 revalidate될지 제어할 수 있다.

static data의 이점은 다음과 같다.

  • 요청의 수를 최소화해 데이터베이스 로드를 줄인다.
  • 데이터가 자동으로 캐시되어 로딩 성능을 향상시킨다.

하지만 데이터가 개인화되어야하고 최신 데이터를 항상 fetch하고 싶다면 dynamic fetch를 하면 된다. pages dir의 SSR같은거다.

Caching data

Next.js cache는 글로벌로 배포할 수 있는 영구적인 HTTP cache이다. 즉 플랫폼에 따라 캐시를 자동으로 확장하고 여러 지역에서 공유할 수 있다.

Next.js는 fetch() 함수의 옵션 객체를 확장하여 서버의 각 요청이 고유한 영구 캐싱 동작을 설정할 수 있도록 한다. 컴포넌트 수준의 data fetching을 사용하면 데이터가 사용되는 어플리케이션 코드 내에서 직접 캐싱을 구성할 수 있다.

서버렌더링 중 next.js가 fetch를 발견하면 캐시를 먼저 확인해 데이터를 사용할 수 있는지 확인한다. 이 경우 캐시된 데이터가 반환되고 그렇지 않다면 나중에 요청할 데이터를 가져와 캐시에 저장한다.

Revalidating data

Revalidation은 캐시를 삭제하고 최신 데이터를 가져오는 프로세스이다. 이는 데이터가 변경되어 전체 응용 프로그램을 재구성하지 않고 어플리케이션에 최신 버전이 표시되게 하려는 경우 사용할 수 있다.

두가지 revalidate 방법을 제공한다.

  1. background: 특정 time interval마다 데이터를 revalidate한다.
  2. on-demand: 필요할 때 revalidate한다.

Streaming and Suspense

Streaming과 Suspense가 있기 때문에 사용자는 전체 페이지가 로드되는 것을 기다리지 않고도 상호작용을 할 수 있게 된다. 데이터 fetching을 하는 일부분은 loading UI를 보여주게 된다.

Data Fetching

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>;
}
  • asnyc Server 컴포넌트는 Promise 타입을 반환하기 때문에 JSX에 타당한 엘리먼트가 아니라고 타입오류가 발생하는데 {/* @ts-expect-error Async Server Component */} 일단 요거를 위에다가 선언해서 오류를 없애라고 한다.

use in Client 컴포넌트

use 는 await와 개념적으로 유사한 promise를 받아들이는 새로운 리액트 함수다. use 는 컴포넌트, 훅, Suspense와 호환가능한 방법으로 함수에서 반환되는 promise를 처리한다.

fetchuse 에 감싸는 방법은 현재는 client 컴포넌트에서 권장되지 않고 다수의 리렌더링을 발생시킬 수 있다. 따라서 client 컴포넌트에서 당장은 third-party 라이브러리인 SWR이나 React-query(Tanstack-query)사용을 권장한다고 한다.

Static Data Fetching

fetch는 기본적으로 자동으로 데이터를 무제한으로 fetch하고 캐시한다. force-cache 가 default

Revalidating data

특정 시간 내에 캐시된 데이터를 revalidate하려면 next.revalidate 옵션을 사용할 수 있다.

// cache의 lifetime을 설정한다 (초 단위)
fetch('https://...', { next: { revalidate: 10 } });

Dynamic Data Fetching

데이터를 매 요청마다 최신의 것으로 가져오고싶다면 cache: 'no-store' 옵션을 사용할 수 있다.

// 캐시를 사용하지 않는다.
fetch('https://...', { cache: 'no-store' })

Data Fetching Patterns

Parallel data fetching

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>
  );
} 

Sequential Data Fetching

데이터를 순차적으로 가져오기 위해서 필요한 컴포넌트에서 직접적으로 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>
    </>
  );
}

차이점은 초기에 병렬로 초기화하지 않는 부분인것 같다.

Blocking Rendering in a route

layout에서 데이터를 요청하면 모든 아래에 있는 route segment에 대한 렌더링은 데이터 로드가 완료된 후에만 시작할 수 있다.

페이지 디렉토리에서 서버 렌더링을 사용하는 페이지는 getServerSideProps 가 완료될때까지 브라우저 로딩 스피너를 표시한 다음 해당 페이지에 대한 컴포넌트를 표시한다. 이는 “전체 or 없음” 데이터 fetch로 설명되는데 전체 데이터가 있거나 없거나이다.

app directory에서는 몇가지 옵션이 있다.

  1. loading.js 를 사용하여 data fetching 의 결과로 streaming하는 동안 서버에서 즉시 로드상태를 표시할 수 있다.

  2. data fetching을 컴포넌트 트리의 하단으로 위치시켜 필요한 페이지의 부분에서만 렌더링이 블록되게 한다. 예를 들어 루트 레이아웃보다 특정한 컴포넌트에 data fetch를 위치시킨다.

    이렇게하면 전체 페이지가 blocking 되지 않고 특정 페이지의 부분만 로딩 상태를 보여주게 된다.

Data Fetching without fetch()

ORM이나 데이터베이스 클라이언트 등 third-party 라이브러리를 사용해서 fetch를 사용할 수 없을 수도 있다.

이런 경우 여전히 캐싱과 revalidating 동작을 제어하고싶다면 segment의 default caching behavior이나 segement cache configuration을 사용할 수 있다.

Default Caching Behavior

fetch 를 사용하지 않는 data fetching 라이브러리는 route의 캐싱에 영향을 주지않을 것이고 route segement에 의존해 정적 or 동적이 될 것이다.

만약 segment가 정적(기본값)이면 요청의 결과가 세그먼트의 나머지 부분과 함께 캐시되고 revalidate될 것이다. 만약 동적이면 요청의 결과가 캐시되지 않고 렌더링될때마다 다시 가져온다.

  • Dynamic function과 같은 cookies(), headers() 등이 route segement를 동적으로 만든다.

Segement Cache Configuration

일시적인 해결법으로 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();
  // ...
}

Caching

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

다음의 경우 캐시되지 않는다.

  • Dynamic methods(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();
  // ...
}

React 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() 를 사용할 필요는 없다.
  • 데이터를 컴포넌트 사이에 props로 전달하는것보다 동일한 요청이라도 필요한 컴포넌트에서 data fetch를 하기를 권장한다고 한다.
  • server-only 패키지로 client에서 사용되지 않을 data fetch는 서버에서 하도록 보장하라고 한다.

Preload pattern with 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 캐싱

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
  • 만약 page, layout, fetch 요청이 모두 revalidate값을 특정하면 가장 낮은 값이 사용된다.
  • fetchCache‘only-cache’'force-cache' 로 설정하면 모든 fetch 요청이 캐시로 선택되지만 개별 fetch 요청에 의해 revalidation 빈도가 낮아질 수 있다.

Revalidating

next.js는 Revalidation(ISR)을 통해 정적 route를 전체 페이지를 재생성하지 않고도 갱신할 수 있게 해준다.

Background Revalidation

캐시된 데이터를 특정 시간마다 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

On-Demand Revalidation

만약 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() });
}
profile
FE 개발자를 꿈꾸고 있습니다 :)

2개의 댓글

comment-user-thumbnail
2024년 2월 19일

깔끔한 정리 정말 감사합니다!

답글 달기
comment-user-thumbnail
2024년 4월 16일

멋지네여 ;;

답글 달기