괄호와 골뱅이 — 주소를 만들지 않는 폴더들
Article
랜딩 페이지에는 큰 헤더와 푸터가 있고, 대시보드에는 사이드바만 있다. 둘의 레이아웃이 완전히 다르다.
2강에서 배운 대로면 방법이 없다. 폴더가 곧 주소이고, 레이아웃은 그 폴더에 딸려 있기 때문이다. /와 /dashboard는 둘 다 루트 레이아웃을 거친다.
이렇게 우회하고 싶어진다.
"use client";
export default function RootLayout({ children }) {
const pathname = usePathname();
const isDashboard = pathname.startsWith("/dashboard");
return (
<html lang="ko">
<body>
{isDashboard ? <Sidebar /> : <Header />}
{children}
</body>
</html>
);
}
동작은 한다. 대신 루트 레이아웃 전체가 클라이언트 컴포넌트가 된다. 3강에서 본 서버 렌더링의 이점을 앱 전체가 잃는다. 그리고 페이지가 늘어날수록 이 if 문이 자란다.
폴더 이름만 바꿔서 해결할 수 있다.
괄호를 치면 주소에서 사라진다
폴더 이름을 소괄호로 감싸면 URL에 나타나지 않는다.
- app
- (marketing)
- layout.js
- page.js
- about/page.js
- (dashboard)
- layout.js
- settings/page.js
- (marketing)
/marketing/about이 아니라 **/about**이다. 괄호 폴더는 주소를 만들지 않는다.
그런데 layout.js는 폴더마다 적용된다. 그래서 /about은 헤더·푸터 레이아웃을, /settings는 사이드바 레이아웃을 거친다. 주소는 그대로 두고 레이아웃만 갈랐다.
| 폴더 | 주소 | 거치는 레이아웃 |
|---|---|---|
| app/(marketing)/page.js | / | (marketing)/layout.js |
| app/(marketing)/about/page.js | /about | (marketing)/layout.js |
| app/(dashboard)/settings/page.js | /settings | (dashboard)/layout.js |
| app/(shop)/cart/page.js | /cart | (shop)/layout.js |
레이아웃을 나눌 생각이 없어도 쓴다. 페이지가 서른 개쯤 되면 app 폴더가 평평해져서 뭐가 뭔지 안 보인다. (auth), (admin)처럼 묶어두면 주소를 하나도 바꾸지 않고 폴더를 정리할 수 있다.
- Error: You cannot have two parallel pages that resolve to the same path.
- Please check /(a)/about and /(b)/about.
언더스코어는 아예 라우팅에서 빠진다
2강에서 app 안에 컴포넌트를 둬도 된다고 했다. page.js가 아니면 라우트가 아니기 때문이다.
그래도 확실히 해두고 싶을 때가 있다. 폴더 이름 앞에 언더스코어를 붙인다.
- app
- _components
- _lib
- (marketing)
- page.js
app/_components/page.js를 만들어도 /_components라는 주소는 생기지 않는다.
(폴더)
라우트 그룹
- 안의
page.js는 살아 있다 - 주소에서 이름만 빠진다
- 레이아웃을 가른다
_폴더
프라이빗 폴더
- 안의
page.js도 무시된다 - 라우팅에서 통째로 빠진다
- 컴포넌트 · 유틸을 둔다
루트 레이아웃을 여러 개 둔다
레이아웃뿐 아니라 <html> 태그부터 다르게 하고 싶다면, 괄호 폴더마다 루트 레이아웃을 둘 수 있다.
조건이 하나 있다. app/layout.js가 없어야 한다.
- app
- (marketing)
- layout.js
- page.js
- (shop)
- layout.js
- cart/page.js
- (marketing)
각 layout.js가 <html>과 <body>를 직접 쓴다. 2강에서 "루트 레이아웃은 지울 수 없다"고 한 것은 루트가 하나일 때 얘기다.
골뱅이는 슬롯을 만든다
여기서부터가 병렬 라우트다. 대시보드에 팀 목록과 통계를 동시에 띄우고 싶다. 둘 다 데이터를 받아오고, 각자 로딩과 에러 상태를 가진다.
한 page.js 안에 다 넣으면 하나가 느릴 때 둘 다 못 뜬다. 폴더 이름 앞에 @를 붙여 나눈다.
- app/dashboard
- layout.js
- page.js
- @team
- page.js
- loading.js
- error.js
- @analytics
- page.js
@team도 주소를 만들지 않는다. 대신 레이아웃의 props로 들어온다.
export default function DashboardLayout({ children, team, analytics }) {
return (
<section>
<nav>{children}</nav>
<article>{team}</article>
<aside>{analytics}</aside>
</section>
);
}
폴더 이름 @team의 team이 곧 props 이름이다. 5강의 [id]가 params.id가 된 것과 같은 규칙이다.
슬롯마다 loading.js와 error.js를 따로 둘 수 있다는 게 핵심이다. 통계가 3초 걸려도 팀 목록은 먼저 뜬다. 통계 쪽에서 예외가 터져도 그 칸만 에러 화면이 되고 나머지는 살아 있다.
default.js — 없으면 새로고침할 때 404가 뜬다
병렬 라우트에서 가장 많이 걸려 넘어지는 지점이다.
/dashboard에서 /dashboard/settings로 링크를 눌러 이동하면, Next.js는 매칭되지 않는 슬롯의 직전 화면을 그대로 유지한다. 사이드바가 남아 있는 것과 같은 이치다.
그런데 /dashboard/settings에서 새로고침을 하면 얘기가 다르다. 서버는 직전 화면을 모른다.
링크로 이동
소프트 내비게이션
- 브라우저가 직전 상태를 안다
- 매칭 안 되는 슬롯은 그대로 유지
- 잘 동작한다
새로고침 · 직접 접속
하드 내비게이션
- 서버는 직전 상태를 모른다
@team/settings/page.js를 찾는다- 없으면 404
그래서 슬롯마다 default.js를 둔다. **"매칭되는 게 없을 때 이걸 그려라"**는 뜻이다.
export default function Default() {
return null;
}
아무것도 안 그려도 된다. 파일이 있다는 것만으로 404를 면한다.
모달을 주소로 만든다
인스타그램에서 사진을 누르면 모달이 뜬다. 그 상태로 새로고침하면 모달이 아니라 사진 전용 페이지가 열린다. 주소를 복사해 친구에게 보내도 마찬가지다.
이걸 만드는 게 인터셉팅 라우트다. 폴더 이름 앞에 점을 괄호로 감싸 붙인다.
| 표기 | 가로채는 위치 |
|---|---|
| (.)folder | 같은 단계 |
| (..)folder | 한 단계 위 |
| (..)(..)folder | 두 단계 위 |
| (...)folder | app 루트부터 |
import 문에서 쓰던 ./, ../ 와 닮았다. 다만 파일 시스템이 아니라 라우트 단계를 센다. 라우트 그룹은 단계로 세지 않는다는 것만 주의한다. (marketing) 은 주소에 없으므로 (..) 가 한 칸으로 치지 않는다.
- app
- layout.js
- page.js
- photo/[id]/page.js
- @modal
- default.js
- (.)photo/[id]/page.js
레이아웃은 슬롯 둘을 나란히 그린다.
export default function RootLayout({ children, modal }) {
return (
<html lang="ko">
<body>
{children}
{modal}
</body>
</html>
);
}
같은 주소 /photo/1이 두 갈래로 갈린다.
- 목록에서 클릭Link href="/photo/1"
- @modal 이 가로챈다(.)photo/[id]/page.js
- 목록 위에 모달children 은 그대로 남는다
- 주소창에 직접 입력또는 F5
- 가로채기가 없다@modal 은 default.js → null
- photo/[id]/page.js전용 페이지가 뜬다
가로채기는 클라이언트 네비게이션일 때만 일어난다. 6강에서 <Link>가 클릭을 가로챈다고 했다. 그렇게 이동할 때만 @modal 슬롯이 반응한다. 서버로 요청이 새로 가면 가로챌 것이 없다.
모달을 닫는 것도 이 구조를 따른다. 주소를 되돌리면 된다.
"use client";
import { useRouter } from "next/navigation";
export default function Modal({ children }) {
const router = useRouter();
return (
<dialog open onClose={() => router.back()}>
{children}
<button onClick={() => router.back()}>닫기</button>
</dialog>
);
}
6강의 router.back()이 여기서 쓰인다. 뒤로 가면 주소가 /로 돌아가고, 그러면 @modal 슬롯은 매칭될 게 없어 default.js가 null을 그린다. 모달이 사라진다.
닫기 버튼이 상태를 끄는 게 아니다. 주소가 모달의 상태다. 6강에서 검색어를 주소에 둔 것과 같은 발상이다.
정리하면
(그룹)— 주소에서 이름이 사라진다. 레이아웃을 가르거나 폴더를 정리할 때 쓴다.- 서로 다른 그룹이 같은 주소를 만들면 빌드가 터진다.
_폴더— 라우팅에서 통째로 빠진다. 컴포넌트와 유틸을 둔다.app/layout.js를 지우면 그룹마다 루트 레이아웃을 가질 수 있다. 대신 그룹을 넘을 때 전체 새로고침이 일어난다.@슬롯— 주소를 만들지 않고 레이아웃의 props로 들어간다. 슬롯마다loading.js와error.js를 가진다.- 슬롯에는
default.js가 필수다. 없으면 새로고침할 때 404가 뜬다. (.)폴더— 클라이언트 네비게이션일 때만 주소를 가로챈다. 모달을 주소로 만드는 방법이다.children도 이름 없는 슬롯이다.
Part 2가 끝났다. 폴더 이름만으로 주소, 레이아웃, 로딩, 에러, 모달까지 만들었다.
다음 강의부터 Part 3다. 3강에서 미뤄둔 질문에 답할 차례다. 이 컴포넌트는 서버에서 도는가, 브라우저에서 도는가. 지금까지 몇 번 스쳐 지나간 "use client" 한 줄이 무엇을 가르는지 본다.