라우트 그룹 활용
URL을 바꾸지 않는 라우트 그룹으로 화면군을 조직하고 독립 레이아웃·병렬 슬롯·인터셉팅 모달을 구성합니다.
App Router는 URL 경로를 파일 시스템에 대응시키는 데서 그치지 않고, 라우트 구조를 더 유연하고 체계적으로 관리할 수 있게 해 줍니다. 그 중심에 있는 기능 가운데 하나가 라우트 그룹(Route Groups)입니다. 라우트 그룹은 여러 폴더를 URL 경로에 영향을 주지 않으면서 논리적으로 묶거나, 특정 그룹에만 고유한 레이아웃을 적용하고 싶을 때 씁니다. 복잡한 애플리케이션에서 라우트 구조를 깔끔하게 유지하고 여러 레이아웃의 요구를 효율적으로 처리하게 해 주며, 이 글의 뒷부분에서는 같은 폴더 규칙을 쓰는 병렬 라우트와 인터셉팅 라우트도 함께 살펴봅니다.
라우트 그룹이란 무엇인가요?
라우트 그룹은 폴더 이름을 괄호(())로 감싸 정의하며, (marketing)이나 (auth)처럼 만들 수 있습니다. 라우트 그룹 폴더는 실제 URL 경로에 나타나지 않으므로 app/(marketing)/about/page.tsx는 /about에 연결됩니다. 그래서 (marketing), (dashboard), (auth)처럼 기능별로 관련된 라우트를 한데 모아 관리할 수 있습니다.
가장 중요한 쓰임 가운데 하나는 특정 라우트 그룹에만 고유한 레이아웃을 적용하는 것입니다. 웹사이트의 마케팅 페이지들은 한 레이아웃을 쓰고 대시보드 페이지들은 다른 레이아웃을 쓸 때 유용하며, 라우트 그룹 안에 layout.tsx를 정의하면 그 그룹 안의 모든 페이지와 중첩 라우트에 그 레이아웃이 적용됩니다.
라우트 그룹 실습
4장 2절에서는 대시보드 레이아웃을 만들었습니다. 이제 홈과 소개, 문의 같은 마케팅 페이지에는 또 다른 독립된 레이아웃을 적용하고 싶다고 해 봅시다. 목표는 마케팅 영역의 홈(/), 소개(/about), 문의(/contact)가 RootLayout을 함께 쓰고, (marketing) 그룹 안에 따로 레이아웃을 더하면 그 레이아웃이 중첩되게 하는 것입니다. 대시보드 영역의 대시보드 홈(/dashboard)과 개요(/dashboard/overview) 등은 (dashboard) 그룹 안의 DashboardLayout을 함께 쓰고, 이것 역시 RootLayout 안에 중첩됩니다.
먼저 src/app 안에 (marketing) 폴더를 만들고, 기존의 src/app/page.tsx와 src/app/about/page.tsx를 이 폴더 안으로 옮깁니다. 그리고 src/app/(marketing)/contact/page.tsx를 새로 만들며, 옮긴 기존 파일은 원래 위치에 겹쳐 남기지 않습니다.
src/app/(marketing)/contact/page.tsx의 내용은 다음과 같습니다.
export default function ContactPage() {
return (
<div>
<h3>문의하기</h3>
<p>문의 사항은 프로젝트에서 안내한 공식 채널을 통해 접수할 수 있습니다.</p>
</div>
);
}다음으로 src/app/(dashboard) 폴더를 만들고 기존의 src/app/dashboard 폴더 전체를 그 안으로 옮겨, 최종 경로를 src/app/(dashboard)/dashboard로 만듭니다. 괄호가 붙은 (dashboard)는 URL에서 빠지지만, 안쪽의 일반 dashboard 폴더가 /dashboard 세그먼트를 그대로 유지합니다. 마지막으로 src/app/layout.tsx의 내비게이션 링크에 새로 만든 문의 페이지로 가는 링크를 더합니다.
// ...
<header style={{ backgroundColor: '#f0f0f0', padding: '10px', borderBottom: '1px solid #ddd' }}>
<nav>
<Link href="/">홈</Link> | {' '}
<Link href="/about">소개</Link> | {' '}
<Link href="/contact">문의</Link> | {' '} {/* 새 링크 추가 */}
<Link href="/dashboard">대시보드</Link>
</nav>
<h1>나 혼자 Next.js</h1>
</header>
// ...개발 서버(npm run dev)를 실행한 뒤 http://localhost:3000/, /about, /contact, /dashboard에 차례로 들어가 보면, 홈과 소개, 대시보드 홈은 여전히 같은 주소에 있고 새로 만든 문의 페이지가 /contact에 보입니다. 라우트 그룹의 이름인 (marketing)과 (dashboard)는 URL에 나타나지 않고, 일반 폴더인 contact와 dashboard만 URL의 세그먼트가 됩니다. 이것이 라우트 그룹의 가장 기본적인 역할입니다.
라우트 그룹마다 독립된 레이아웃 적용하기
이제 그룹마다 레이아웃을 적용해 봅니다. 이 실습에는 src/app/layout.tsx가 있으므로 그룹 레이아웃은 그 안에 중첩됩니다. 그룹마다 최상위 레이아웃을 따로 두는 여러 루트 레이아웃 구성과는 다르며, 서로 다른 루트 레이아웃 사이를 이동하면 페이지 전체를 다시 불러옵니다.
마케팅 페이지에 대시보드와 다른 헤더나 푸터가 필요하다면 (marketing) 그룹 안에 layout.tsx를 정의할 수 있습니다. src/app/(marketing) 폴더 안에 layout.tsx를 만들고 다음 내용을 작성합니다.
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div style={{ border: '2px dashed #0070f3', padding: '20px', margin: '20px 0', borderRadius: '10px' }}>
<h2 style={{ color: '#0070f3' }}>마케팅 섹션 (그룹 레이아웃)</h2>
{children}
</div>
);
}이 MarketingLayout은 (marketing) 그룹 안의 모든 페이지(홈, 소개, 문의)에 적용되며, RootLayout의 children으로 렌더링되고 이 레이아웃의 children으로 다시 그 페이지의 page.tsx 내용이 렌더링되는 중첩 구조입니다. 개발 서버에서 http://localhost:3000/, /about, /contact를 다시 확인하면, 세 페이지에서 최상위 헤더와 푸터(RootLayout) 안에 파란 점선 테두리를 가진 마케팅 영역(MarketingLayout)이 보이고 그 안에 페이지 내용이 나타납니다. 반면 src/app/(dashboard)/dashboard/layout.tsx는 http://localhost:3000/dashboard에 계속 적용됩니다. 이처럼 라우트 그룹을 쓰면 URL을 깔끔하게 유지하면서도 필요에 따라 여러 레이아웃을 유연하게 적용할 수 있으며, 대규모 애플리케이션이나 여러 영역이 각자의 디자인을 가질 때 특히 쓸모가 있습니다.
주의할 점
URL 세그먼트가 같은 여러 라우트 그룹을 만들 수도 있습니다. 예를 들어 (auth)/login/page.tsx와 (admin)/login/page.tsx는 둘 다 /login에 연결되는데, 이 경우 Next.js는 어느 page.tsx를 렌더링할지 정할 수 없어 오류를 냅니다. 따라서 라우트 그룹마다 최종 URL은 고유해야 하며, 라우트 그룹은 레이아웃이나 폴더 구조를 위한 논리적 묶음으로 쓰되 실제 URL이 충돌하지 않게 주의합니다.
그룹 부분만 빼고 남는 URL로 충돌을 판단하면 쉽습니다. (marketing)/contact/page.tsx에서 그룹 부분을 빼면 /contact가 남고, (support)/contact/page.tsx도 같은 URL이므로 두 그룹의 이름이 달라도 독립된 라우트 두 개로 쓸 수 없습니다. 반면 (support)/help/page.tsx는 /help이므로 충돌하지 않습니다. 이 실습처럼 공통 src/app/layout.tsx가 위에 있으면 각 그룹의 레이아웃은 그 안에 중첩되며, 그룹마다 따로 루트를 두어 공통 최상위 레이아웃이 없는 구성의 이동 방식까지 같다고 일반화하지 않습니다.
병렬 라우트 (Parallel Routes): @slot으로 UI를 독립된 영역으로 나누기
여기서부터의 병렬 라우트는 src/app/dashboard를 쓰는 별도 구성의 예입니다. 앞에서 대시보드를 (dashboard) 그룹으로 옮긴 프로젝트에 적용한다면 그 그룹 안의 dashboard를 고치고, 같은 URL의 폴더 두 개를 함께 만들지 마세요.
대시보드에서 분석 패널과 팀 활동 패널을 함께 보여 주면서, 영역마다의 이동과 로딩, 오류 상태를 라우트 단위로 관리하고 싶다면 병렬 라우트를 쓸 수 있습니다. 두 컴포넌트를 나란히 놓기만 할 때는 병렬 라우트가 필요하지 않습니다. @analytics, @team처럼 @ 접두사를 붙인 슬롯 폴더를 만들면 레이아웃이 각 슬롯을 독립적으로 렌더링할 수 있고, 슬롯마다 loading.tsx, error.tsx, default.tsx를 따로 둘 수 있어 한 영역의 오류가 다른 영역으로 번지지 않게 하기 쉽습니다.
먼저 레이아웃이 슬롯들을 받아 배치하는 코드와 기본 페이지입니다.
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<section>{analytics}</section>
<section>{team}</section>
</div>
</div>
);
}export default function DashboardPage() {
return <h1>대시보드 개요</h1>;
}children도 이름 없는 기본 슬롯이므로 /dashboard를 구성할 page.tsx가 필요하며, 레이아웃은 이 기본 슬롯과 @analytics, @team 슬롯을 함께 받아 원하는 위치에 배치합니다.
NEXT · CH4-4
세 슬롯을 세 layout props로 받습니다
기본 page는 children, @analytics는 analytics, @team은 team입니다. 슬롯 이름은 URL에 포함되지 않고 layout이 세 UI를 함께 배치합니다.
그림은 파일과 prop의 대응이며 실제 두 패널의 좌우 배치는 본문의 grid 코드가 정합니다. 두 슬롯의 page 구현은 생략했습니다. 직접 접근·새로고침의 미일치 슬롯에는 default.tsx가 필요하며 없으면 404입니다.
위 그림은 세 슬롯을 레이아웃의 세 props로 받는 대응을 보여 줍니다. 그림의 요약처럼 기본 page는 children, @analytics는 analytics, @team은 team으로 전달되며, 슬롯의 이름은 URL에 들어가지 않고 레이아웃이 세 UI를 함께 배치합니다. DashboardLayout 안에 기본 본문인 page.tsx, 분석 영역인 @analytics/page.tsx, 팀 영역인 @team/page.tsx가 각각 대응하는 prop 이름과 함께 놓입니다. 그림의 마지막 설명처럼 이것은 파일과 prop의 대응일 뿐 실제 두 패널의 좌우 배치는 레이아웃의 grid 코드가 정하며, 직접 들어오거나 새로고침할 때 매칭되지 않는 슬롯에는 default.tsx가 필요합니다.
다음은 분석 슬롯의 로딩, 오류, 기본 화면입니다. 오류 화면은 에러 바운더리이므로 'use client'가 필요합니다.
export default function LoadingAnalytics() {
return <p>분석 데이터를 불러오는 중...</p>;
}'use client';
export default function AnalyticsError({
error,
retry,
}: {
error: Error;
retry: () => void;
}) {
return (
<div>
<p>분석 패널 오류: {error.message}</p>
<button onClick={() => retry()}>다시 시도</button>
</div>
);
}오류 화면은 error로 받은 오류의 메시지를 보여 주고, 버튼으로 retry를 부르면 이 슬롯의 내용을 서버에서 다시 받아 렌더링해 봅니다. 이 에러 바운더리는 @analytics 슬롯 안에만 있으므로, 분석 패널에서 오류가 나도 팀 패널과 기본 본문은 그대로 보입니다.
export default function AnalyticsDefault() {
return <p>분석 패널 기본 화면</p>;
}파일 트리와 레이아웃 코드만으로는 두 패널의 본문이 만들어지지 않으므로, 다음 세 파일을 같은 대시보드 가지에 더해 외부 데이터 없이도 슬롯과 prop의 대응을 확인합니다. 대시보드를 (dashboard)로 옮긴 프로젝트라면 이 파일들도 그 그룹 안의 같은 dashboard에 두며, 두 위치에 같은 URL의 대시보드를 함께 만들지 않습니다.
export default function AnalyticsPage() {
return <h2>분석 패널: 방문 120회</h2>;
}export default function TeamPage() {
return <h2>팀 패널: 활동 3건</h2>;
}export default function TeamDefault() {
return <p>팀 패널 기본 화면</p>;
}이 숫자들은 로딩이나 DB 조회가 없는 고정 표시용 예입니다. /dashboard에서는 기존의 기본 page와 두 패널의 page가 각각 children, analytics, team으로 전달되어 함께 보이며, 지금의 숫자를 보고 세 슬롯이 같은 데이터 요청을 함께 쓴다고 추론하지 않습니다. 이 최소한의 확인에는 팀 슬롯의 오류·로딩 파일이 꼭 필요하지는 않으며, 실제 비동기 작업을 붙일 때 그 내용을 채웁니다.
병렬 라우트를 쓸 때는 몇 가지를 점검합니다. 슬롯 폴더의 이름은 반드시 @slotName 형태여야 합니다. 클라이언트 측 이동에서는 URL과 매칭되지 않는 다른 슬롯이 지금 보이던 화면을 유지할 수 있지만, 직접 들어오거나 새로고침할 때는 그 활성 상태를 되살릴 수 없어 매칭되지 않는 슬롯이 default.tsx를 보여 주며, children도 매칭되지 않을 수 있는 구성이라면 그 기본 화면도 필요합니다. Next.js 16부터는 모든 병렬 라우트 슬롯에 default.js(TypeScript라면 default.tsx)를 명시적으로 두어야 하고, 없으면 빌드가 실패합니다. 예전처럼 동작하게 하려면 notFound()를 부르거나 null을 돌려주는 기본 파일을 만듭니다(Next.js 16 업그레이드 안내). 슬롯마다 loading.tsx와 error.tsx를 나누어 장애가 번지는 범위를 줄이고, 슬롯 사이에 서로 기대는 데이터를 줄여 한 패널의 실패가 레이아웃 전체의 실패로 번지지 않게 합니다.
인터셉팅 라우트 (Intercepting Routes): 모달로 상세 화면 띄우기
피드 목록(/feed)에서 카드를 눌렀을 때 페이지 전체를 이동하는 대신 상세를 모달로 띄우고, 새로고침하거나 주소로 바로 들어왔을 때는 전체 페이지(/photo/[id])를 보여 주고 싶을 때 인터셉팅 라우트(Intercepting Routes)가 쓸모가 있습니다. (..)는 파일 시스템의 폴더 수가 아니라 라우트 세그먼트를 세므로, @modal은 세그먼트가 아니어서 feed/@modal/(..)photo에서 한 단계 위는 루트의 photo입니다. (.)는 같은 수준, (..)(..)는 두 단계 위, (...)는 루트(app)부터의 세그먼트를 가로챕니다.
같은 사진 URL을 여는 두 경로
feed의 클라이언트 Link 이동은 배경을 유지한 modal이고 같은 URL 직접 진입·새로고침은 전체 photo입니다. feed에서 modal을 연 뒤 새로고침하는 실제 상태 변화로 URL과 탐색 문맥의 차이를 설명할 수 있습니다.
NEXT · CH4-4
같은 /photo/42도 탐색 문맥에 따라 다릅니다
피드의 Link 탐색은 기존 피드 위에 모달을, 직접 접근이나 새로고침은 전체 사진 페이지를 보여줍니다. @modal은 라우트 세그먼트로 세지 않습니다.
본문은 구성 예제입니다. feed의 Link 목록과 오버레이 CSS는 별도로 작성합니다. 닫기는 router.back()이며 default.tsx의 null이 모든 클라이언트 이동에서 모달을 닫는 규칙은 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 피드의 클라이언트 이동은 원래 피드 위에 모달 경로를 렌더하고 직접 접근이나 새로고침은 전체 사진 페이지를 렌더한다.
- 동일한 photo/42 URL이 피드 링크 이동이면 모달, 직접 접근이나 새로고침이면 전체 페이지가 된다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 같은 사진 URL을 여는 두 경로 |
| 4 | 피드의 Link 탐색은 기존 피드 위에 모달을, 직접 접근이나 새로고침은 전체 사진 페이지를 보여 줍니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| feed에서 출발 feed가 현재 배경으로 표시됩니다. | 현재 화면: feed · photo: 아직 열기 전 | 클라이언트 이동의 출발 문맥이 준비됩니다. |
| Link로 modal 열기 feed 안의 client 이동은 배경을 유지한 modal photo를 엽니다. | URL: photo 경로 · 배경: feed 유지 · 출력: modal | URL 이동 뒤에도 feed 문맥이 남습니다. |
| 같은 URL 새로고침 현재 photo URL을 새로고침하면 전체 photo 페이지로 들어갑니다. | URL: 같은 photo · 출력: 전체 photo · 조건: 새로고침·직접 진입 | 탐색 문맥이 사라져 표시 형태가 바뀝니다. |
같은 사진 URL을 여는 두 경로
feed의 클라이언트 Link 이동은 배경을 유지한 modal이고 같은 URL 직접 진입·새로고침은 전체 photo입니다. feed에서 modal을 연 뒤 새로고침하는 실제 상태 변화로 URL과 탐색 문맥의 차이를 설명할 수 있습니다.
NEXT · CH4-4
같은 /photo/42도 탐색 문맥에 따라 다릅니다
피드의 Link 탐색은 기존 피드 위에 모달을, 직접 접근이나 새로고침은 전체 사진 페이지를 보여줍니다. @modal은 라우트 세그먼트로 세지 않습니다.
본문은 구성 예제입니다. feed의 Link 목록과 오버레이 CSS는 별도로 작성합니다. 닫기는 router.back()이며 default.tsx의 null이 모든 클라이언트 이동에서 모달을 닫는 규칙은 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 피드의 클라이언트 이동은 원래 피드 위에 모달 경로를 렌더하고 직접 접근이나 새로고침은 전체 사진 페이지를 렌더한다.
- 동일한 photo/42 URL이 피드 링크 이동이면 모달, 직접 접근이나 새로고침이면 전체 페이지가 된다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 같은 사진 URL을 여는 두 경로 |
| 4 | 피드의 Link 탐색은 기존 피드 위에 모달을, 직접 접근이나 새로고침은 전체 사진 페이지를 보여 줍니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| feed에서 출발 feed가 현재 배경으로 표시됩니다. | 현재 화면: feed · photo: 아직 열기 전 | 클라이언트 이동의 출발 문맥이 준비됩니다. |
| Link로 modal 열기 feed 안의 client 이동은 배경을 유지한 modal photo를 엽니다. | URL: photo 경로 · 배경: feed 유지 · 출력: modal | URL 이동 뒤에도 feed 문맥이 남습니다. |
| 같은 URL 새로고침 현재 photo URL을 새로고침하면 전체 photo 페이지로 들어갑니다. | URL: 같은 photo · 출력: 전체 photo · 조건: 새로고침·직접 진입 | 탐색 문맥이 사라져 표시 형태가 바뀝니다. |
위 그림은 같은 /photo/42 URL도 어떻게 이동했느냐에 따라 다른 화면이 된다는 점을 보여 줍니다. 그림의 요약처럼 피드에서 Link로 이동하면 기존 피드 위에 모달이 뜨고, 주소로 바로 들어오거나 새로고침하면 전체 사진 페이지가 보이며, @modal은 라우트 세그먼트로 세지 않습니다. 피드에서 Link를 누르면 feed/@modal/(..)photo/[id]/page.tsx가 피드 위의 모달로 렌더링되고, 바로 들어오거나 새로고침하면 photo/[id]/page.tsx의 전체 사진 페이지가 렌더링됩니다. 그림의 단계도 피드에서 출발해, Link로 모달을 열고, 같은 URL을 새로고침해 전체 페이지로 들어가는 순서입니다. 그림의 마지막 설명처럼 본문은 구성의 예이므로 피드의 Link 목록과 오버레이 CSS는 따로 작성하며, 닫기는 router.back()이고 default.tsx의 null이 모든 클라이언트 이동에서 모달을 닫는 규칙은 아닙니다.
아래는 라우팅과 닫기 동작에 집중한 구성의 예입니다. 폴더 구조는 모달 상세와 전체 페이지를 함께 두는 형태입니다.
export default function FeedLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}피드 레이아웃은 @modal 슬롯을 modal prop으로 받아 피드 본문 뒤에 그립니다. 평소에는 아래의 default.tsx가 null을 돌려주므로 이 자리에 아무것도 없고, 피드에서 사진 링크를 눌렀을 때만 가로챈 상세 페이지가 들어옵니다. 가로챈 페이지는 서버 컴포넌트로서 params에서 id만 꺼내고, 포커스와 키보드를 다뤄야 하는 모달 본체는 클라이언트 컴포넌트에 맡깁니다.
import PhotoModalClient from './PhotoModalClient';
export default async function PhotoModalPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <PhotoModalClient id={id} />;
}'use client';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
export default function PhotoModalClient({ id }: { id: string }) {
const router = useRouter();
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const previousOverflow = document.body.style.overflow;
closeButtonRef.current?.focus();
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') router.back();
if (event.key === 'Tab') {
event.preventDefault();
closeButtonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = previousOverflow;
previouslyFocused?.focus();
};
}, [router]);
return (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) router.back();
}}
>
<div
className="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="photo-modal-title"
>
<h2 id="photo-modal-title">사진 상세 (모달) #{id}</h2>
<button ref={closeButtonRef} type="button" onClick={() => router.back()}>
닫기
</button>
</div>
</div>
);
}export default async function PhotoDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <h1>사진 상세 전체 페이지 #{id}</h1>;
}export default function FeedModalDefault() {
return null; // 모달이 없을 때 빈 슬롯
}PhotoModalClient는 모달이 열리면 닫기 버튼에 포커스를 주고 배경의 스크롤을 막으며, Escape 키나 배경 클릭, 닫기 버튼으로 router.back()을 불러 이전 화면으로 돌아갑니다. 정리 함수에서는 이벤트 리스너를 지우고 스크롤과 이전 포커스를 되돌립니다. 이제 앞에서 생략했던 피드 파일을 다음처럼 작성합니다. 기본 Link의 클라이언트 측 이동을 쓰며, 페이지 전체의 새로고침을 강제하는 설정은 더하지 않습니다.
import Link from 'next/link';
export default function FeedPage() {
return (
<section>
<h1>사진 피드</h1>
<ul>
<li><Link href="/photo/42">사진 42 보기</Link></li>
<li><Link href="/photo/84">사진 84 보기</Link></li>
</ul>
</section>
);
}루트 레이아웃이 이미 가져오는 src/app/globals.css에는 다음 학습용 오버레이 규칙을 더합니다. 기존 전역 규칙을 지우는 내용이 아니며, 포커스를 받을 대상이 닫기 버튼 하나뿐인 지금의 모달에 한정합니다.
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 16px;
background: rgb(0 0 0 / 0.45);
}
.modal-card {
width: min(100%, 28rem);
max-height: calc(100dvh - 32px);
overflow: auto;
padding: 24px;
border-radius: 12px;
background: white;
color: #111;
}먼저 /feed를 직접 열고 “사진 42 보기” Link를 누릅니다. 주소는 /photo/42로 바뀌고, 피드가 남은 채 모달이 뜬 화면을 확인합니다. 닫기 버튼으로 뒤로 돌아온 뒤, /photo/42를 주소창에서 직접 열거나 그 주소에서 새로고침해 전체 페이지와 비교합니다. 이것은 클릭과 뒤로 가기, 새로고침을 구별해 보는 절차이며, 이 학습 앱의 실제 화면을 실행한 기록은 아닙니다(Next.js 문서의 인터셉팅 라우트).
인터셉팅 라우트를 쓸 때도 몇 가지를 점검합니다. (.), (..), (...)의 경로 깊이를 잘못 쓰면 의도하지 않은 세그먼트를 가로챕니다. default.tsx의 null은 바로 들어오거나 새로고침할 때 매칭되지 않는 모달 슬롯을 비우는 기본값일 뿐 모든 클라이언트 이동에서 모달을 닫는 규칙이 아니며, 이 예제의 닫기 동작은 router.back()입니다. 지금의 포커스 처리는 닫기 버튼 하나만 있는 모달에 한정하므로, 입력이나 다른 버튼을 더하면 전체 포커스 순환과 배경 상호작용 차단을 함께 구현하고, Escape 닫기와 배경 스크롤의 복원도 확인합니다. 공유할 수 있는 URL이 필요하므로 전체 페이지 라우트(/photo/[id])는 반드시 유지합니다.
정리
- 라우트 그룹
(name)은 URL에 나타나지 않으면서 라우트를 논리적으로 묶고, 그룹 안의layout.tsx로 그 그룹에만 고유한 레이아웃을 적용합니다. - 그룹 이름을 뺀 최종 URL이 같으면 충돌하므로, 그룹마다 최종 URL은 고유해야 합니다.
- 병렬 라우트의
@slot폴더는 레이아웃의 같은 이름 prop으로 전달되며, Next.js 16에서는 모든 슬롯에default파일이 있어야 빌드됩니다. - 매칭되지 않는 슬롯은 클라이언트 측 이동에서는 지금의 화면을 유지할 수 있지만, 직접 들어오거나 새로고침하면
default파일을 보여 줍니다. - 인터셉팅 라우트
(..)는 폴더가 아니라 라우트 세그먼트를 세며, 같은 URL도 Link 이동이면 모달, 직접 접근이나 새로고침이면 전체 페이지가 됩니다.
연습 문제
1. 현재 활성 슬롯과 새로 되살릴 슬롯
@team/settings/page.tsx만 더하고 /dashboard/settings로 클라이언트 측 이동을 했다고 가정합니다. 분석 슬롯에는 그 하위 경로가 없습니다. 이동하기 전에 분석 화면이 보이던 상태와, 그 URL에서 새로고침한 상태의 분석 슬롯의 결과를 각각 예측해 보세요. 기본 children에도 그 경로가 없다는 조건이라면 어느 기본 파일을 더 검토해야 할까요?
해설 보기
클라이언트 측 이동에서는 매칭되지 않는 분석 슬롯이 지금 보이던 화면을 유지할 수 있습니다. 새로고침하면 그 활성 상태를 되살릴 수 없으므로 @analytics/default.tsx를 보여 주며, Next.js 16에서는 이 기본 파일이 없으면 애초에 빌드가 실패합니다.
기본 children도 이 조건에서는 매칭되지 않으므로 부모 dashboard의 default.tsx를 검토합니다. 앞의 실제 대시보드에 settings/page.tsx가 이미 있다면 children은 매칭되므로 그때까지 매칭되지 않는다고 판단하지 않습니다. 같은 주소여도 파일의 구성과 이동한 방식을 모두 봐야 하며, 이는 Next.js 문서의 병렬 라우트 규칙에 따른 예측일 뿐 실제 요청을 실행한 결과는 아닙니다.
2. 도착 주소와 이동 방식
피드의 Link로 사진 84를 연 뒤 새로고침했다면 어떤 page가 지금 화면을 맡을까요? @modal을 폴더 한 단계로 세어 (..)(..)photo로 바꿔야 할까요? default.tsx의 null만으로 이후의 모든 클라이언트 이동에서 모달이 닫힌다고 보장할 수 있을까요?
해설 보기
새로고침한 /photo/84는 전체 페이지인 photo/[id]/page.tsx가 맡습니다. @modal은 URL 세그먼트가 아니므로, 지금의 feed에서 루트의 photo를 향하는 (..)photo가 맞습니다.
null을 돌려주는 기본 화면은 매칭되지 않는 슬롯을 되살리는 경우에 쓰일 뿐, 모든 클라이언트 이동에서 모달이 닫힌다고 보장하지 않습니다. 이 예제의 닫기는 router.back()이며, 다시 앞으로 이동할 때 모달이 어떻게 보이는지도 따로 확인합니다.
3. 같은 URL을 만드는 두 그룹
src/app/(shop)/sale/page.tsx와 src/app/(event)/sale/page.tsx를 함께 만들었습니다. 무엇이 문제이고, 두 화면을 모두 유지하려면 어떻게 해야 할까요?
해설 보기
그룹 이름을 빼면 두 파일 모두 /sale이 되어 URL이 충돌하고, Next.js가 오류를 냅니다. 두 화면을 모두 유지하려면 (event)/event-sale/page.tsx처럼 그룹 안의 일반 폴더 이름을 달리해 최종 URL을 서로 다르게 만듭니다.
이것으로 4장을 마칩니다. 4장에서는 대괄호 폴더로 동적 라우트를 만들어 params로 값을 받는 방법과 generateStaticParams, 중첩 라우트와 레이아웃의 조합, 가변 깊이의 경로를 받는 Catch-all 세그먼트, 그리고 라우트 그룹과 병렬·인터셉팅 라우트로 라우트 구조를 조직하는 방법을 익혔습니다. 다음 장의 페이지 컴포넌트 작성부터는 페이지와 레이아웃 컴포넌트를 작성하는 방법을 더 자세히 다룹니다.