app router 톺아보기
사람들이 page router를 버리고 app router로 갈아타는 데에는 수많은 이유가 있겠지만, 그 중 큰 비중을 차지하는 것이 바로 app router만의 routing 전략이지 싶다(하지만 나는 Route Groups나 Private Folders 같은 기능을 더 높게 친다). 실제로 주변 사람들에게 물어봤을 때 app router를 사용하는 주된 이유로 중첩 layout과 함께 고급 routing 전략을 꼽는 사람이 다수였다. Next.js 15 버전이 정식 출시된 이후로 app router도 ─ 물론 아직 버그가 한참 있기는 하지만 ─ 나름 쓸만해진 것 같아 page router와의 차이점을 비교해보고 각각의 라우트 전략도 간단히 살펴보고자 한다.
URI 관련
페이지 라우터에서는 useRouter 한 곳에서 처리되던 기능들이 앱 라우터에서는 useRouter, useParams, useSearchParams, usePathname 등으로 세분화되었다. 특정 서버 컴포넌트에서는 params와 searchParams를 props를 통해 직접 조회할 수 있지만, pathname은 간단하게 접근할 수 있는 방법이 없다. 이는 서버 컴포넌트가 라우팅 정보에 직접 의존하지 않도록 하여 렌더링 최적화를 유도하려는 의도이며, 따라서 라우팅 정보를 다루는 방식에 있어 클라이언트와 서버 컴포넌트 간의 역할을 명확히 구분하는 것이 중요하다.
export default async function Home({
params,
searchParams
}: {
params: Promise<{ provider: string }>,
searchParams: Promise<{ [key: string]: string | string[] }>
}): Promise<JSX.Element>
getServerSideProps에서 처리하던 리디렉션이 redirect 함수 하나로 간단해진 것도 만족스럽다. 다만 이 함수는 서버 컴포넌트나 서버 액션 등에서만 유효하고, 클라이언트에서는 여전히 useRouter를 사용해야 하는 점은 조금 아쉽다. notFound 함수는 /404 페이지로 이동하는 대신 해당 세그먼트에서 가장 가까운 not-found.jsx를 호출한다.
loading & error.tsx
loading.tsx가 존재하는 경우, Next.js는 해당 라우트에 대해 자동으로 React.Suspense를 적용하며, 이때 fallback으로 loading.tsx를 사용한다. 이를 통해 페이지나 특정 컴포넌트가 비동기로 로딩되는 동안 사용자에게 로딩 UI를 자연스럽게 보여줄 수 있다. 별도의 Suspense 설정 없이도 라우트 단위로 손쉽게 로딩 상태를 처리할 수 있기 때문에, 사용자 경험을 개선하는 데 유용하다.
loading.tsx를 사용하는 경우에도, 개발자가 특정 컴포넌트에 대해 직접 React.Suspense를 사용할 수 있다. 이 경우 Next.js는 라우트 전환 시 loading.tsx를 전역적인 fallback으로 활용하고, 동시에 페이지 내부에서 설정한 Suspense는 해당 컴포넌트 단위의 로딩 UI를 개별적으로 처리한다. 따라서 loading.tsx와 직접 작성한 Suspense는 서로 독립적으로 동작하며, 비동기 컴포넌트가 로딩될 때 어느 쪽이 먼저 또는 별도로 fallback을 표시할지는 컴포넌트의 구조와 로딩 타이밍에 따라 달라진다. 이를 통해 전체 페이지의 로딩 흐름과 개별 컴포넌트의 로딩 상태를 유연하게 분리하여 처리할 수 있다.
error.tsx 파일은 특정 라우트에서 예외가 발생했을 때 자동으로 렌더링되며, 사용자는 별도의 ErrorBoundary를 작성하지 않아도 기본적인 에러 처리 UI를 구성할 수 있다. error.tsx는 서버/클라이언트 모두에서 발생하는 오류를 포착하며, 라우트의 layout.tsx나 page.tsx에서 에러가 발생한 경우 해당 라우트의 error.tsx가 fallback 컴포넌트로 작동한다.
한편, 더 세밀한 에러 제어가 필요할 경우 React의 ErrorBoundary를 직접 사용할 수 있다. ErrorBoundary는 클라이언트 사이드에서 발생한 렌더링 오류만 포착하며, 특정 컴포넌트 단위로 오류 처리를 나눌 수 있다. 예를 들어, 네트워크 요청 결과에 따라 실패할 가능성이 있는 특정 UI 컴포넌트만 별도로 감싸고, 그 안에서 발생한 오류에 대해서만 fallback UI를 보여주는 식이다. 이처럼 ErrorBoundary를 활용하면 오류에 대한 사용자 경험을 좀 더 세밀하게 제어할 수 있다.
error.tsx에서 받는 reset 함수는 클라이언트에서만 작동하며, 클라이언트 상태만 리셋한다. 서버에서 발생한 오류 자체를 리셋하지는 않는다. 따라서 서버까지 리셋하고 싶은 경우 reset 함수와 함께 useRouter를 사용하여 router.refresh를 호출해주어야 한다.
라우트 세그먼트 옵션 관련
라우트 세그먼트 옵션을 사용하면 다음의 변수를 export const 형태로 직접 내보내어 페이지, 레이아웃 또는 라우트 핸들러의 동작을 구성할 수 있다.
옵션 | 설명 |
experimental_ppr | 실험적인 Partial Prerendering 기능을 활성화. 아직 안정 버전 아님. |
dynamic | 페이지의 정적/동적 처리 방식을 명시. - force-dynamic: 항상 서버에서 동적으로 렌더링 - force-static: 항상 정적으로 빌드 - error: 동적 요청 발생 시 에러 - auto: 사용 방식에 따라 Next.js가 자동 판단(기본값) |
dynamicParams | 동적 세그먼트에 전달되는 파라미터를 빌드 시점에 고정할지 여부. false이면 모든 가능성은 generateStaticParams로 명시 필요. 기본값은 true |
revalidate | 정적 페이지의 ISR(Incremental Static Regeneration) 주기 설정. - false: 재검증 없음 (기본값, 완전 정적) - 0: 항상 재검증 (동적) - 숫자: n초마다 백그라운드에서 새로 빌드 |
fetchCache | fetch() 호출 시 캐싱 동작 설정 - force-cache: 항상 캐시 - force-no-store: 항상 생 데이터 - auto: fetch 시점과 context로 자동 판별(기본값) |
runtime | 이 페이지를 실행할 런타임 환경. - edge: Edge Function으로 실행 - nodejs: Node.js 서버에서 실행 (기본값) |
preferredRegion | Vercel 등에서 요청을 처리할 지역을 지정. 기본값은 'auto' 예: "arn1" (서울), "lhr1" (런던 등). |
maxDuration | 서버 함수가 최대 몇 초까지 실행될 수 있는지 제한. Edge/Serverless 플랫폼에서 중요함. |
캐싱 관련
데이터 캐시 & 리퀘스트 메모이제이션
둘 다 백엔드 요청에 대한 결과를 캐시하지만, 맡은 역할에 대해서는 다소 차이가 있다. 우선 데이터 캐시의 경우 redis와 유사해서, 동일한 GET 요청에 대해 캐시 설정에 따라 백엔드에 새로 요청을 보내는 대신 캐시된 값을 리턴한다. 이때 어디서부터 어디까지를 '동일'하다고 판단하는지 확실하지 않으나, fetch에 제공되는 url과 옵션 객체의 내용이 '직렬화 후 동일하면 동일하다'고 판단하는 듯하다.
// 기본값은 auto no cache
fetch(URL)
type RequestCache = "default" | "force-cache" | "no-cache" | "no-store" | "only-if-cached" | "reload";
// 명시적으로 캐시 여부를 지정할 수도 있다
fetch(URL, { cache: RequestCache })
// 여러 옵션을 동시에 적용할 수도 있다
fetch(URL, {
next: { revalidate: 30, tags: ["tag"] },
cache: "force-cache",
})
// cache 옵션이 next 옵션보다 적용 우선순위가 높다
fetch(URL, {
next: { revalidate: 30, tags: ["tag"] },
cache: "no-cache",
})
리퀘스트 메모이제이션은 렌더링 과정 중에 동일한 URL과 옵션으로 여러 번 호출되는 fetch 요청을 최적화하는 기능이다. 한 페이지 렌더링 과정에서 같은 데이터를 여러 컴포넌트에서 요청할 때, 실제로 네트워크 요청은 한 번만 발생하고 그 결과를 재사용하는 것이다. 따라서 cache: "no-cache" 옵션을 주었다 해도 리퀘스트 메모이제이션은 여전히 작동한다.
풀 라우터 캐시 & 클라이언트 라우터 캐시
캐시되지 않는 Data Fetching을 사용하거나, 쿠키∙헤더∙쿼리스트링을 사용하는 컴포넌트가 존재할 경우 Nextjs는 해당 페이지를 Dynamic Page로 인식한다. 그 외의 경우는 모두 Static Page로 간주되며, 풀 라우트 캐시에 의해 해당 페이지의 랜더링 결과가 저장되고 재사용된다. 즉, 정적 페이지는 첫 요청 시에만 서버에서 생성되며, 이후 요청에서는 캐시된 HTML이 반환되므로 성능이 뛰어나고, 서버 부하가 줄어든다. 반면, 동적 페이지는 요청마다 실행되어야 하므로 사용자 맞춤형 콘텐츠 제공에는 유리하지만, 캐시 이점을 활용하지 못한다.
데이터가 캐시되더라도 revalidate나 tags 등을 통해 정적 페이지를 갱신하거나 무효화할 수 있다. 이를 통해 Next.js는 정적 캐싱의 성능과 동적 데이터의 신선함을 모두 만족시키는 하이브리드 렌더링 전략을 구현할 수 있다.
아래와 같이 generateStaticParams 함수를 사용하면 Dynamic Page도 풀 라우트 캐시에 의해 사전에 생성된 정적 페이지로 간주되어 캐싱될 수 있다. 이 방식은 동적 세그먼트를 포함한 경로라 하더라도, 미리 가능한 값들을 정의해두면 Next.js가 빌드 타임에 해당 경로들을 정적으로 생성해주기 때문에, 실제 서비스 시 정적 페이지와 동일하게 빠른 응답성과 낮은 서버 부하를 제공할 수 있다. 결과적으로 사용자는 동적 경로에서도 정적 렌더링의 성능 이점을 누릴 수 있게 된다.
import { z } from 'zod';
const IdsSchema = z.object({
ids: z.array(z.string())
});
export async function generateStaticParams() {
// 백엔드 API에서 ID 배열 조회
const res = await fetch('http://localhost:3000/api/article');
if (!res.ok) {
// 에러 처리 - 빈 배열 반환하거나 기본값 사용
console.error('ID 목록을 가져오는데 실패했습니다');
return [];
}
const rawData = await res.json();
const { ids } = IdsSchema.parse(rawData);
// ID 배열을 매개변수 객체 배열로 변환
return ids.map(id => ({ id }));
}
클라이언트 라우터 캐시는 브라우저 측에 저장되는 캐시로, Next.js에서 페이지 이동을 보다 효율적으로 처리하기 위해 일부 데이터를 보관하는 기술이다. 이 캐시는 한 번 접속한 페이지의 공통 레이아웃 컴포넌트를 브라우저에 저장하여, 페이지 이동 시 동일한 레이아웃 데이터를 다시 서버로부터 요청하지 않도록 최적화한다.
서버 액션 관련
리액트에서는 여러 이유로 인해 '서버 함수(Server Functions)'라는 이름으로 개념이 변경되었지만, Next.js에서는 여전히 '서버 액션(Server Actions)'이라는 이름을 사용하고 있다. 언제 이 명칭이 변경될지는 알 수 없으나, 현재까지는 Next.js 문서와 코드 전반에서 '서버 액션'이라는 용어가 유지되고 있다. 이름만 보면 <form action=...> 형태로만 사용 가능한 것처럼 보일 수 있지만, 실제로는 서버 액션은 폼 액션 외에도 useActionState 등을 통해 자유롭게 호출할 수 있다. 즉, 서버 액션은 폼 제출에만 국한되지 않고, 클라이언트 컴포넌트에서 비동기 요청을 수행하는 일반적인 수단으로도 활용할 수 있는 범용적인 기능이다.
useActionState
useActionState는 기존의 폼 액션 함수와 초기 State를 전달받고, 폼에서 사용할 새로운 액션을 반환한다. 또한 최신 폼 State와 액션이 대기 중인지 여부(isPending)도 반환한다. 이때 최신 폼 State는 useActionState에 전달한 함수에도 함께 전달된다.
'use server'
export async function greetUser(prevState: string | null, formData: FormData) {
const name = formData.get('name') as string
if (!name) {
throw new Error('이름을 입력해주세요.')
}
return `안녕하세요, ${name}님!`
}
'use client'
import { useActionState } from 'react'
import { greetUser } from './actions'
export default function GreetingForm() {
const [message, formAction, isPending] = useActionState(greetUser, null)
return (
<form action={formAction}>
<input type="text" name="name" placeholder="이름 입력" />
<button type="submit" disabled={isPending}>
{isPending ? '처리 중...' : '인사받기'}
</button>
{message && <p>{message}</p>}
</form>
)
}
import GreetingForm from './Form'
export default function Page() {
return (
<main className="p-4">
<h1>서버 액션 인사 예제</h1>
<GreetingForm />
</main>
)
}
useOptimistic
useOptimistic은 React 18에서 도입된 훅으로, 서버 응답을 기다리지 않고 사용자 입력에 기반한 예상 결과를 UI에 즉시 반영할 수 있도록 도와준다. 이 훅은 현재 상태와 업데이트 함수를 반환하며, 사용자가 어떤 동작을 했을 때 addOptimistic을 호출하면 낙관적인 상태를 계산하여 즉시 화면에 반영하고, 실제 서버 응답이 도착하면 최종 상태로 덮어쓴다. 특히 서버 액션(useActionState)과 함께 사용할 때 사용자 경험을 자연스럽고 빠르게 만들어주는 데 유용하다.
'use client'
import { useActionState, useOptimistic } from 'react'
import { greetUser } from './actions'
export default function GreetingForm() {
const [message, formAction, isPending] = useActionState(greetUser, null)
const [optimisticMessage, addOptimisticMessage] = useOptimistic(
message,
(currentMessage, formData: FormData) => {
const name = formData.get('name')?.toString() || ''
return name ? `(예상) 안녕하세요, ${name}님!` : currentMessage
}
)
return (
<form
action={async (formData) => {
addOptimisticMessage(formData) // 낙관적 메시지 먼저 보여줌
await formAction(formData) // 실제 서버 액션 실행
}}
>
<input type="text" name="name" placeholder="이름 입력" />
<button type="submit" disabled={isPending}>
{isPending ? '처리 중...' : '인사받기'}
</button>
{optimisticMessage && <p>{optimisticMessage}</p>}
</form>
)
}
revalidatePath
revalidatePath와 revalidateTags는 모두 서버 캐시 무효화를 위해 Next.js에서 제공하는 함수로, 엄밀히 말하면 서버 액션에만 국한되지 않는다. 하지만 이 함수들은 서버 전용 API이며, 클라이언트 컴포넌트나 클라이언트 훅에서는 직접 호출할 수 없고, 반드시 서버 환경에서 실행되어야 한다. 즉, 서버 액션뿐 아니라 서버 컴포넌트나 서버에서 실행되는 함수 내에서도 사용할 수 있지만, 클라이언트 측에서는 사용할 수 없다는 점을 유념해야 한다.
이 두 함수는 각각 특정 경로(path)나 태그(tag)를 기준으로 캐시된 데이터를 무효화하며, 데이터 변경 후 최신 상태를 반영하기 위해 자주 활용된다. revalidatePath는 지정한 경로 전체를 재검증하는 반면, revalidateTags는 여러 경로나 컴포넌트에서 공유하는 공통 데이터를 태그 단위로 무효화할 때 유용하다.
고급 라우트 관련
Parallel Routes
패럴렐 라우트 혹은 병렬 라우트를 사용하면 하나의 layout.tsx에서 하나 이상의 page.tsx를 동시에 ─ 혹은 조건부로 ─ 랜더링할 수 있다. Next.js는 이 기능을 대시보드나 게시물 피드 같이 동적인 섹션에서 사용하기를 권장하고 있다. 나는 같은 페이지에 존재해야되지만, 조금 결이 다른 컴포넌트를 랜더링해야할 때 패럴렐 라우트를 사용하는 편이다.
패럴렐 라우트는 @를 통해 명명된 파일 구조인 '슬롯'을 통해 구현한다. 아래의 이미지는 @team 슬롯과 @analytics 슬롯을 사용해 Layout.tsx에 세 개의 page.tsx를 병렬적으로 랜더링하는 모습이다. 슬롯은 부모 layout.tsx에 props로 전달되기 때문에 이를 사용하여 처리해주면 된다.
참고해야할 점은 슬롯 자체는 경로 세그먼트가 아니기에 URL 구조에 영항을 미치지 않는다는 사실이다. 또한 슬롯에는 ─ 일반적인 라우팅과 동일하게 ─ 동적 슬롯과 정적 슬롯이 존재하며, 동적 슬롯이 하나 이상 존재한다면 해당 경로의 모든 슬롯은 동적이어야 한다는 제한이 있다.
페이지를 탐색하는 방식에 따라 Next.js는 슬롯 내에서 랜더링되는 콘텐츠 유형을 다르게 가져간다. Link 컴포넌트나 router 객체의 push 메소드를 사용하는 '소프트 네비게이션'의 경우 Next.js는 부분 랜더링을 수행하여 URL과 일치하지 않더라도, 이전의 슬롯 상태를 유지하며 콘텐츠를 제공한다. 이는 '/' 페이지에서 '/dashboard' 페이지로 이동할 때, 슬롯 내에 해당하는 하위 page.tsx가 존재하지 않더라도 이전의 슬롯 상태를 계속 유지한다는 의미이다.
하지만 페이지 전체를 새로 고침해버리는 '하드 네비게이션'의 경우 Next.js는 현재 URL과 일치하지 않는 슬롯의 활성 상태를 결정할 수 없다. 따라서 default.tsx가 존재하지 않는 이상 404 페이지를 랜더링하게 된다. default.tsx는 위와 같은 상황에서 특정 슬롯의 백업 콘텐츠를 랜더링하는 역할을 한다. 특정 경로가 제대로 렌더링되지 않거나 부모 페이지 상태를 복원할 수 없을 때 페이지 전체를 faliure 시키는 대신, 해당 슬롯의 백업 콘텐츠를 렌더링하여 사용자 경험을 보호하는 역할을 한다.
특정 라우트의 기본 페이지가 되는 page.tsx 역시 Next.js는 @children으로 처리한다. 이를 공식 문서에서는 암시적 슬롯(implicit slot)이라고 한다. 이로 인해 Layout.tsx의 children props로 page.tsx의 내용이 들어오게 되는 것이다.
Intercepting Routes
인터셉팅 라우트 ─ 나는 긴빠이 라우트라고 부르기는 하는데 ─ 는 소프트 탐색 중에 특정 라우트에 대한 요청을 가로채어, 해당 라우트가 처리되는 방식을 제어한다. 패럴렐 라우트와 마찬가지로 소프트/하드 네비게이션에서 동작하는 방식이 다르기에 유저의 엔트리 포인트에 따라 다른 방식으로 페이지를 제공할 수 있다는 장점이 있다.
가로채고자 하는 라우트를 파일 시스템의 상대 경로와 비슷하게 (..) 와 같은 방법으로 표시하게 되는데, 주의해야할 점은 파일 시스템이 아니라 세그먼트에 대한 상대 경로로 작성해야 한다는 점이다. 따라서 2레벨 위의 세그먼트를 가로채기 위해서는 폴더 이름 앞에 (..)(..)를 붙여주어야 한다.
Parallel & Intercepting Routes
하지만 일반적으로 인터셉팅 라우트는 페럴렐 라우트와 '함께' 사용하여 딥 링크 ─ 특정 페이지나 상태로 바로 접근할 수 있는 링크 ─ 를 지원하는 모달을 만들 수 있다. 이를 통해 URL을 통해 모달 콘텐츠를 공유할 수 있고, 모달의 열림 상태를 state가 아니라 URL의 세그먼트로 관리하게 된다.
모달로 띄우고 싶은 라우트 전체를 @modal 슬롯 내부에서 처리하고, 평소에는 아무런 내용도 없는 default.tsx를 랜더링시킨 다음 필요한 라우트만 인터셉트 하는 방식이다. 만약 /report/[id] 라우트의 내용을 모달로 띄우고 싶다고 하면, 패럴랠 & 인터셉트 라우트의 형태는 @modal/(.)report/[id]와 같을 것이다.
// src/@modal/default.tsx
export default function Default() {
return null
}
// src/layout.tsx
export default function RootLayout({
children,
modal
}: Readonly<{
modal: ReactNode;
children: ReactNode;
}>) {
return (
<html lang="ko">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
{modal}
</body>
</html>
);
}
Next.js의 모든 page.tsx는 컴포넌트를 default export 하기 때문에 어디서든 import 해올 수 있다. 덕분에 이렇게 인터셉팅 라우트에서 새로 컴포넌트를 꾸리는 것이 아니라 원래 라우트의 Page 컴포넌트를 가져와 간단히 재사용할 수 있다.
// src/@modal/(.)report/[id]/page.tsx
import ReportPage from "@/report/[id]/page"
export default function Page() {
return (
<Modal>
<ReportPage />
</Modal>
);
}
결론
app router를 처음 접하는 사람들은 어째서인지 '모달을 구현하는 데 있어 반드시 패럴랠 라우터와 인터셉팅 라우터를 사용해야 한다'는 이상한 압박감을 느끼는 듯하다. 사실, 이 두 기능은 매우 유용하지만, 그 자체로 모든 문제를 해결하는 만능 열쇠는 아니다. 패럴랠 라우팅과 인터셉팅 라우팅은 복잡한 사용자 경험을 더 잘 관리할 수 있도록 돕는 도구일 뿐, 모달 구현을 위해서만 존재하는 것은 아니다.
내가 생각하기에 이 두 라우터를 사용해 모달을 구현하기에 적합한 경우는 ─ 인스타그램에서 게시물을 조회하면 모달이 뜨는 것과 같이 ─
사용자가 특정한 라우트를 소프트 네비게이팅으로 접근할 때와 하드 네비게이팅으로 접근할 때 서로 다른 컨텐츠를 랜더링해야하는 경우 말고는 없지 않나 싶다.
블로그의 정보
Ayden's journal
Beard Weard Ayden