지난 포스팅에서 Next.js의 등장배경에 대해 간략히 알아보았다. 이번 포스팅에서는 Next.js의 Pages Router, App Router를 배우기 앞서 기본적으로 알아야 할 개념들에 대해 짚고 넘어가고자 한다.
CSR에서는 기본적으로 경로(URL)가 하나, SSR에서는 여러 페이지가 존재하며 각각의 경로가 존재한다. 따라서 SSR에서 경로에 따라 페이지를 선택하는 방법이 필요했고, 이때 경로에 페이지를 대응시키는 프로세스가 Routing이다.
<Route path='/' components = {<Home />}/>app.get('/', (req, res) => { res.send('Hello'); };@Controller('users')
export class Users Controller {
@Get()
findAll(): string {
return 'Hello';
}
}Next.js는 파일 기반 라우팅을 선택했다. 말 그대로 파일명에 따라 그 기능을 고정하고 경로를 지정한다. 예시로 about/age.tsx 는 ‘/about’ 경로에 존재하는 페이지이다. 이렇게 Next.js는 개발자들이 자주 사용하는 구조를 쉽게 사용할 수 있게 추상화된 구조를 파일 컨벤션으로써 제공한다.
파일 구조 기반을 채택함으로써 얻는 장점은 다음과 같다.
첫째, 직관적이다.
둘째, 경로가 모두 나누어져 있어 특정 경로에서 필요한 JavaScript 번들만 받아오면 되고, 응답 시간이 줄어든다. React로 개발할 때를 생각해보면, 별도의 router 파일을 생성하고 해당 파일 내에서 React.lazy 로 지연 로딩을 수행했었다. 하지만 Next.js는 서로 다른 경로에 따라 자동적으로 코드 스플리팅을 수행해준다.
Navigation
페이지 간 이동 과정 자체를 의미한다.
브라우저의 요청에 대하여 사전에 렌더링이 완료된 HTML을 응답하는 렌더링 방식이다. 이 방식을 사용하면 늦은 FCP, 낮은 SEO와 같은 CSR의 단점을 효율적으로 해결할 수 있다. 이를 통해, 빠른 FCP로 React의 단점을 해소하고 hydration 이후 빠른 페이지 이동으로 React의 장점을 살린다.
서버에서 일어나는 '렌더링'은 JavaScript 코드(React 컴포넌트)를 HTML로 변환하는 과정을 의미하고, 브라우저에서 일어나는 '렌더링'은 HTML 코드를 브라우저가 화면에 그리는 작업을 의미한다.
브라우저가 화면에 HTML을 렌더링한 이후에도 여전히 사용자는 화면과 상호작용할 수 없다. 왜냐하면 JS 코드가 아닌 HTML만 전달받은 상태이기 때문이다. 따라서 JS 번들을 통해서 HTML을 Hydration 시켜주어야 한다.
Hydration: 수화. 어떤 물질이 물과 회합하거나 결합하여 수화물이 되는 현상. [출처: 네이버 어학사전]
서버사이드 렌더링(SSR)을 통해 만들어진 interactive 하지 않은 HTML을 클라이언트 측 JavaScript를 사용하여 interactive한 리액트 컴포넌트로 변환하는 과정(서버 환경에서 이미 렌더링된 HTML에 React를 붙이는 것)이다. 이때, hydration을 위한 interactive한 JavaScript만 다운받으면 되므로, 다운받아야 할 JavaScript 코드 양이 줄어드는 장점이 있다. 다시 말해, 현재 페이지에 필요한 JS 번들만 전달되므로 용량 경량화로 인해 hydration 시간이 단축된다.
기존 React에서의 기본적인 데이터 페칭 방식은 아래와 같았다. 하지만 이런 방식은 초기 접속 요청부터 데이터 로딩까지 오랜 시간이 걸린다는 단점이 있다. 왜냐하면 컴포넌트 마운트된 이후에 데이터 페칭 함수가 호출되기 때문이다. 즉, 데이터 요청 시점 자체가 늦다.
이러한 문제를 Next.js는 사전 렌더링 시점에 데이터 페칭을 수행함으로써 해결한다. 사전 렌더링에서 응답받은 데이터는 HTML에 삽입되며, 사용자에게 완성된 HTML로써 화면을 보여줄 수 있다.
| React | Next.js | |
|---|---|---|
| 시점 | 컴포넌트 마운트 이후에 발생 | 사전 렌더링 중 발생 (컴포넌트 마운트 이후에도 발생 가능) |
| 특징 | 데이터 요청 시점이 상대적으로 늦음 | 데이터 요청 시점이 매우 빨라짐 |
하지만 데이터 페칭 자체가 오래걸리면 이런 방식은 오히려 초기 로딩을 늦출 수도 있다. 따라서 Next.js는 빌드 타입에 데이터 페칭을 미리 수행하는 SSG 등의 방법을 제공하여 이러한 우려를 해소한다.
| 페이지 라우터 | 앱 라우터 |
|---|---|
| 모든 컴포넌트가 클라이언트 컴포넌트 | 클라이언트 컴포넌트 + 서버 컴포넌트 (비동기 함수로서 서버에서만 동작) |
| getServerSideProps(SSR), getStaticProps(SSG), Dynamic SSG | fetch |
필요한 JS 번들을 그때마다 가져오는(Code Splitting) 덕분에 Hydration 시간이 단축되는 장점이 있다고 했다. 하지만 이후에 사용자가 페이지 이동을 요청했을 때, 그에 맞는 JS 번들을 가져와야 하므로 추가적인 시간이 필요하다는 단점이 존재한다.
따라서 이러한 문제를 방지하기 위한 방법이 필요했고, 필요한 페이지를 미리 가져오는 Pre(미리)-Fetching(불러오기) 기능이 등장했다. 이 기능은 사용자가 특정 링크를 클릭하기도 전에 백그라운드 상에서 미리 해당 라우트를 로드한다. 덕분에 렌더링이 최적화되고 사용자는 빠른 페이지 이동 경험을 제공받을 수 있다.
이 기능은 운영 환경에서만 적용됨에 유의한다.
기본적으로 <Link> 컴포넌트에서만 동작한다. 따라서 useRouter() 를 통해 생성한 라우터 객체로 페이지를 이동하는 경우, 즉 프로그래밍 방식으로 라우트를 변경을 사용한다면 별도의 설정이 필요하다.
<Link> 의 경우, 뷰포트에서 보일 때 자동으로 라우트를 미리 가져온다.
router.prefetch('경로') : useEffect 로 마운트 시 prefetching하도록 설정한다.import { useRouter } from "next/router";
const router = useRouter();
const onClickButton = () => {
router.push("/test");
};
useEffect(() => {
router.prefetch("/test");
}, []);
useRouter훅은 App Router를 사용할 때next/router가 아닌next/navigation에서 가져와야 한다.
<Link> 컴포넌트의 prefetching 기본 동작을 취소하려면 prefetch 속성을 false로 설정하면 된다. 단, 이때에도 사용자의 마우스 호버 시에 prefetching 동작이 수행된다.
public API를 설계하는 기능을 제공한다. 프런트엔드 프로젝트 내에서 서버리스 함수처럼 백엔드 API 엔드포인트를 직접 만들 수 있도록 해준다. Pages router의 경우, pages/api 경로에 존재하는 파일들은 페이지 기능 대신 API 엔드포인트로써 동작한다. 서버 사이드의 번들이므로 클라이언트 사이드의 번들 용량을 늘리지는 않는다.
// Pages Router
import type { NextApiRequest, NextApiResponse } from 'next'
type ResponseData = {
message: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
res.status(200).json({ message: 'Hello from Next.js!' })
}
// App router
export async function GET(request: Request) {}
App router의 경우에는 데이터 페칭 시 서버 컴포넌트를 활용하는 방안도 존재한다.
다수의 라우트 간 UI 공유를 위해 사용되며, layout.js 파일로부터 리액트 컴포넌트를 export 함으로써 정의할 수 있다. 이때, 리액트 컴포넌트는 children 을 prop으로 받아야 한다. NextJS는 layout 컴포넌트를 렌더링한 후 URL을 확인하여 무엇을 렌더링해야 할 지 결정한다.
export default function DashboardLayout({
children, // will be a page or nested layout
}: {
children: React.ReactNode
}) {
return (
<section>
{/* Include shared UI here e.g. a header or sidebar */}
<nav></nav>
{children}
</section>
)
}
app 폴더 내 최상단에 위치해야하며, 모든 라우트에 적용되는 레이아웃이다. 이는 필수이며 html 및 body 태그를 포함하여야 한다. (서버로부터 반환되는 초기 HTML을 수정하게끔 한다.)
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{/* Layout UI */}
<main>{children}</main>
</body>
</html>
)
}
중첩 레이아웃을 수행할 수 있다. 렌더링할 페이지와 가장 가까운 레이아웃을 찾은 후, 다음 가까운 레이아웃을 찾는 과정을 반복한다.
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <section>{children}</section>
}
next.js에서 다크모드를 손쉽게 적용하게 해주는 라이브러리인 next-themes 을 활용할 수 있다.
npm install next-themes
는 클라이언트 컴포넌트이므로, 만약 layout 이 클라이언트 컴포넌트가 아니라면("use client"를 사용하지 않은 경우)ThemeProvider 를 별도의 클라이언트 컴포넌트로 분리하여 layout` 에 제공해야 함// app/layout.jsx
import { ThemeProvider } from 'next-themes'
export default function Layout({ children }) {
return (
<html suppressHydrationWarning>
<head />
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
ThemeProvider 분리"use client";
import { ThemeProvider } from "next-themes";
export default function ThemeChangeProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThemeProvider enableSystem={true} attribute="class">
{children}
</ThemeProvider>
);
}
const config: Config = {
mode: "jit",
darkMode: "class",
...
};