11.1 app 디렉터리의 등장
- 기존 Next.js에서는 Layout 구조를 구현하기가 힘들었다.
- 페이지 공통으로 집어넣을 수 있는 곳은 _app.tsx, _document.tsx인데, 목적이 서로 달랐다.
- _app.tsx : 페이지를 초기화하는 용도. 페이지 변경 시 유지하고 싶은 레이아웃, 상태 유지, componentDidCatch를 이용한 에러 핸들링, 페이지간 추가적인 데이터 삽입, global CSS 주입의 작업이 가능하다.
- _document.tsx
<html>
, <body>
태그 수정, SSR시 일부 CSS-in-JS를 지원하는 코드 삽입
- 서버에서만 작동 -> onClick등 이벤트 핸들러를 붙이거나 클라이언트 로직을 붙이는 것을 금지하고 있다.
- 공통 레이아웃 유지는 _app이 유일했으나, 이는 제한적이고 페이지별로 서로 다른 레이아웃을 유지하기 힘들다.
라우팅
라우팅을 정의하는 법
- 기본적으로 파일 시스템을 기반으로 하지만, 약간의 차이가 있다.
- Nextjs 12이하 : /pages/a/b.tsx 또는 /pages/a/b/index.tsx는 모두 동일한 주소로 변환된다.(파일명이 index이면 무시)
- Nextjs 13 : /app/a/b는 /a/b로 변환되며, 파일명은 무시된다. 폴더명까지만 주소로 변환된다.
- app 내부에서 가질 수 있는 파일명은 예약어로 제한된다.
layout.js
- 파일명이 될 수 있는 예약어 중 하나
- 해당 폴더에 layout이 있다면 그 하위 폴더 및 주소에 모두 영향을 미친다.
- 기존 _document에서 수행했던 CSS-in-JS의 초기화도 루트의 레이아웃에서 적용하는 방식으로 바뀌었다.
- 주소별 공통 UI를 포함하고, _app, _document를 대신해 웹페이지를 시작하는 데 필요한 공통코드를 삽입할 수 있다.
- 주의해야 할 점
- 레이아웃 이외의 다른 목적으로는 사용할 수 없다.
- children을 props로 받아서 렌더링해야 한다.
- export default로 내보내는 컴포넌트가 있어야 한다.
- API 요청과 같은 비동기 작업을 수행할 수 있다.
page.js
- 파일명이 될 수 있는 예약어 중 하나
- layout을 기반으로 컴포넌트를 노출한다.
- props
- params(optional) : [...id] 같은 동적 라우트 파라미터를 사용할 경우 값이 들어온다.
- searchParams
- URLSearchParams를 의미한다.
- layout에서는 제공되지 않는다. -> layout은 페이지 탐색 중에 리렌더링을 수행하지 않기 때문
- 규칙
- 내부에서 export default로 내보내야 한다.
error.js
- 해당 라우팅 영역에서 사용하는 공통 컴포넌트
- 특정 라우팅별로 서로 다른 에러 UI를 렌더링할 수 있다.
'use client'
import {useEffect} from 'react'
export default function Error({
error,
reset
}: {
error: Error
reset: () => void
}){
useEffect(() => {
console.log('logging error:', error)
},[error])
return (
<>
<div>
<strong>Error: </strong> {error?.message}
</div>
<div>
<button onClick={() => reset()}>에러 리셋</button>
</div>
</>
)
}
- 같은 수준의 layout에서 에러가 발생하면 해당 error 컴포넌트로 이동하지 않는다.
not-found.js
- 특정 라우팅 하위 주소를 찾을 수 없는 404 페이지를 렌더링할 때 사용된다.
- 서버 컴포넌트로 구성
loading.js
- 리액트 Suspense를 기반으로 해당 컴포넌트가 불러오는 중임을 나타낼 때 사용하낟.
- 'use client'를 사용해 클라이언트에서 렌더링되게 할 수 있다.
route.js
- REST API의 get, post와 같은 메서드명을 예약어로 선언해두면 HTTP 요청에 맞게 해당 메서드를 호출한다.
- route가 존재하는 폴더 내부에는 page.tsx가 존재할 수 없다.
import {NextRequest} from 'next/server'
export async function GET(request : NextRequest){}
export async function HEAD(request : NextRequest){}
export async function POST(request : NextRequest){}
...
- params
- request : Request를 확장한 Nextjs만의 Request. API 요청 관련 cookie, headers, nextUrl 등 확인 가능
- context : params만을 가지고 있는 객체. 동적 라우팅 파라미터 객체가 포함되어 있다.
리액트 서버 컴포넌트
기존 리액트 컴포넌트와 서버 사이드 렌더링의 한계
- 기존의 구조
- 미리 서버에서 DOM을 만듦 -> 이 DOM을 기준으로 클라이언트에서는 하이드레이션 진행 -> 브라우저에서는 상태 추적, 이벤트 핸들러 추가, 응답에 따라 렌더링 트리 변경
- 한계점
- JS 번들 크기가 0인 컴포넌트를 만들 수 없다.
- 백엔드 리소스에 대한 직접적인 접근이 불가능하다. : REST API 등의 방법을 사용하는 수 밖에 없다.
- 자동 코드 분할이 불가능하다.
- 코드 분할 : 하나의 거대한 코드 번들 대신, 코드를 여러 작은 단위로 나누어 필요할 때만 동적으로 지연 로딩해 앱을 초기화하는 속도를 높이는 기법
- 리액트에서는 lazy를 사용
- 연쇄적으로 발생하는 클라이언트와 서버의 요청을 대응하기 어렵다. : 최초 컴포넌트의 요청과 렌더링이 끝나기 전까지 하위 컴포넌트의 요청과 렌더링이 끝나지 않는다.
- 추상화에 드는 비용이 증가한다
- 즉 서버 사이드 렌더링의 한계점은 리액트가 클라이언트 중심으로 돌아가기 때문이다.
- SSR : 서버 데이터에 쉽게 접근 + CSR : 다양한 사용자 경험을 제공 -> 두 구조의 장점을 모두 취하고자 하여 서버 컴포넌트가 등장했다.
서버 컴포넌트란?
하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법
- 서버 컴포넌트와 클라이언트 컴포넌트가 혼재될 수 있다.
- 서버 컴포넌트
- 요청이 오면 서버에서 딱 한 번 실행된다 -> 상태를 가질 수 없다. -> useState, useReducer등의 훅을 사용할 수 없다.
- 한 번 렌더링되면 끝 -> 렌더링 생명주기를 사용할 수 없다.
- effect나 state에 의존하는 사용자 정의 훅을 사용할 수 없다.(서버에서 제공할 수 있는 기능만 사용하는 훅이면 가능)
- DOM API를 쓰거나 window.document 등에 접근할 수 없다.
- DB, 파일시스템 등 서버에만 있는 데이터를 async/await으로 접근할 수 있다.
- 다른 서버 컴포넌ㅌ, div등 요소, 클라이언트 컴포넌트 모두 렌더링할 수 있다.
- 클라이언트 컴포넌트
- 서버 컴포넌트를 불러오거나, 서버 전용 훅 등을 사용할 수 없다.
- 서버 컴포넌트 -> 클라이언트 컴포넌트 -> 서버 컴포넌트 구조는 가능하다.(이미 서버에서 만들어진 트리를 삽입해서 보여주기 때문)
- state, effect 사용 가능, 브라우저 API 사용 가능
- 공용 컴포넌트
- 클라이언트, 서버 모두에서 사용할 수 있다.
- 두 가지의 모든 제약을 받는다.
- 리액트는 모든 것을 다 공용 컴포넌트로 판단하는데, 클라이언트 컴포넌트라는 것을 명시하기 위해 'use client'를 붙인다.
서버 컴포넌트는 어떻게 작동하는가?
app.get(
"/",
handleErrors(async function (_req, res) {
await waitForWebpack();
const html = readFileSync(
path.resolve(__dirname, "../build/index.html"),
"utf8"
);
res.send(html);
})
);
-
작동 과정
- 서버가 렌더링 요청을 받는다. : 서버가 렌더링 과정을 수행해야 하므로 서버 컴포넌트를 사용하는 모든 페이지는 서버에서 시작된다.
- 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화(serialize)한다.
- 서버에서 렌더링 할 수 있는 것은 직렬화, 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 플레이스홀더 형식으로 비워두고 나타낸다.
- 서버는 와이어 포맷(데이터 형태)을 스트리밍 해 클라이언트에 제공한다.
- 브라우저는 이 결과물을 받아 역직렬화한다음 렌더링을 수행한다.
- 브라우저가 리액트 컴포넌트 트리를 구상한다.
- 받은 결과물을 다시 파싱한 결과물을 바탕으로 트리를 재구성해 컴포넌트를 만든다.
- 클라이언트 컴포넌트 -> 클라이언트에서 렌더링 / 서버에서 만들어진 결과물 -> 그대로 리액트 트리를 만든다.
-
작동방식의 특징
- 서버에서 클라이언트로 정보를 보낼 때 스트리밍 형태로 보낸다 : 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링해 시간 소요가 적다.
- 각 컴포넌트 별로 번들링 별개 -> 필요에 따라 컴포넌트를 지연해서 받을 수 있다.
- 결과물이 HTML이 아닌 JSON이다.
- 따라서 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 반드시 JSON 직렬화 가능한 데이터로 넘겨야 한다.
Next.js에서의 리액트 서버 컴포넌트
새로운 fetch 도입과 getServerSideProps, getStaticProps, getInitialProps의 삭제
getServerSideProps, getStaticProps, getInitialProps가 /app 디렉터리 내부에서는 삭제되었고, 그 대신 모든 데이터 요청은 fetch를 기반으로 다뤄진다.
async function getData() {
const result = await fetch("https://api.example.com/");
if (!result.ok) {
throw new Error("데이터 불러오기 실패");
}
return result.json();
}
export default async function Page() {
const data = await getData();
return (
<main>
<Children data={data} />
</main>
);
}
- 서버에서 직접 데이터를 불러올 수 있다.
- 컴포넌트가 비동기적으로 작동할 수 있다.
- 데이터를 불러올 때까지 기다렸다가, 데이터가 불러와지면 페이지가 렌더링되어 클라이언트로 전달된다.
- fetch API를 확장 -> 같은 서버 컴포넌트 트리 내에 동일한 요청이 있다면 재요청이 발생하지 않도록 요청 중복을 방지했다.
정적 렌더링과 동적 렌더링
- 정적 라우팅 : 빌드 타임에 미리 렌더링을 해두고 캐싱해 재사용할 수 있게 했다.
- 동적 라우팅 : 서버에 매번 요청이 올 때마다 컴포넌트를 렌더링하게 했다.
- Next.js가 제공하는 next/headers나 next/cookie 같은 헤더 정보, 쿠키 정보를 불러오는 함수를 사용하면 정적 렌더링 대상에서 제외된다.
- 동적 주소이지만 캐싱하고 싶은 경우 : genereateStaticParams를 사용한다.
export async function generateStaticParams(){
return [{id: '1'}, {id: '2'}, {id: '3'}, {id: '4'}]
}
async function fetchData(params: {id: string}){
const res = await fetch(`https://.../${params.id}`)
const data = await res.json()
return data
}
export default async function Page({
params
} : {
params: {id: string}
children?: React.ReactNode
}){
const data = await fetchData(params)
return(
<div>
<h1>{data.title}</h1>
</div>
)
}
-
미리 HTML 결과물을 만들어둔다.
-
fetch 옵션
fetch(URL, {cache : 'force-cache'})
: 불러온 데이터를 캐싱해 해당 데이터로만 관리한다.
fetch(URL, {cache : 'force-cache'}), fetch(URL, {next: {revalidate: 0}})
: 캐싱하지 않고 매번 새로운 데이터를 불러온다.
fetch(URL, {next: {revalidate: 10}})
: 정해진 유효시간 동안 캐싱하고, 이 유효시간이 지나면 캐시를 파기한다.
캐시와 mutating, 그리고 revalidating
- 페이지에 revalidate 변수를 선언해서 페이지 레벨로 정의할 수 있다.
export const revalidate = 60;
- 루트에 선언하면 하위 모든 라우팅에서는 페이지를 60초 간격으로 갱신해 새로 렌더링한다.
- 캐시와 갱신 과정
- 최초로 해당 라우트로 요청이 올 때는 미리 정적으로 캐시해 둔 데이터를 보여준다.
- 캐시된 초기 요청은 revalidate에 선언된 값만큼 유지된다.
- 만약 해당 시간이 지나도 일단은 캐시된 데이터를 보여준다. + 백그라운드에서 다시 데이터를 불러온다.
- 이후 캐시된 데이터를 갱신한다.(실패했다면 과거 데이터를 보여준다.)
- 전적으로 무효화하고 싶다면 router.refresh()를 사용한다.
- 서버에서 루트부터 데이터를 전체적으로 가져와 갱신한다.
- 브라우저나 리액트의 state에는 영향을 미치지 않는다.
스트리밍을 활용한 점진적인 페이지 불러오기
- 과거의 SSR : 요청받은 페이지를 모두 렌더링해서 내려줄 떄까지 사용자에게 아무것도 보여줄 수 없다.
- 이를 해결하기 위해 HTML을 작은 단위로 쪼개 완성되는 대로 클라이언트로 점진적으로 보내는 스트리밍이 도입되었다.
- 스트리밍을 활용할 수 있는 방법
- 경로에 loading.tsx 배치 : loading 파일을 배치하면 자동으로 Suspense가 배치된다.
- Suspense 배치 : 직접 리액트의 Suspense를 배치한다.
서버 액션
API를 굳이 생성하지 않더라도 함수 수준에서 서버에 직접 접근해 데이터 요청 등을 수행할 수 있는 기능
- 특정 함수 실행 그 자체만을 서버에서 수행할 수 있다는 장점이 있다.
- 사용하기 위해서는 'use server'를 상단에 선언하고, 함수는 반드시 async여야 한다.
action props를 추가해서 양식 데이터를 처리할 URI를 넘겨줄 수 있다.
export default function Page() {
async function handleSubmit() {
"use server";
console.log(
"해당 작업은 서버에서 수행합니다. 따라서 CORS 이슈가 없습니다."
);
const response = await fetch("https://...", {
method: "post",
body: JSON.stringify({
title: "foo",
body: "bar",
userId: 1,
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
const result = await response.json();
console.log(result);
}
return (
<form action={handleSubmit}>
<button type="submit">요청 보내보기</button>
</form>
);
}
- 서버 액션을 실행하면 클라이언트에서는 현재 라우트 주소와 ACTION_ID만 보내고 아무것도 실행하지 않는다.
- 서버에서는 요청받은 라우트 주소와 ACTION_ID를 바탕으로, 실행할 내용을 찾고 서버에서 직접 실행하낟.
- 내용을 빌드 시점에 미리 서버로 옮김 -> 클라이언트 번들링 결과물에 포함되지 않고 서버에서만 실행된다.
- 이 모든 과정은 새로고침 없이 수행된다.
- revalidatePath : 인수로 넘겨받은 경로의 캐시를 초기화해서 해당 URL에서 즉시 새로운 데이터를 불러오는 역할을 한다.(server mutation)
사용 시 주의할 점
- 클라이언트 컴포넌트 내에서 정의될 수 없다. 클라이언트 컴포넌트에서 사용하고자 할 떄는 서버 액션만 모여 있는 파일을 별도로 import 해야 한다.
- props 형태로 서버 액션을 클라이언트 컴포넌트에 넘기는 것 또한 가능하다.
그 밖의 변화
- 프로젝트 전체 라우트에서 쓸 수 있는 미들웨어 강화
- SEO를 쉽게 작성할 수 있는 기능 추가
- 정적으로 내부 링크를 분석할 수 있는 기능 등 다양한 내용 추가
Next.js 13 코드 맛보기
getServerSideProps
- 과거에는 getServerSideProps 등을 사용해야만 서버에서 데이터를 불러와서 하이드레이션 할 수 있었다.
- 13 버전부터는 서버 컴포넌트라면 어디든 서버 관련 코드를 추가할 수 있게 되었다.
- 기존 getServerSideProps와 마찬가지로 미리 렌더링되어 완성된 HTML이 내려온다.
- 리액트 18부터는 서버 컴포넌트의 렌더링 결과를 컴포넌트별로 직렬화된 데이터로 받는다.
- 클라이언트는 이 데이터를 바탕으로 하이드레이션한다.
getStaticProps
- 과거 : getStaticProps나 getStaticPaths를 이용해 사전에 미리 생성 가능한 경로를 모아둔다음, 이 경로에 내려줄 props를 미리 빌드하는 형식으로 구성
- 13 : fetch, cache로 유사하게 구현 가능
로딩, 스트리밍, 서스펜스
Next.js 13에서는 스트리밍과 리액트의 서스펜스를 활용해 컴포넌트가 렌더링 중이라는 것을 나타낼 수 있다.