Next.js 버전 13은 Next.js의 릴리스 역사를 통틀어 가장 큰 변화가 있는 릴리스라고 해도 과언이 아니다.
현재까지 Next.js 의 아쉬운 점으로 평가받던 것 중 하나는 바로 레이아웃의 존재다.
공통 헤더와 공통 사이드바가 거의 대부분의 페이지에 필요흔 웹사이트를 개발한다고 가정해보자.
만약 react-router-dom을 사용한다면 다음과 같이 구현할 수 있다.
import {Routes, Route, Outlet, Link} from 'react-router-dom'
export default function App() {
return (
<div>
<div>Routes 외부의 공통 영역</div>
<Routes>
<Route path="/" element={<Layout/>}>
<Route index element={<Home/>}/>
<Route path="/menu1" element={<Menu1/>}/>
<Route path="/menu2" element={<Menu2/>}/>
<Route path="*" element={<NoMatch/>}/>
</Route>
</Routes>
</div>
)
}
function Layout() {
return (
<div>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/menu1">menu1</Link>
</li>
<li>
<Link to="/menu2">menu2</Link>
</li>
</ul>
</nav>
<hr />
<div>/ path 하위의 공통 영역</div>
<Outlet/>
</div>
)
}
function Home() {
return (
<div>
<h2>Home</h2>
</div>
)
}
function Menu1() {
return (
<div>
<h2>Menu1</h2>
</div>
)
}
function Menu2() {
return (
<div>
<h2>Menu2</h2>
</div>
)
}
function NoMatch() {
return (
<div>
<h2>Nothing to see here!</h2>
<p>
<Link to="/">Go to the home page</Link>
</p>
</div>
)
}
<Routes>
영역은 주소에 따라 바뀌는 영역으로, 각 주소에 맞는 컴포넌트를 선언해서 넣어주면 된다.
<Routes>
의 외부 영역은 주소가 바뀌더라도 공통 영역으로 남는다. <Routes>
내부만 주소에 맞게 변경된다.
또한 <Routes>
내부의 <Outlet/>
은 <Routes>
의 주소 체계 내부의 따른 하위 주소를 렌더링하는 공통 영역이다. 사용하기에 따라 <Routes>
의 외부 영역 같이 해당 주소의 또 다른 영역을 공통으로 꾸밀 수 있다.
An <Outlet>
should be used in parent route elements to render their child
route elements. This allows nested UI to show up when child routes are
rendered. If the parent route matched exactly, it will render a child index
route or nothing if there is no index route.
13 버전 이전 까지 모든 페이지는 각각의 물리적으로 구별된 파일로 독립돼 있었다.
페이지 공통으로 무언가를 집어 넣을 수 있는 곳은 _document, _app이 유일하다.
하지만... 서로 다른 목적을 지니고 있다.
_document: 페이지에서 쓰이는 <html>
과 <body>
태그를 수정하거나. 서버사이드 렌더링 시 styled-components 같은 일부 CSS-in-JS를 지원하기 위한 코드를 삽입하는 제한적인 용도로 사용된다. 오직 서버에서만 작동하므로 onClcik 같은 이벤트 핸들러를 붙이거나 클라이언트 로직을 붙이는것이 금지 된다.
_app: _app은 페이지를 초기화하기 위한 용도로 사용되며, 다음과 같은 작업이 가능하다.
즉, 이전의 Next.js 12 버전까지는 페이지 공통 레이아웃을 유지할 수 있는 방법은 _app이 유일했다.
그러나 이 방식은 _app에서밖에 할 수 없어 제한적이고, 각 페이별로 서로 다른 레이아웃을 유지할 수 없다. 이런 레이아웃의 한계를 극복하기 위해 나온 것이 Next.js의 app 레이아웃이다.
기본적으로 Next.js의 라우팅은 파일 시스템을 기반으로 하고 있다.
app 기반 라우팅 시스템은 기존에 /pages를 사용했던 것과 다음과 같은 차이가 있다.
즉, Next.js 13의 app 디렉터리 내부의 파일명은 라우팅 명칭에 아무런 영향을 미치지 못한다.
app 내부에서 가질 수 있는 파일명은 뒤이어 설명할 예약어로 제한된다
Next.js 13 부터는 app 디렉터리 내부의 폴더명이 라우팅이 되며,
이 폴더에 포함될 수 있는 파일명은 몇 가지로 제한돼 있다. 그중 하나가 layout.js 이다.
이 파일은 페이지의 기본적인 레이아웃을 구성하는 요소이다. 해당 폴더에 layout이 있다면 그 하위 폴더 및 주소에 모두 영향을 미친다.
// app/layout.tsx
import { ReactNode } from 'react'
export default function AappLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<head>
<title>안녕하세요!</title>
</head>
<body>
<h1>웹페이지에 오신 것을 환영합니다.</h1>
<main>{children}</main>
</body>
</html>
)
}
//app/blog/layout.tsx
import { ReactNode } from 'react'
export default function BlogLayout({ children }: { children: ReactNode }){
return <section>{children}</section>
}
<html lang="ko">
<head>
<title>안녕하세요!</title>
</head>
<body>
<h1>웹페이지에 오신 것을 환영합니다.</h1>
<main><section>여기에 블로그 글</section></main>
</body>
</html>
page도 에약어 이며, 이전까지 Next.js에서 일반적으로 다뤘던 페이지를 의미한다.
export default function BlogPage() {
return <>여기에 블로그 글</>
}
이 page는 앞에서 구성했던 layout을 기반으로 위와 같은 리액트 컴포넌트를 노출하게 된다. 이 page가 받는 props는 다음과 같다.
error.js는 해당 라우팅 영역에서 사용되는 공통 에러 컴포넌트다. 이 error.js를 사용하면 특정 라우팅별로 서로 다른 에러 UI를 렌더링하는 것이 가능해진다.
'use client'
import {useEffect} from 'react'
export default funcion Error({error, reset}:{error:Error, reset: () => void})
{
useEffect(() => {
console.log('logging error:', error)
}, [error])
return (
<>
<div>
<string>Error:</string> {error?.message}
</div>
<div>
<button onClick={() => reset()}> 에러 리셋</button>
</div>
</>
)
}
error페이지는 에러 정보를 담고 있는 error: Error 객체와 에러 바운도리를 초기화할 rest: () => void를 props로 받는다.
에러 바운더리는 클라이언트에서만 작동하므로 error 컴포넌트도 클라이언트 컴포넌트여야한다.
이 error 컴포넌트는 같은 수준의 layout에서 에러가 발생할 경우 해당 error 컴포넌트로 이동하지 않는다.
그 이유는 아마도 <Layout><Error>{children}</Error><Layout>
과 같은 구조로 페이지가 렌더링되기 때문일 것이다.
만약 Layout 에서 발생하는 에러를 처리하고 싶다면 상위 컴포넌트의 error를 사용하거나, app의 루트 에러처리를 담당하는 app/global-error.js 페이지를 생성하면 된다.
not-fount는 특정 라우팅 하위의 주소를 찾을 수 없는 404 페이지를 렌더링할 떄 사용된다.
리액트 Suspense를 기반으로 해당 컴포넌트가 불러오는 중임을 나타낼 때 사용할 수 있다.
export default function Loading() {
return 'Loading...'
}
디렉터리가 라우팅 주소를 담당하며 파일명은 route.js로 통일됐다. /app/api/hello/route.ts에 다음 예제와 같은 내용을 추가했다고 가정해보자.
// app/api/hello/route.ts
import { NextRequest } from 'next/server'
export async function GET(request: Request) {}
export async function HEAD(request: Request) {}
export async function POST(request: Request) {}
export async function PUT(request: Request) {}
export async function DELETE(request: Request) {}
export async function PATCH(request: Request) {}
export async function OPTIONS(request: Request) {}
route.ts ㅍ일 내부에 REST API의 get, post와 같은 메서드명을 예약어로 선언해 두면 HTTP 요청에 맞게 해당 메서드를 호출하는 방식으로 작동한다.
route.ts가 존재하는 폴더 내부에는 page.tsx가 존재할 수 없다.
route의 함수들이 받을 수 있는 파라미터는 다음과 같다.
// app/api/users/[id]/routes.ts
export async function GET(
request: NextRequest,
context: { params: { id: string} },
){
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${context.params.id}`,
)
// ...
return new Response(JSON.stringify(result), {
status: 200,
headers: {
'content-type': 'application/json',
},
})
}
리액트 18에서 새로 도입된 리액트 서버 컴포넌트는 서버 사이드 렌더링과 완전 다른 개념이다.
두 용어 모두 '서버' 라는 단어가 포함돼 있어 혼동의 여지가 있지만 '서버'라는 단어가 있다는 점, 그리고 '서버'에서 무언가 작업을 수행한다는 점을 제외하면 완전히 다른 개념으로 보는 것이 옳다.
리액트의 모든 컴포넌트는 클라이언트에서 작동하며, 브라우저에서 자바스크립트 코드 처리가 이뤄진다.
예를 들어, 리액트로 만들어진 페이지를 방문한다고 가정해보자.
웹사이트를 방문하면 리액트 실행에 필요한 코드를 다운로드하고 리액트 컴포넌트 트리를 만든 다음, DOM에 렌더링한다.
서버 사이드 렌더링의 경우는?
미리 서버에서 DOM을 만들어 오고, 클라이언트에서는 이렇게 만들어진 DOM을 기준으로 하이드레이션을 진행한다.
이후 브라우저에서는 상태를 추적하고, 이벤트 핸들러를 DOM에 추가하고, 응답에 따라 렌더링 트리를 변경하기도 한다.
결국 서버 사이드 렌더링, 클라이언트 사이드 렌더링은 모두 이 문제를 해결하기에는 아쉬움이 있다.
서버 사이드 렌더링은 정적 콘텐츠를 빠르게 제공하고, 서버에 있는 데이터에 손쉽게 제공할 수 있는 반면 사용자의 인터랙션에 따른 다양한 사용자 경험을 제공하긴 어렵다.
클라이언트 사이드 렌더링은 사용자의 인터랙션에 따라 정말 다양한 것들을 제공할 수 있지만 서버에 비해 느리고 데이터를 가져오는 것도 어렵다.
이러한 두 구조의 장점을 모두 취하고자 하는 것이 바로 리액트 서버 컴포넌트다.
서버 컴포넌트(Server Component)란 하나의 언어, 하나의 프레임워크, 그리고 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링할 수 있는 기법을 의미한다.
서버에서 할 수 있는 일은 서버가 처리하게 두고, 서버가 할 수 없는 나머지 작업은 클라이언트인 브라우저에서 수행된다.
서버컴포넌트의 이론에 따르면 모든 컴포넌트는 서버 컴포넌트가 될 수도 있고, 클라이언트 컴포넌트가 될 수도 있다.
따라서 컴포넌트 트리에서 위와 같이 클라이언트 및 서버 컴포넌트가 혼재된 상황은 자연스럽다.
어떻게 이런 구조가 가능할까? 그 비밀은 흔히 children으로 자주 사용되는 ReactNode에 달려 있다.
// ClientComponent.jsx
'use client'
// ❌ 이렇게 클라이언트 컴포넌트에서 서버 컴포넌트를 불러오는 것은 불가능 하다.
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
return (
<div>
<ServerComponent />
</div>
)
}
'use client'
// ClientComponent.jsx
export default function ClientComponent({children}) {
return (
<div>
<h1>클라이언트 컴포넌트</h1>
{children}
</div>
)
}
// ServerComponent.jsx
export default function ServerComponent({children}) {
return (
<div>
<h1>서버 컴포넌트</h1>
</div>
)
}
// ParentServerComponent.jsx
import ClientComponent from './ClientComponent'
import ServerComponent from './ServerComponent'
export default function ParentServerComponent({children}) {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
서버 컴포넌트
클라이언트 컴포넌트
공통 컴포넌트
리액트는 모든 것을 다 공용 컴퍼넌트로 판단한다. 즉, 모든 컴포넌트를 다 서버에서 실행 가능한 것으로 분류한다. 대신, 클라이언트 컴포넌트라는 것을 명시적으로 선언하려면 파일의 맨 첫 줄에 "use client" 라고 작성해 두면 된다.
서버사이드 렌더링
이후에는 서버 사이드 렌더링과 서버 컴포넌트를 모두 채택하는 것도 가능해질 것이다.
서버 컴포넌트를 활용해 서버에서 렌더링 할 수 있는 컴포넌트는 서버에서 완성해 제공받는 다음, 클라이언트 컴포넌트는 서버 사이드 렌더링으로 초기 HTML으로 빠르게 전달 받을 수가 있다.
이 두가지 방법을 결합하면 클라이언트 및 서버 컴포넌트를 모두 빠르게 보여줄 수 있고, 동시에 클라이언트에서 내려받아야 하는 자바스크립트의 양도 줄어들어 브라우저의 부담을 덜 수 있다.
결론적으로 둘은 대체제가 아닌 상호 보완하는 개념이다.
https://yceffort.kr/2022/01/how-react-server-components-work 참고!