자바스크립트의 Promise를 이해하는데 참고할 수 있는 간단한 예제코드
Next.js 13, 14, 15 버전의 업데이트 내역 살펴보기
2024-11-15
Explanation
오랜만에 찾아온 포스팅 시간!
어떤 주제로 쓰면 좋을까 고민하다가, 최근에 Next.js가 15버전이 릴리스되었다고 하더라고요?! 얼마 전에 React의 지난 업데이트 내역을 가볍게 정리했던 것처럼, 오늘은 Next.js의 지난 업데이트 내역을 간단하게 정리해 보려 합니다!
글의 대부분의 내용은 Next.js 공식 홈페이지의 문서와 크게 다르지 않습니다. 보다 자세하고 정확한 정보가 필요하시다면 공식 홈페이지의 문서를 확인해 주세요 :)
링크: https://nextjs.org/docs/app/getting-started
이 글에 사용되는 샘플 코드들은 아래의 리포지토리에서 모두 확인 하실 수 있습니다.
링크: https://github.com/falsy/blog-post-example/tree/main/nextjs-by-version
13 버전에서 추가된 가장 큰 변화는 아무래도 App 라우터겠죠?!
1 2 3 4 5 6 |
/app ├─ layout.js (필수) ├─ page.js └─ /dashboard ├─ layout.js └─ page.js |
아주 간단하게 표현하면 위와 같은 구조에요. 여기서 app 디렉토리 바로 아래 있는 필수로 표시한 layout가 루트 레이아웃이 되고 그 아래로는 쉽게 생각하면, 고차 컴포넌트로 layout 컴포넌트 안에 children 영역에 page 컴포넌트가 출력된답니다. 만약에 위 예시 구조에서 dashboard(“/dashboard”)라우트로 접근한다면, app 디렉토리에 있는 layout의 자식으로 dashboard의 layout과 그 자식으로 dashbaord의 page 순서로 중첩되어 출력됩니다.
그리고 위와 같이 layout, page 말고도 몇가지 라우팅 파일 규칙이 더 있는데요.
1 2 3 4 5 6 7 8 9 |
- layout - page - loading - not-found - error - global-error - route - template - default |
layout과 page는 앞서 이야기 했으니까 제외하고 loading부터 간단하게 하나씩 알아보자면,
해당 라우트의 페이지를 불러오는 동안 보여주는 로딩 상태의 페이지입니다.
1 2 3 4 5 6 7 |
/app ├─ layout.js ├─ page.js └─ /dashboard ├─ layout.js ├─ loading.js └─ page.js |
위와 같은 구조에서 dashboard 라우트 페이지에 접근했다고 가정하면, dashboard의 page.js를 불러오는 동안 loading.js 로딩 UI가 출력됩니다.
1 2 3 4 5 6 7 |
<RootLayout> <Layout> <Suspense fallback={<Loading />}> <Page /> </Suspense> </Layout> </RootLayout> |
대략, 위와 같이 이렇게 loading.js의 내용이 Page 컴포넌트의 Suspense의 fallback으로 동작합니다.
해당 라우트의 페이지가 존재하지 않을때 보여주는 페이지입니다.(404)
1 2 3 4 5 6 7 8 |
/app ├─ layout.js ├─ not-found.js ├─ page.js └─ /notfound ├─ layout.js ├─ not-found.js └─ page.js |
기본적으로 app 디렉토리 바로 아래의 not-found 파일이 전체 앱의 기본 404 페이지가 되며, 페이지 라우터 안의 not-found는 해당 라우터의 페이지에서 next에서 제공하는 함수를 사용해서 조건에 따라 호출할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// /app/notfound/page.js import { notFound } from "next/navigation" export default function page() { if (true) { notFound() } return ( <div> <p>404 - test</p> </div> ) } |
루트 레이아웃 아래의 error.js에서 처리되지 않은 오류 처리를 하며 페이지 라우터에서의 error.js는 세분화된 미처리 오류를 처리합니다. 그리고 루트 레이아웃에서 처리되지 않은 오류는 global-error.js에서 처리합니다.
1 2 3 4 5 6 7 8 9 |
/app ├─ layout.js ├─ error.js ├─ page.js ├─ /error │ ├─ error.js │ └─ page.js └─ /error2 └─ page.js |
위와 같은 구조에서 error 페이지에서 오류가 발생하면 error 디렉토리 안에 error.js가 출력되며 error2 페이지에서 오류가 발생하면 app 디렉토리에 있는 error.js가 출력됩니다.
그리고 error.js 파일은 “use client” 로 클라이언트 컴포넌트로 선언해주어야 합니다.
1 2 3 4 5 6 7 8 9 10 |
// /app/error.js "use client" export default function Error() { return ( <div> <p>root - error</p> </div> ) } |
global-error는 실제로 호출되는 경우는 루트에 error.js가 없을때를 제외하고는 아주 제한적이라서, 일반적으로는 라우트별 error.js와 루트의 error.js를 활용하여 설계하고 global-error는 최종 안전망 정도로 사용합니다.
global-error.js는 error.js와 달리 layout 밖에서 동작하기 때문에 html, body 등의 태그를 넣어줘야합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// /app/global-error.js "use client" export default function GlobalError({ error, reset }) { return ( <html lang="en"> <head> <title>Error</title> </head> <body> <button onClick={() => reset()}>Reset</button> </body> </html> ) } |
global-error.js은 프로덕션에서만 활성화됩니다. 개발 중에는 대신 오류 오버레이가 표시됩니다.
route.js 파일을 통해서 커스텀한 요청 핸들러를 생성할 수 있습니다.
1 2 3 4 5 6 7 8 |
// /app/api/data/route.js export async function GET() { return new Promise((resolve) => { setTimeout(() => { resolve(Response.json({ data: "true" })) }, 1000) }) } |
template은 layout과 비슷하지만, 페이지의 변환에도 상태값이 유지되는 것과 유지되지 않는 차이가 있습니다. 간단하게 코드를 만들어보면,
1 2 3 4 5 6 7 8 9 |
/app ├─ layout.js ├─ page.js └─ /blog ├─ layout.js ├─ template.js ├─ page.js └─ /post └─ page.js |
위와 같은 페이지 구조가 있고, blog 페이지에 layout과 template에 useState가 있을때, “/blog” → “/blog/post” 이렇게 페이지 변환이 일어나면 layout의 state 값은 유지되고, tempalte의 state 값은 초기화 됩니다.
default의 경우는 조금 복잡할 수 있는데요. 간단한 예를 들면,
1 2 3 4 5 6 7 8 9 10 11 |
/app ├─ layout.js ├─ page.js └─ /defaultDir ├─ /@test │ ├─ default.js │ └─ /testChildren │ └─ page.js ├─ layout.js ├─ default.js └─ page.js |
위와 같은 구조에서 default의 layout이 아래와 같을 때,
1 2 3 4 5 6 7 8 9 |
// /app/defaultDir/layout.js export default function Layout({ test, children }) { return ( <div> <div>{test}</div> <div>{children}</div> </div> ) } |
“/defaultDir” 페이지로 접근을 하면, children 위치에는 defaultDir page.js가 출력되며, test에는 @test 디렉토리에 page.js가 없기 때문에 @test 디렉토리의 default.js가 출력됩니다.
그리고 “/defaultDir” → “/defaultDir/testChildren” 로 페이지를 이동하게 되면, defaultDir의 layout의 children 위치에는 여전히 defaultDir page.js가 출력되고 test 부분에는 testChildren의 page.js가 출력됩니다.
그런데 만약 여기에서(“/defaultDir/testChildren”) 새로고침을 한다면?! defaultDir의 layout의 test 위치에는 testChildren의 page.js가 출력되지만 children 영역에는 defaultDir의 page.js가 아니라 defaultDir의 default.js가 출력된답니다.
한번 다시 돌아가보면, “/defaultDir” 일 때는 경로가 일치하기 때문에 해당 디렉토리의 page.js가 children으로 출력되고 “/defaultDir/testChildren” 로 페이지가 변경되었을 때에는 라우터가 변경되었지만, 앞서 이야기 했던 것처럼 layout은 페이지 변화에서 상태를 유지하기 때문에 여전히 defaultDir의 page.js가 출력되는 것입니다.
그리고 새로고침을 했을때에는 경로가 “/defaultDir/testChildren” 이기 때문에 layout에서 children은 page.js를 가리키지 않기 때문에 이때 default.js를 대신해서 출력합니다.
만약 defaultDir 디렉토리에 default.js가 없다면, “/defaultDir”를 거치지 않고 “/defaultDir/testChildren”에 직접 접근하거나 “/defaultDir/testChildren”에서 새로고침을 했을때 layout의 children 영역에 출력할 것을 찾지 못하기 때문에 404가 출력됩니다.
대괄호([ ])를 사용해서 동적으로 페이지를 라우팅할 수 있습니다.
1 2 3 4 5 6 |
/app ├─ layout.js ├─ page.js └─ /dynamic └─ [slug] └─ page.js |
위와 같은 구조에서, dynamic/[slug]/page.js 코드가 아래와 같을 때
1 2 3 4 5 6 7 8 |
// /app/dynamic/[slug]/page.js export default function Page({ params: { slug } }) { return ( <div> <p>dynamic page {slug}</p> </div> ) } |
“/dynamic/test” 로 접근하면, “dynamic page test”가 출력됩니다.
앞서 default를 이야기하며 살짝 먼저 사용했지만, 골뱅이표(@)를 사용한 병렬 라우터로 동일한 레이아웃에 하나 이상의 페이지를 동시에 또는 조건부로 렌더링 할 수 있습니다.
1 2 3 4 5 6 7 8 9 |
/app ├─ layout.js ├─ page.js └─ /parallel ├─ @foo │ └─ page.js ├─ @boo │ └─ page.js └─ layout.js |
위와 같은 구조에서, parallel/layout.js 코드가 아래와 같을 때
1 2 3 4 5 6 7 8 9 |
// /app/parallel/layout.js export default function Layout({ foo, boo }) { return ( <div> {foo} {boo} </div> ) } |
“/parallel” 로 접근하면, foo 자리에는 “@foo/page.js”가 출력되고 boo 자리에는 “@boo/page.js”가 출력됩니다.
기본적으로 앱 라우터는 중첩된 디렉토리 구조로 URL 경로가 생성되는데, 소괄호(( ))를 사용해서 생성한 라우트 그룹은 URL 경로에 포함하지 않으면서 그룹내의 여러 페이지에 layout을 공유할 수 있습니다.
1 2 3 4 5 6 7 8 9 |
/app ├─ layout.js ├─ page.js └─ /(groups) ├─ group-a │ └─ page.js ├─ group-b │ └─ page.js └─ layout.js |
위와 같은 구조에서 “/group-a” 페이지와 “/group-b”페이지는 동일하게 (groups) 디렉토리의 layout을 사용합니다.
기본적으로 앱 디렉토리 내부의 모든 컴포넌트는 서버 컴포넌트로 동작합니다. 그리고 useState, useEffet 등을 사용하기 위한 클라이언트 컴포넌트를 사용하기 위해서는 “use strict”를 사용하던 것처럼 “use client”라는 선언을 해줘야 합니다.
Web API의 fetch를 지원하며 이를 활용하여 cache 옵션을 ‘force-cache’로 설정하여 SSG(Static Site Generation)로 사용하고 next 옵션의 revalidate 속성을 사용하여 ISR(Incremental Static Regeneration)를, 그리고 cache 옵션을 “no-store”로 설정해서 SSR(Server-Side Rendering)를 구현할 수 있습니다.
next의 image 컴포넌트가 변경되었습니다. 이전에는 width와 height가 필수였지만, 이제 fill 속성을 사용하여 부모의 요소에 맞게 자동으로 확장되게 할 수 있습니다. 그리고 placeholder=”blur”와 fetchPriority=”high” 같은 로딩 최적화, sizes 속성을 사용하여 반응형 이미지의 성능을 높일 수 있습니다.
이제 Link 컴포넌트에 a 태그를 추가할 필요가 없어졌습니다. Link 컴포넌트는 기본적으로 a 태그를 랜더링합니다.
Rust 기반의 JS 번딜링 툴인 Turbopack이 포함되었습니다. Webpack이나 Vite보다 빠르게 업데이트 된다고 합니다.
v15 내용을 적으려고 시작했는데.. v13가 간추렸는데도 많네요..
Turbopack의 성능이 향상되었습니다.
이전 버전에서는 데이터를 변경하기 위해서 API 라우터를 만들고 클라이언트에서 이 라우트를 호출하여 데이터를 서버에 전송하는 방식이었는데, Server Actions를 통해서 API 라우터 없이 데이터를 수정할 수 있는 방법이 추가되었습니다.
간단하게, NestJS로 로컬에 외부 서버를 하나 만들어서 통신을 테스트 해보면.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// NestJS - Controller // localhost:4000 @Controller("") export default class TestController { private test: Array<{ name: string; email: string }> = [] @Post("test") createCarrier(@Body() body: { name: string; email: string }): boolean { this.test.push(body) return true } @Get("test") getTest(): Array<{ name: string; email: string }> { return this.test } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import { revalidatePath } from "next/cache" export default async function Page() { const response = await fetch("http://localhost:4000/test") const data = await response.json() const postAction = async (formData) => { "use server" await fetch("http://localhost:4000/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: formData.get("name"), email: formData.get("email") }) }) revalidatePath("/actions") } return ( <div> <h1>Actions</h1> <div> {data && data.map((item, index) => ( <div key={index}> <h2>{item.name}</h2> <p>{item.email}</p> </div> ))} </div> <div> <form action={postAction}> <input type="text" name="name" /> <input type="text" name="email" /> <button type="submit">Submit</button> </form> </div> </div> ) } |
위와 같이 따로 API 라우터 없이, postAction이라는 서버 함수를 사용해서 데이터을 추가하고 revalidatePath 함수를 통해서 추가된 값을 갱신해서 다시 화면에 출력할 수 있습니다.
페이지의 콘텐츠가 스트리밍되기 전에 뷰포트나 색상 스킴, 테마 관련 메타 태그를 미리 전송해서 레이아웃이나 색상이 갑작이 변하는 것을 방지하고, 메타데이터를 블로킹 메타데이터와 논 블로킹 메타데이터로 분리해서 사전 렌더링 페이지가 정적 셸을 제공하는 것을 방해하지 않도록 변경되었습니다.
그래서 Next.js v14부터는 “viewport”, “generateViewport”가 새로 추가되어 기존의 viewport, colorScheme, themeColor 옵션을 대체합니다.
아래는 공홈에서 소개하는 viewport 예시입니다.
1 2 3 4 5 6 |
// themeColor import type { Viewport } from 'next' export const viewport: Viewport = { themeColor: 'black', } |
1 2 |
<!-- output --> <meta name="theme-color" content="black" /> |
1 2 3 4 5 6 7 8 9 10 11 |
// width, initialScale, maximumScale and userScalable import type { Viewport } from 'next' export const viewport: Viewport = { width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false, // Also supported by less commonly used // interactiveWidget: 'resizes-visual', } |
1 2 3 4 5 |
<!-- output --> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> |
1 2 3 4 5 6 |
// colorScheme import type { Viewport } from 'next' export const viewport: Viewport = { colorScheme: 'dark', } |
1 2 |
<!-- output --> <meta name="color-scheme" content="dark" /> |
네이티브의 History API를 사용하여 페이지를 다시 로드하지 않고도 브라우저의 기록 스택을 업데이트할 수 있습니다. pushState, replaceState 호출은 이제 Next.js의 앱 라우터와 통합되어 usePathname, useSearchParams와 동기화됩니다.
예전 “useRouter”의 “push” 메서드 옵션 중에 얕은 라우팅(shallow routing)과 유사합니다.
“next/image”의 getImageProps 함수를 사용해서 라이트 모드, 다크모드에서 다른 이미지를 표시하거나 모바일, PC와 같이 해상도에 따라 다른 이미지를 보여주도록 설정할 수 있습니다.
아래는 공홈에서 소개하는 테마에 따른 이미지 출력 예시입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { getImageProps } from 'next/image'; export default function Page() { const common = { alt: 'Hero', width: 800, height: 400 }; const { props: { srcSet: dark }, } = getImageProps({ ...common, src: '/dark.png' }); const { props: { srcSet: light, ...rest }, } = getImageProps({ ...common, src: '/light.png' }); return ( <picture> <source media="(prefers-color-scheme: dark)" srcSet={dark} /> <source media="(prefers-color-scheme: light)" srcSet={light} /> <img {...rest} /> </picture> ); } |
드디어.. 대망의 v15.. 글이 이렇게 길어질 줄 몰랐네요..
조금 신기한게, 아직 React v19가 정식 릴리스 되지 않았는데 Next.js v15는 정식 릴리스가 되었고 Next.js v15의 최소 React 버전은 19입니다. 오잉?!
이제 fetch 요청이 기본적으로 캐시되지 않습니다. 캐시를 사용하려면 fetch의 옵션으로 cache에 값을 설정해 주거나 layout에서 아래와 같이 설정할 수 있습니다. 해당 레이아웃의 자식으로 전파되며 적용됩니다.(루트 레이아웃에 적용한다면 앱 모든 fetch 요청에 적용됩니다.)
1 |
export const fetchCache = 'default-cache' |
그리고 라우트 핸들러의 GET 함수도 역시 기본적으로 더이상 캐시되지 않습니다. GET 메서드를 캐시에 포함하려면 아래와 같이 옵션을 추가합니다.
1 |
export const dynamic = 'force-static' |
그밖의 옵션은 아래에서 확인할 수 있습니다.
링크: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
Form 컴포넌트는 html form 엘리먼트의 확장으로 로딩 UI, 프리페칭, 클라이언트 탐색, 점진적 향상 기능을 제공합니다.
역시 공홈의 제공하는 예시 코드를 통해 살펴보면,
1 2 3 4 5 6 7 8 9 10 11 |
// /app/page.tsx import Form from 'next/form' export default function Page() { return ( <Form action="/search"> <input name="query" /> <button type="submit">Submit</button> </Form> ) } |
1 2 3 4 5 6 7 8 9 10 11 12 |
// /app/search/page.tsx import { getSearchResults } from '@/lib/search' export default async function SearchPage({ searchParams, }: { searchParams: { [key: string]: string | string[] | undefined } }) { const results = await getSearchResults(searchParams.query) return <div>...</div> } |
1 2 3 4 |
// /app/search/loading.tsx export default function Loading() { return <div>Loading...</div> } |
Form이 사용자에 뷰포트에 표시되면 “/search” 페이지의 layout.js 및 loading.js 등..이 프리페치 되며,
사용자가 Form에서 검색을 하면(“/search?query=abc..”) 양식은 즉시 새 경로로 이동하고 결과를 가져오는 동안 로딩 UI가 출력됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// /app/posts/create/page.tsx import Form from 'next/form' import { createPost } from '@/posts/actions' export default function Page() { return ( <Form action={createPost}> <input name="title" /> {/* ... */} <button type="submit">Create Post</button> </Form> ) } |
1 2 3 4 5 6 7 8 9 10 11 |
// /app/posts/actions.ts 'use server' import { redirect } from 'next/navigation' export async function createPost(formData: FormData) { // Create a new post // ... // Redirect to the new post redirect(`/posts/${data.id}`) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// /app/posts/[id]/page.tsx import { getPost } from '@/posts/data' export default async function PostPage({ params, }: { params: Promise<{ id: string }> }) { const data = await getPost((await params).id) return ( <div> <h1>{data.title}</h1> {/* ... */} </div> ) } |
위 흐름처럼 createPost 에서 데이터를 추가한 후 redirect를 실행해서 새로 추가한 게시물 페이지로 이동하도록 구성할 수 있습니다.
기존의 서버측 렌더링에서 서버는 콘텐츠를 렌더링하기 전에 요청을 기다리는데, 실제로 렌더링에 필요한 모든 요소가 요청별 데이터에 의존하지는 않기 때문에, 이상적으로는 서버의 응답이 오기전에 최대한 많은 것을 준비하여 최적화할 수 있습니다.
그래서 요청별 데이터에 의존하는 headers, cookies, params, searchParams 와 같은 API를 비동기적으로 전환하고 있습니다. 아래는 cookies를 비동기적으로 전환한 예시입니다.
1 2 3 4 5 6 7 8 |
import { cookies } from 'next/headers'; export async function AdminPanel() { const cookieStore = await cookies(); const token = cookieStore.get('token'); // ... } |
– Turbopack 성능 향상 및 안정화
– next.config.ts 지원 (TypeScript 파일 유형 지원)
– ESLint 9 지원
– 개발 및 빌드 개선
실험적이거나 간단하게 미리 소개하는 내용 등.. 포함하지 않은 내용들이 있는데, 이 부분들은 천천히 공부해서 추가하도록 하겠습니다!!