11장. Next.js 13과 리액트 18
app 디렉터리의 등장
- 13 버전 이전까지는 페이지 공통으로 무언가를 집어 넣을 수 있는 곳은
_document
와_app
이 유일하다._document
: 페이지에서 쓰이는 태그를 수정하거나, 서버 사이드 렌더링 시 styled-components 같은 일부 CSS-in-JS를 지원하기 위한 코드를 삽입하는 제한적인 용도로 사용된다._app
: 페이지를 초기하기 위한 용도로 사용된다.
- Next.js 12 버전까지는 무언가 페이지 공통 레이아웃을 유지할 수 있는 방법은
_app
이 유일했다. - 그러나 이 방식은
_app
에서밖에 할 수 없어 제한적이고, 각 페이지별로 서로 다른 레이아웃을 유지할 수 있는 여지도 부족하다. - 이러한 레이아웃의 한계를 극복하기 위해 나온 것이 Next.js의 app 레이아웃이다.
- 라우팅
- 기존에 page로 정의하던 라우팅 방식이 app 디렉터리로 이동했다.
- 파일명으로 라우팅하는 것이 불가능해졌다. 즉, 폴더명까지만 주소로 변환된다.
*layout.js
- layout은 주소별 공통 UI를 포함할 수 있을 뿐만 아니라
_document
와_app
을 대신해 웹페이지를 시작하는 데 필요한 공통 코드를 삽입할 수도 있다. - 또한 layout.js를 통해
_document.jsx
에서만 처리할 수 있었던 부자연스러움이 사라졌다. - layout에서 주의해야 할 점은
- layout은 app 디렉터리 내부에서는 예약어다.
- layout은 children을 props로 받아서 렌더링해야 한다. 레이아웃이므로 당연히 그려야 할 컴포넌트를 외부에서 주입받고 그려야 한다.
- layout 내부에는 반드시 export default로 내보내는 컴포넌트가 있어야 한다.
- layout 내부에서도 API 요청과 같은 비동기 작업을 수행할 수 있다.
*page.js
- page가 받는 props는 다음과 같다.
- params : 옵셔널 값으로, 동적 라우트 파라미터를 사용할 경우 해당 파라미터에 값이 들어온다.
- searchParams : URL에서
?a=1
과 같은 URLSearchParams를 의미한다. 이 값은 layout에서는 제공되지 않는다. layout은 페이지 탐색 중에는 리렌더링을 수행하지 않기 때문이다.
- page는 다음과 같은 규칙을 가지고 있다.
- page도 역시 app 디렉터리 내부의 예약어다.
- page도 역시 내부에는 반드시 export default로 내보내는 컴포넌트가 있어야 한다.
*error.js
- 해당 라우팅 영역에서 사용되는 공통 에러 컴포넌트다.
- 에러 정보를 담고 있는
error: Error
객체와 에러 바운더리를 초기화할reset: () ⇒ void를 props
로 받는다. - 한 가지 명심해야 할 점은, 에러 바운더리는 클라이언트에서만 작동하므로 error 컴포넌트도 클라이언트 컴포넌트여야 한다는 점이다.
- 그리고 이 error 컴포넌트는 같은 수준의 layout에서 에러가 발생할 경우 해당 error 컴포넌트로 이동하지 않는다는 점도 명심해야 한다.
- Layout에서 발생한 에러를 처리하고 싶다면 상위 컴포넌트의 error를 사용하거나, app의 루트 에러 처리를 담당하는 app.global-error.js 페이지를 생성하면 된다.
*not-found.js
- 특정 라우팅 하위의 주소를 찾을 수 없는 404 페이지를 렌더링할 때 사용된다.
- 전체 애플리케이션에서 404를 노출하고 싶다면 app/not-found.js를 생성해 사용하면된다.
- 이 컴포넌트는 서버 컴포넌트로 구성하면 된다.
*loading.js
- 리액트 Suspense를 기반으로 해당 컴포넌트가 불러오는 중임을 나타낼 때 사용할 수 있다.
- ‘use client’ 지시자를 사용해 클라이언트에서 렌더링되기 할 수도 있다.
*route.js
- 이전까지 지원하지 못했던 /pages/api에 대한 /app 디렉터리 내부의 지원도 추가됐다.
- 디렉터리가 라우팅 주소를 담당하며 파일명은 route.js로 통일됐다.
- 파일 내부에 REST API의 get, post와 같은 메서드명을 예약어로 선언해두면 HTTP 요청에 맞게 해당 메서드를 호출하는 방식으로 작동한다.
- 한 가지 흥미로운 점은 app/api 외에 다른 곳에서 선언해도 작동한다는 것이다.
- route.ts가 존재하는 폴더 내부에는 page.tsx가 존재할 수 없다. 만약 두 파일이 존재한다면 경고 메시지가 뜬다.
- route의 함수들이 받을 수 있는 파라미터는 다음과 같다.
- request : NextRequest 객체이며, fetch의 Request를 확장한 Next.js만의 Request라고 보면 된다. API 요청과 관련된 cookie, headers 등 뿐만 아니라 nextUrl 같은 주소 객체도 확인할 수 있다.
- context : params만을 가지고 있는 객체이며, 동적 라우팅 파라미터 객체가 포함돼 있다. 이 객체는 Next.js에서 별도 인터페이스를 제공하지 않으므로 주소의 필요에 따라 원하는 형식으로 선언하면 된다.
리액트 서버 컴포넌트
리액트 서버 컴포넌트는 서버 사이드 렌더링과 완전히 다른 개념이다.
기존 리액트 컴포넌트와 서버 사이드 렌더링의 한계
- 리액트의 모든 컴포넌트는 클라이언트에서 작동하며, 브라우저에서 자바스크립트 코드 처리가 이뤄진다.
- 서버 사이드 렌더링은, 미리 서버에서 DOM을 만들어 오고, 클라이언트에서는 이렇게 만들어진 DOM을 기준으로 하이드레이션을 진행한다. 이후 브라우저에서는 상태를 추적하고, 이벤트 핸들러를 DOM에 추가하고, 응답에 따라 렌더링 트리를 변경하기도 한다.
- 서버 사이드 렌더링 구조는 다음과 같은 한계점이 있다.
-
자바스크립트 번들 크기가 0인 컴포넌트를 만들 수 없다.
-
백엔드 리소스에 대한 직접적인 접근이 불가능하다.
-
자동 코드 분할(code split)이 불가능하다 : 코드 분할이란 하나의 거대한 코드 번들 대신, 코드를 여러 작은 단위로 나눠 필요할 때만 동적으로 지연 로딩함으로서 앱을 초기화하는 속도를 높여주는 기법을 말한다. 일반적으로 리액트는 lazy를 사용해 구현해왔다.
그러나 lazy는 몇 가지 단점이 있다.
- 일일이 lazy로 감싸는 것을 기억해야 하기 때문에, 누락하는 경우가 발생할 수 있다.
- 해당 컴포넌트가 호출되고 if문을 판단하기 전까지 어떤 지연 로딩한 컴포넌트를 불러올 지를 결정할 수 없다. 이는 지역 로딩으로 인한 성능 이점을 상쇄해버리는 결과를 만들고만다.
-
연쇄적으로 발생하는 클라이언트와 서버의 요청을 대응하기 어렵다 : 최초의 컴포넌트의 요청과 렌더링이 끝나기 전까지는 하위 컴포넌트의 요청과 렌더링이 끝나지 않는다. 그만큼 서버에 요청하는 횟수도 늘어나고, 불필요한 렌덜이까지 발생한다.
-
추상화에 드는 비용이 증가한다.
-
- 서버 사이드 렌더링의 한계점을 쭉 살펴본다면 모든 문제는 리액트가 클라이언트 중심으로 돌아가기 때문에 발생하는 문제라는 것을 알 수 있다.
- 서버 사이드 렌더링은 정적 콘텐츠를 빠르게 제공하고, 서버에 있는 데이터에 손쉽게 제공할 수 있는 반면 사용자의 인터렉션에 따른 다양한 사용자 경험을 제공하긴 어렵다.
- 클라이언트 사이드 렌더링은 사용자의 인터렉션에 따라 정말 다양한 것들을 제공할 수 있지만 서버에 비해서 느리고 데이터를 가져오는 것도 어렵다.
- 이러한 두 구조의 장점을 모두 취하고자 하는 것이 바로 리액트 서버 컴포넌트다.
서버 컴포넌트란?
- 하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법을 의미한다.
- 즉, 일부 컴포넌트는 클라이언트에서, 일부 컴포넌트는 서버에서 렌더링되는 것이다.
- 클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없다. 클라이언트는 서버 컴포넌트를 실행할 방법이 없기 때문에(서버 환경이 브라우저에는 존재하지 않으므로) 컴포넌트를 호출할 수 없다.
- 서버 컴포넌트와 클라이언트 컴포넌트가 있으며 동시에 두 군데에서 모두 사용할 수 있는 공용 컴포넌트가 있다.
- 서버 컴포넌트
- 요청이 오면 그 순간 서버에서 딱 한 번 실행될 뿐이므로 상태를 가질 수 없다. 따라서 리액트에서 상태를 가질 수 있는 useState, useReducer 등의 훅을 사용할 수 없다.
- 렌더링 생명주기도 사용할 수 없다. 한 번 렌더링되면 그걸로 끝이기 때문이다. 따라서 useEffect, useLayoutEffect를 사용할 수 없다.
- 앞의 두 가지 제약사항으로 인해 effect나 state에 의존하는 사용자 정의 훅 또한 사용할 수 없다. 다만 effect나 state에 의존하지 않고 서버에서 제공할 수 있는 기능만 시용하는 훅이라면 충분히 사용 가능하다.
- 브라우저에서 실행되지 않고 서버에서만 실행되기 때문에 DOM API를 쓰거나 window, document 등에 접근할 수 없다.
- 데이터베이스, 내부 서비스, 파일 시스템 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다. 컴포넌트 자체가 async한 것이 가능하다.
- 다른 서버 컴포넌트를 렌더링하거나 div, span, p 같은 요소를 렌더링하거나, 혹은 클라이언트 컴포넌트를 렌더링할 수 있다.
- 클라이언트 컴포넌트
- 브라우저 환경에서만 실행되므로 서버 컴포넌트를 불러오거나, 서버 전용 훅이나 유틸리티를 불러올 수 없다.
- 그러나 앞의 코드에서 본 것처럼 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하는데, 그 클라이언트 컴포넌트가 자식으로 서버 컴포넌트를 갖는 구조는 가능하다. 그 이유는 클라이언트 입장에서 봤을 때 서버 컴포넌트는 이미 서버에서 만들어진 트리를 가지고 있을 것이고, 클라이언트 컴포넌트는 이미 서버에서 만들어진 그 트리를 삽입해서 보여주기만 하기 때문이다. 따라서 서버 컴포넌트와 클라이언트 컴포넌트를 중첩해서 갖는 위와 같은 구조로 설계하는 것이 가능하다.
- 공용 컴포넌트(shared components)
- 이 컴포넌트는 서버와 클라이언트 모두에서 사용할 수 있다.
- 서버 컴포넌트
- 리액트는 모든 것을 다 공용 컴포넌트로 판단한다. 즉, 모든 컴포넌트를 다 서버에서 실행 가능한 것으로 분류한다.
- 클라이언트 컴포넌트라는 것을 명시적으로 선언하려면 파일의 맨 첫 줄에 ‘use client’라고 작성해두면 된다.
서버 사이드 렌더링과 서버 컴포넌트의 차이
- 서버 사이드 렌더링은 응답받은 페이지 전체를 HTML로 렌더링하는 과정을 서버에서 수행한 후 그 결과를 클라이언트에 내려준다.
- 그리고 이후 클라이언트에서 하이드레이션 과정을 거쳐 서버의 결과물을 확인하고 붙이는 등의 작업을 수행한다.
- 목적은 초기에 인터랙션은 불가능하지만 정적인 HTML을 빠르게 내려주는 데 초점을 두고 있다.
- 따라서 여전히 초기 HTML이 로딩된 이후에는 클라이언트에서 자바스크립트 코드를 다운로드하고, 파싱하고, 실행하는 데 비용이 든다.
- 둘은 대체제가 아닌 상호보완하는 개념으로 봐야 할 것이다.
서버 컴포넌트는 어떻게 작동하는가?
- 서버가 렌더링 요청을 받는다. 서버가 렌더링 과정을 수행해야 하므로 리액트 서버 컴포넌트를 사용하는 ㅗㅁ든 페이지는 항상 서버에서 시작된다. 즉, 루트에 있는 컴포넌트는 항상 컴포넌트다.
- 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화(serialize)한다. 이때 서버에서 렌더링할 수 있는 것은 직렬화해서 내보내고, 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워두고 나타낸다. 브라우저는 이후에 이 결과물을 받아서 다시 역질렬화한 다음 렌더링을 수행한다.
- 브라우저가 리액트 컴포넌트 트리를 구성한다. 브라우저가 서버로 스트리밍으로 JSON 결과물을 받았다면 이 구문을 다시 파싱한 결과물을 바탕으로 트리를 재구성해 컴포넌트를 만들어 나간다.
리액트 서버 컴포넌트의 작동 방식의 특별한 점은?
- 먼저 서버에서 클라이언트로 정보를 보낼 때 스트리밍 형태로 보냄으로써 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링할 수 있어 브라우저에서는 되도록 빨리 사용자에게 결과물을 보여줄 수 있다.
- 컴포넌트들이 하나의 번들러 작업에 포함돼 있지 않고 각 컴포넌트별로 번들링이 별개로 돼 있어 필요에 따라 컴포넌트를 지연해서 받거나 따로 받는 등의 작업이 가능해졌다.
- 서버 사이드 렌더링과는 다르게 결과물이 HTML이 아닌 JSON 형태로 보내진 것 또한 주목해 볼 만하다.
Next.js에서의 리액트 서버 컴포넌트
- 서버 컴포넌트는 클라이언트 컴포넌트를 불러올 수 없으며, 클라이언트 컴포넌트는 서버 컴포넌트를 children props로 받는 것만 가능하다.
- page.js와 layout.js는 반드시 서버 컴포넌트여야 하며, 서버 컴포넌트의 제약을 받는다.
- 새로운 fetch 도입과 getServerSideProps, getStaticProps, getInitialProps의 삭제
- 모든 데이터 요청은 웹에서 제공하는 표준 API인 fetch를 기반으로 이뤄진다.
- fetch API를 확장해 같은 서버 컴포넌트 트리 내에서 동일한 요청이 있다면 재요청이 발생하지 않도록 요청 중복을 방지했다.
- 해당 fetch 요청에 대한 내용을 서버에서는 렌더링이 한 번 끝날 때까지 캐싱하며, 클라이언트에서는 별도의 지시자나 요청이 없는 이상 해당 데이터를 최대한 캐싱해서 중복된 요청을 방지한다.
- 정적 렌더링과 동적 렌더링
- 과거 Next.js에는 getStaticProps를 활용해 서버에서 불러오는 데이터가 변경되지 않는 경우에 정적으로 페이지를 만들어 제공할 수 있는 기능이 있었다. 이 기능을 활용하면 해당 주소로 들어오는 경우 모든 결과물이 동일하기 때문에 CDN에서 캐싱해 기존 서버 사이드 렌더링보다 더 빠르게 데이터를 제공할 수 있다는 장점이 있었다.
- Next.js 13에서는 정적인 라우팅에 대해서는 기본적으로 빌드 타임에 렌더링을 미리 해두고 캐싱해 재사용할 수 있게끔 해뒀고, 동적인 라우팅에 대해서는 서버에 매번 요청이 올 때마다 컴포넌트를 레더링하도록 변경했다.
- 동적인 주소지만 특정 주소에 대해서 캐싱하고 싶은 경우, 즉 과거 Next.js에서 제공하는 getStaticProps를 흉내내고 싶다면, 새로운 함수인 generateStaticParams를 사용하면 된다.
- 캐시와 mutating, 그리고 revalidating
- 캐시를 전체적으로 무효화하고 싶다면 router에 추가된 refresh 메서드로
router.refresh();
를 사용하면 된다. - 이는 브라우저를 새로고침하는 등 브라우저의 히스토리에 영향을 미치지 않고, 오로지 서버에서 루트부터 데이터를 전체적으로 가져와서 갱신하게 된다.
- 캐시를 전체적으로 무효화하고 싶다면 router에 추가된 refresh 메서드로
- 스트리밍을 활용한 점진적인 페이지 불러오기
- 서버 사이드 렌더링은 요청받은 페이지를 모두 렌더링해서 내려줄 때까지는 사용자에게 아무것도 보여줄 수 없으며, 사용자는 빈 페이지만 보게 된다.
- 하이드레이션 과정을 거쳐야만 비로소 사용자가 사용할 수 있는 페이지가 된다.
- 문제는 이 모든 작업이 순차적으로 다 완료돼야만 페이지 하나를 온전하게 볼 수 있다는 것이다.
- 이를 해결하기 위해서 HTML을 작은 단위로 쪼개서 완성되는 대로 클라이언트로 점진적으로 보내는 스트리밍이 도입됐다.
- 스트리밍을 통해 모든 데이터가 로드될 때까지 기다리지 않더라도 먼저 데이터가 로드되는 컴포넌트를 빠르게 보여주는 방법이 가능하다.
- 이는 사용자가 일부라도 페이지와 인터랙션을 할 수 있다는 것을 의미하며, 나아가 핵심 웹 지표인 최초 바이트까지의 시간(TTFB: Time To First Byte)과 최초 콘텐츠풀 페인팅(FCP: First Contentful Paint)을 개선하는 데 큰 도움을 준다.
- 스트리밍을 활용할 수 있는 방법은
- 경로에 loading.tsx 배치
- Suspense 배치
웹팩의 대항마, 터보팩의 등장(beta)
- 웹팩 대비 최대 700배, Vite 대비 최대 10배 빠르다
서버 액션(alpha)
-
API를 굳이 생성하지 않더라도 함수 수준에서 서버에 직접 접근해 데이터 요청 등을 수행할 수 있는 기능
-
서버 컴포넌트와 다르게, 특정 함수 실행 그 자체만을 서버에서 수행할 수 있다는 장점이 있다.
-
실행 결과에 따라 다양한 작업을 수행할 수도 있다.
-
서버 액션을 활성화하려면 next.config.js에서 실험 기능을 활성화해야 한다.
-
서버 액션을 만들려면 먼저 함수 내부 또는 파일 상단에 클라이언트 선언과 비슷하게 ‘use server’ 지시자를 선언해야 한다. 그리고 함수는 반드시 aync여야 한다.
async function serverAction() { "use server"; // 서버에 바로 접근하는 코드 }
// 이 파일 내부의 모든 내용이 서버 액션으로 간주된다. 'user server' export async function myAction() { // ... // 서버에 바로 접근하는 코드 }
-
서버 액션이 수행할 수 있는 작업은
- form의 action
- input의 submit과 image의 formAction
- startTransition과의 연동
- server mutation이 없는 작업
-
전통적인 서버 기반 웹 애플리케이션(php)과 크게 다를 바 없어 보이지만, 주목해야 할 가장 큰 차이는 모든 과정이 페이지 새로고침이 없이 수행된다는 것이다.
-
한 가지 더 주목해야 할 것은 revalidatePath다. 이는 인수로 넘겨받은 경로의 캐시를 초기화해서 해당 URL에서 즉시 새로운 데이터를 불러오는 역할을 한다. Next.js에서는 이를 server mutation(서버에서의 데이터 수정)이라고 하는데, server mutation으로 실행할 수 있는 함수는 다음과 같다.
- redirect : 특정 주소로 리다이렉트 할 수 있다.
- revalidatePath : 해당 주소의 캐시를 즉시 업데이트한다.
- revalidateTag : 다양한 fetch 요청을 특정 태그 값으로 구분할 수 있으며, 이 특정 태그가 추가된 fetch 요청을 모두 초기화한다.
-
useTransition을 사용하면 얻을 수 있는 장점 중 하나는 이전과 동일한 로직을 구현하면서도 page 단위의 loading.jsx를 사용하지 않아도 된다는 것이다.
-
서버 액션은 클라이언트 컴포넌트 내에서 정의될 수 없다.
-
서버 액션을 Import하는 것뿐만 아니라, props 형태로 서버 액션을 클라이언트 컴포넌트에 넘기는 것 또한 가능하다.
Next.js에서 사용하는 fetch에서 주는 cache 옵션
- force-cache : 캐시가 존재한다면 해당 캐시값을 반환하고, 캐시가 존재하지 않으면 서버에서 데이터를 불러와 가져온다.(기본값)
- no-store : 캐시를 절대 사용하지 않고, 매 요청마다 새롭게 값을 불러온다.
fetch(https://…, { next: { revalidate: false | 0 | number } } });
: 캐시를 초 단위로 줄 수 있다.- 정적으로 미리 빌드해두는 것뿐만 아니라 캐시를 활용하는 것도 가능하다.
- 이러한 방식을 Next.js에서는 ‘Incremental Static Regeneration’이라고 하는데, 정적으로 생성된 페이지를 점진적으로 갱신하는 것을 의미한다.
로딩, 스트리밍, 서스펜스
- 스트리밍과 리액트의 서스펜스를 활용해 컴포넌트가 렌더링 중이라는 것을 나타낼 수 있다.
- loading과 Suspense 모두 동일한 방식으로 작동하며, Suspense가 조금 더 개발자가 원하는 형태로 쪼개서 보여줄 수 있다는 차이만 있다.