병렬·인터셉트 라우트와 미들웨어로 복잡한 UX를 설계한다.
고급 라우팅 패턴: 병렬·인터셉트·미들웨어
Next.js App Router가 제공하는 파일 기반 라우팅은 단순한 페이지 매핑을 훨씬 넘어섭니다. 병렬 라우트(Parallel Routes)와 인터셉팅 라우트(Intercepting Routes)를 조합하면 Instagram 스타일의 모달 갤러리나 복잡한 대시보드 레이아웃을 URL 공유까지 지원하면서 구현할 수 있습니다. 그 위에 미들웨어(Middleware)를 더하면 인증, 지역화, A/B 테스트까지 라우팅 레이어에서 일관되게 처리할 수 있습니다.
입문편 2강에서 파일 기반 라우팅과 동적 세그먼트([slug], [...rest])의 기초를 익혔다면, 이 레슨에서는 그 위에 쌓을 수 있는 고급 프리미티브와 실전 조합 패턴을 다룹니다. 특히 각 기능이 "왜" 이런 파일명 규칙을 갖는지, 실수하기 쉬운 함정은 무엇인지에 집중합니다.
학습 목표
- Parallel Routes(
@slot) 를 사용해 동일한 레이아웃 안에서 독립적으로 로딩되는 영역을 구성할 수 있다. - Intercepting Routes(
(.),(..),(...)) 로 현재 컨텍스트를 유지하면서 다른 경로를 모달로 렌더링하고, URL까지 공유 가능하게 만들 수 있다. default.tsx폴백 처리와 Route Groups 조합 패턴을 이해하고 슬롯 간 동기화 문제를 해결할 수 있다.- Middleware로 인증 가드, 지역화, A/B 테스트, 리라이트/리다이렉트를 Edge Runtime에서 처리할 수 있다.
- 매처(matcher) 설정과 Edge Runtime 제약을 숙지하여 미들웨어 성능을 튜닝할 수 있다.
Parallel Routes로 독립 로딩 영역 구성하기
슬롯(Slot)의 개념
Parallel Routes는 동일한 URL에서 하나의 레이아웃이 여러 "슬롯"을 동시에 렌더링할 수 있게 해 줍니다. 슬롯은 @이름 형태의 디렉터리로 선언합니다. 슬롯 디렉터리는 URL 세그먼트에 영향을 주지 않습니다.
app/
dashboard/
layout.tsx ← @team, @analytics 슬롯을 props로 받음
page.tsx
@team/
page.tsx
@analytics/
page.tsx
layout.tsx는 각 슬롯을 props로 받아 배치합니다.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="dashboard-grid">
<main>{children}</main>
<aside className="team-panel">{team}</aside>
<section className="analytics-panel">{analytics}</section>
</div>
);
}
각 슬롯은 완전히 독립적인 Suspense 경계를 가집니다. @analytics가 느린 데이터를 패칭하는 동안 @team은 이미 화면에 표시될 수 있습니다. 이 독립성이 Parallel Routes의 핵심 가치입니다.
슬롯 내 중첩 라우팅
슬롯 안에도 동적 세그먼트와 중첩 경로를 만들 수 있습니다.
app/
dashboard/
@team/
page.tsx ← /dashboard
[memberId]/
page.tsx ← /dashboard (슬롯 내부 전환)
@team 슬롯에서 특정 멤버를 선택하면 /dashboard URL은 유지되면서 @team 슬롯만 해당 멤버 상세 뷰로 교체됩니다. URL 오염 없이 복잡한 상태를 표현하는 강력한 패턴입니다.
⚠️ 주의 슬롯 간 탐색 상태는 클라이언트 사이드 탐색에서만 독립적으로 유지됩니다. 페이지를 하드 리프레시하면 Next.js는 활성 슬롯 상태를 알 수 없으므로
default.tsx를 렌더링하려 합니다.default.tsx가 없으면 404가 발생합니다.
default.tsx — 폴백 처리
초기 로드나 하드 리프레시 시 매칭되는 슬롯 경로가 없을 때 렌더링되는 파일입니다.
// app/dashboard/@team/default.tsx
export default function TeamDefault() {
// 팀 목록 기본 뷰 또는 null 반환
return <TeamList />;
}
// app/dashboard/@analytics/default.tsx
// 아무것도 렌더링하지 않아도 됨 — null은 허용
export default function AnalyticsDefault() {
return null;
}
💡 TIP 슬롯을 사용하는 레이아웃에
@slot/default.tsx가 없으면 하드 리프레시 시 전체 페이지가 404로 처리됩니다. 항상default.tsx를 작성하는 습관을 들이세요.
Intercepting Routes로 모달·라이트박스 UX 구현하기
인터셉팅 라우트의 동작 원리
Intercepting Routes는 클라이언트 측 탐색 시에만 다른 경로의 페이지를 "가로채서" 현재 레이아웃 안에서 렌더링합니다. 직접 URL을 입력하거나 새 탭으로 열면 원래 경로의 페이지가 그대로 렌더링됩니다. 이 이중 동작 덕분에 URL 공유가 가능한 모달이 만들어집니다.
인터셉팅 접두사는 상대적 깊이를 나타냅니다.
| 접두사 | 의미 |
|---|---|
(.) | 같은 레벨 세그먼트 |
(..) | 한 단계 위 세그먼트 |
(..)(..) | 두 단계 위 세그먼트 |
(...) | 루트(app/)에서의 세그먼트 |
Instagram 스타일 사진 모달 구현
app/
feed/
page.tsx ← 피드 목록
@modal/
(..)photos/
[id]/
page.tsx ← 피드에서 사진 클릭 시 모달로 렌더링
default.tsx ← null 반환
layout.tsx ← @modal 슬롯 포함
photos/
[id]/
page.tsx ← 직접 접근 시 전체 페이지
// app/feed/layout.tsx
export default function FeedLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
// app/feed/@modal/default.tsx
export default function ModalDefault() {
return null; // 모달이 없을 때는 아무것도 렌더링하지 않음
}
// app/feed/@modal/(..)photos/[id]/page.tsx
import { PhotoModal } from "@/components/PhotoModal";
export default async function InterceptedPhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await fetchPhoto(id);
return <PhotoModal photo={photo} />;
}
// app/photos/[id]/page.tsx — 직접 접근 또는 새 탭
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await fetchPhoto(id);
return (
<div className="photo-fullpage">
<img src={photo.url} alt={photo.title} />
<p>{photo.description}</p>
</div>
);
}
모달 컴포넌트에서는 useRouter().back()으로 모달을 닫고 피드로 돌아갑니다.
// components/PhotoModal.tsx
"use client";
import { useRouter } from "next/navigation";
export function PhotoModal({ photo }: { photo: Photo }) {
const router = useRouter();
return (
<div
className="modal-backdrop"
onClick={() => router.back()} // ✅ 히스토리 기반으로 닫기
>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<img src={photo.url} alt={photo.title} />
<button onClick={() => router.back()}>닫기</button>
</div>
</div>
);
}
💡 TIP 모달 닫기에
router.push('/')같은 하드코딩된 경로 대신router.back()을 사용하면 탐색 히스토리를 보존하면서 자연스럽게 이전 상태로 돌아갑니다.
Parallel + Intercepting 조합 패턴
Parallel Routes의 @modal 슬롯과 Intercepting Routes를 함께 쓰는 패턴이 Next.js 공식 문서(nextgram 예제)에서 권장되는 구조입니다. 이 조합에서 주의할 점은 인터셉팅 접두사의 깊이가 파일시스템 구조가 아니라 라우트 세그먼트 구조를 기준으로 한다는 것입니다.
// ❌ 잘못된 이해 — 파일 경로 깊이로 계산
app/feed/@modal/(.)photos → feed 아래 photos 인터셉트? (틀림)
// ✅ 올바른 이해 — 라우트 세그먼트 기준
// @modal 슬롯은 /feed 세그먼트 내부에 위치하지만
// 슬롯 자체는 라우트 세그먼트로 세지 않습니다.
// 형제 세그먼트 /photos를 인터셉트하려면 한 단계 위로 올라가야 하므로
// (파일시스템상으로는 두 단계 위지만 슬롯은 세지 않으므로 한 단계)
// (..)photos 를 사용합니다.
// (.)는 /feed 자신의 자식 레벨을 의미하므로 의도한 /photos를 가로채지 못합니다.
Route Groups와 동적 세그먼트 조합
Route Groups((groupName))는 URL에 영향을 주지 않고 파일을 폴더로 그룹화하며, 그룹마다 별도의 레이아웃을 적용할 수 있습니다. 고급 패턴에서 이 기능은 인증 레이아웃 분리, 역할별 레이아웃 분리에 특히 유용합니다.
app/
(auth)/
layout.tsx ← 인증 전용 레이아웃 (헤더/사이드바 없음)
login/
page.tsx → /login
signup/
page.tsx → /signup
(app)/
layout.tsx ← 앱 레이아웃 (헤더/사이드바 있음)
dashboard/
page.tsx → /dashboard
settings/
page.tsx → /settings
동적 세그먼트와 Parallel Routes를 함께 사용할 때는 각 슬롯에 동일한 동적 세그먼트 구조가 있어야 Next.js가 올바르게 매칭합니다.
app/
users/
[userId]/
layout.tsx
@profile/
page.tsx
[userId]/ // ❌ 불필요 — 부모 세그먼트의 params를 상속
@activity/
page.tsx
// app/users/[userId]/layout.tsx
export default function UserLayout({
params,
profile,
activity,
}: {
params: { userId: string };
profile: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div>
<h1>사용자: {params.userId}</h1>
<div className="user-grid">
{profile}
{activity}
</div>
</div>
);
}
슬롯 내 페이지에서도 부모의 params는 동일하게 접근 가능합니다.
// app/users/[userId]/@profile/page.tsx
export default async function ProfileSlot({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const { userId } = await params;
const profile = await fetchUserProfile(userId);
return <ProfileCard profile={profile} />;
}
Middleware로 인증·지역화·A/B 분기 처리하기
미들웨어 기본 구조
middleware.ts는 app/ 디렉터리가 아닌 프로젝트 루트(또는 src/)에 위치합니다. 모든 요청이 페이지나 API에 도달하기 전에 이 파일을 통과합니다.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 인증 토큰 확인
const token = request.cookies.get("auth-token")?.value;
if (pathname.startsWith("/dashboard") && !token) {
// ✅ 리다이렉트 — 현재 경로를 redirect 파라미터로 보존
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/api/protected/:path*"],
};
매처(matcher) 성능 튜닝
매처를 정확히 지정하지 않으면 미들웨어는 정적 파일(_next/static, 이미지 등)까지 포함하여 모든 요청에서 실행됩니다. Edge Runtime에서 실행되므로 지연은 작지만 불필요한 실행은 비용과 지연을 높입니다.
// ❌ 지나치게 광범위한 매처
export const config = {
matcher: "/:path*",
};
// ✅ 필요한 경로만 명시
export const config = {
matcher: [
/*
* 다음 경로로 시작하는 요청은 제외:
* - _next/static (정적 파일)
* - _next/image (이미지 최적화)
* - favicon.ico
* - public 폴더 파일
*/
"/((?!_next/static|_next/image|favicon.ico|public/).*)",
],
};
매처는 정규표현식도 지원합니다. 단, Edge Runtime에서 lookbehind(후방탐색) 같은 일부 정규식 기능은 지원되지 않습니다.
지역화(i18n) 처리
쿠키나 Accept-Language 헤더를 기반으로 요청을 지역별 경로로 리라이트하는 패턴입니다.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const SUPPORTED_LOCALES = ["ko", "en", "ja"];
const DEFAULT_LOCALE = "ko";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 이미 locale 접두사가 있는 경우 건너뜀
const pathnameHasLocale = SUPPORTED_LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// Accept-Language 헤더에서 locale 감지
const acceptLanguage = request.headers.get("accept-language") ?? "";
const detectedLocale =
SUPPORTED_LOCALES.find((locale) => acceptLanguage.startsWith(locale)) ??
DEFAULT_LOCALE;
// 감지된 locale로 리라이트 (URL 변경 없이 내부 경로 변경)
const rewriteUrl = new URL(`/${detectedLocale}${pathname}`, request.url);
return NextResponse.rewrite(rewriteUrl);
}
⚠️ 주의
NextResponse.rewrite()는 URL을 변경하지 않고 내부적으로 다른 경로를 렌더링합니다. 브라우저 주소창의 URL을 바꾸려면NextResponse.redirect()를 사용하세요. 두 함수의 차이를 혼동하면 SEO나 캐싱에 예기치 않은 문제가 생깁니다.
A/B 테스트 분기
미들웨어에서 쿠키로 실험 그룹을 할당하고 리라이트하는 패턴입니다. 클라이언트 자바스크립트 없이 서버 레벨에서 처리하므로 레이아웃 깜빡임(CLS)이 발생하지 않습니다.
// middleware.ts (A/B 테스트 부분)
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 기존에 할당된 그룹 확인
let bucket = request.cookies.get("ab-bucket")?.value;
if (!bucket) {
// 신규 방문자: 무작위로 그룹 할당
bucket = Math.random() < 0.5 ? "control" : "variant";
response.cookies.set("ab-bucket", bucket, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30일
});
}
const { pathname } = request.nextUrl;
if (pathname === "/pricing" && bucket === "variant") {
// variant 그룹은 새 가격 페이지로 리라이트
return NextResponse.rewrite(new URL("/pricing-v2", request.url), {
headers: response.headers,
});
}
return response;
}
Edge Runtime 제약과 미들웨어 설계 원칙
Edge Runtime에서 사용 불가능한 것들
미들웨어는 기본적으로 Edge Runtime에서 실행됩니다. Edge Runtime은 Node.js 런타임보다 훨씬 가볍고 빠르지만, 사용할 수 있는 API가 제한적입니다.
| 가능 | 불가능 |
|---|---|
| Web Fetch API | Node.js fs, path, crypto 모듈 |
NextRequest / NextResponse | Prisma, Sequelize 등 ORM (TCP 소켓) |
TextEncoder / TextDecoder | Buffer (단, Uint8Array로 대체 가능) |
| Web Crypto API | child_process, worker_threads |
| 경량 JWT 검증 (jose — Web Crypto 기반) | jsonwebtoken (Node crypto/Buffer 의존), 무거운 npm 패키지 |
// ❌ Edge Runtime에서 동작 안 함 — DB 직접 조회
import { prisma } from "@/lib/prisma";
export async function middleware(request: NextRequest) {
const user = await prisma.user.findUnique({ where: { id: "..." } }); // 오류 발생
}
// ✅ Edge Runtime에서 가능한 패턴 — JWT 검증만 수행
import { jwtVerify } from "jose";
export async function middleware(request: NextRequest) {
const token = request.cookies.get("auth-token")?.value;
if (!token) return NextResponse.redirect(new URL("/login", request.url));
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
미들웨어를 Node.js Runtime으로 전환
미들웨어에서 Node.js API가 반드시 필요하다면 런타임을 전환할 수 있습니다. 단, 이 경우 Edge의 지연 이점이 사라집니다.
// middleware.ts
export const runtime = "nodejs"; // Edge 대신 Node.js 런타임 사용
export function middleware(request: NextRequest) {
// Node.js API 사용 가능 (단, 성능 주의)
}
⚠️ 주의 Node.js Runtime으로 전환한 미들웨어는 모든 요청에서 콜드 스타트 오버헤드가 발생할 수 있습니다. 가능하면 JWT나 세션 쿠키 검증처럼 Edge에서 처리 가능한 로직만 미들웨어에 넣고, DB 조회가 필요한 권한 검사는 Server Component나 Route Handler에서 수행하세요.
라우트 레벨 권한 가드와 레이아웃 기반 접근 제어
계층적 권한 제어 패턴
미들웨어는 조악한 접근 제어(인증 여부, 역할 확인)를 담당하고, 세밀한 권한 검사(특정 리소스에 대한 소유권 등)는 Server Component나 레이아웃에서 처리하는 것이 권장 패턴입니다.
미들웨어 → 인증 여부, 역할(role) 쿠키/토큰 기반 거친 분기
layout.tsx → DB 기반 세밀한 권한 확인, 접근 불가 UI 표시
page.tsx → 개별 리소스 소유권 확인
// app/(app)/admin/layout.tsx
import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/auth";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
if (!session) {
redirect("/login");
}
if (session.user.role !== "admin") {
// ✅ 403 대신 권한 없음 UI를 렌더링하거나 리다이렉트
redirect("/dashboard?error=unauthorized");
}
return (
<div className="admin-layout">
<AdminSidebar />
<main>{children}</main>
</div>
);
}
레이아웃에서 redirect()를 호출하면 해당 레이아웃 하위의 모든 페이지에 동일한 가드가 적용됩니다. 각 페이지에 반복 작성할 필요가 없어 유지보수성이 높아집니다.
중첩 레이아웃과 슬롯의 권한 처리
Parallel Routes를 사용할 때는 각 슬롯에도 독립적으로 권한 검사가 필요할 수 있습니다. 슬롯의 page.tsx는 부모 레이아웃의 가드를 통과하더라도 별도로 권한을 확인해야 하는 경우가 있습니다.
// app/dashboard/@analytics/page.tsx
import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/auth";
export default async function AnalyticsSlot() {
const session = await getServerSession();
// analytics 슬롯은 pro 플랜 이상에서만 접근 가능
if (!session?.user.plan || session.user.plan === "free") {
// 슬롯에서의 리다이렉트는 전체 페이지를 리다이렉트하지 않음
// 대신 업그레이드 유도 UI를 반환
return (
<div className="upgrade-prompt">
<p>분석 기능은 Pro 플랜에서 이용 가능합니다.</p>
<a href="/upgrade">업그레이드</a>
</div>
);
}
const analytics = await fetchAnalytics(session.user.id);
return <AnalyticsDashboard data={analytics} />;
}
💡 TIP 슬롯(
@slot) 내 컴포넌트에서redirect()를 호출하면 해당 슬롯뿐만 아니라 전체 페이지가 리다이렉트됩니다. 슬롯 내 접근 제어는 리다이렉트 대신 대체 UI를 반환하는 방식으로 처리하세요.
요약
- Parallel Routes(
@slot)는 동일한 URL에서 독립적으로 로딩·탐색되는 영역을 구성하며, 각 슬롯은 별도의 Suspense 경계를 가집니다. 하드 리프레시 대응을 위해 반드시default.tsx를 작성해야 합니다. - Intercepting Routes(
(.),(..),(...))는 클라이언트 탐색 시에는 모달로, 직접 URL 접근 시에는 전체 페이지로 동작하는 이중 렌더링을 구현합니다.@modal슬롯과 조합하는 것이 표준 패턴입니다. - 인터셉팅 접두사의 깊이는 파일시스템 경로가 아닌 라우트 세그먼트 기준이며,
@slot디렉터리는 세그먼트로 세지 않습니다.@modal슬롯에서 형제 세그먼트를 인터셉트할 때는 한 단계 위인(..)를 사용합니다(예:@modal/(..)photos). - Middleware는 Edge Runtime에서 실행되므로 Node.js API(ORM,
fs등)를 사용할 수 없습니다. JWT/쿠키 기반의 가벼운 인증 검사와 리다이렉트/리라이트에 집중해야 합니다. - 매처(matcher) 를 정확히 지정해 정적 파일에는 미들웨어가 실행되지 않도록 하고, 세밀한 권한 검사는
layout.tsx의 Server Component에서 처리하는 계층적 설계를 따르세요.
연습문제
/photos갤러리 페이지가 있고, 갤러리에서 사진을 클릭하면 현재 갤러리 목록을 유지한 채 모달로 사진이 열리며, 해당 모달의 URL(/photos/[id])을 복사해 새 탭에서 열면 전체 화면으로 보이는 UX를 구현하려 합니다. 어떤 파일 구조와 Next.js 기능을 사용해야 하는지 설명하고, 필요한 핵심 파일 3개의 경로와 역할을 작성하세요.
힌트 Parallel Routes의
@modal슬롯과 Intercepting Routes를 조합하세요.(..)photos인터셉팅 디렉터리가 어디에 위치해야 하는지, 그리고 왜(.)가 아니라(..)인지 주의하세요.
- 미들웨어에서
/dashboard로 시작하는 경로에 대해auth-token쿠키가 없으면 로그인 페이지로 리다이렉트하되, 원래 접근하려던 경로를?redirect=쿼리 파라미터로 보존하는 코드를 작성하세요.
힌트
request.nextUrl.pathname과new URL()을 활용하고,config.matcher도 함께 작성하세요.
app/dashboard/layout.tsx가@summary와@detail두 슬롯을 사용합니다./dashboard/reports/123경로에서@detail슬롯이 해당 보고서를 보여주고 있을 때 사용자가 페이지를 하드 리프레시하면 어떤 일이 발생하는지 설명하고, 이를 올바르게 처리하는 방법을 작성하세요.
힌트 하드 리프레시 시 클라이언트 탐색 상태는 사라집니다.
default.tsx가 없는 경우와 있는 경우를 비교하세요.
- 미들웨어에서 Prisma를 사용해 사용자의 DB 권한을 확인하려 했지만 Edge Runtime 오류가 발생했습니다. 이 문제를 어떻게 아키텍처적으로 해결할지, 두 가지 방법을 제시하세요.
힌트 미들웨어의 역할을 "가벼운 1차 검사"로 한정하는 방향과, 런타임을 바꾸는 방향을 각각 검토하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Next.js 심화” 강좌에 대한 댓글입니다.