학습 자료

괄호와 골뱅이 — 주소를 만들지 않는 폴더들


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에 나타나지 않는다.

(marketing) 은 주소에 없다
  • app
    • (marketing)주소에 안 나온다
      • layout.js헤더 + 푸터
      • page.js/
      • about/page.js/about
    • (dashboard)주소에 안 나온다
      • layout.js사이드바
      • settings/page.js/settings

/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가 없어야 한다.

루트가 둘
  • applayout.js 가 없다
    • (marketing)
      • layout.jshtml · body 를 직접 쓴다
      • page.js/
    • (shop)
      • layout.jshtml · body 를 직접 쓴다
      • cart/page.js/cart

layout.js<html><body>를 직접 쓴다. 2강에서 "루트 레이아웃은 지울 수 없다"고 한 것은 루트가 하나일 때 얘기다.

골뱅이는 슬롯을 만든다

여기서부터가 병렬 라우트다. 대시보드에 팀 목록과 통계를 동시에 띄우고 싶다. 둘 다 데이터를 받아오고, 각자 로딩과 에러 상태를 가진다.

page.js 안에 다 넣으면 하나가 느릴 때 둘 다 못 뜬다. 폴더 이름 앞에 @를 붙여 나눈다.

@ 폴더는 슬롯이 된다
  • app/dashboard
    • layout.js슬롯을 props 로 받는다
    • page.jschildren 슬롯
    • @teamteam 슬롯
      • page.js
      • loading.js이 슬롯만의 로딩
      • error.js이 슬롯만의 에러
    • @analyticsanalytics 슬롯
      • page.js

@team도 주소를 만들지 않는다. 대신 레이아웃의 props로 들어온다.

export default function DashboardLayout({ children, team, analytics }) {
  return (
    <section>
      <nav>{children}</nav>
      <article>{team}</article>
      <aside>{analytics}</aside>
    </section>
  );
}

폴더 이름 @teamteam이 곧 props 이름이다. 5강의 [id]params.id가 된 것과 같은 규칙이다.

슬롯마다 loading.jserror.js를 따로 둘 수 있다는 게 핵심이다. 통계가 3초 걸려도 팀 목록은 먼저 뜬다. 통계 쪽에서 예외가 터져도 그 칸만 에러 화면이 되고 나머지는 살아 있다.

슬롯은 각자 도착한다
한 page.js
둘 다 기다린다한꺼번에
@team
빠르다먼저 뜬다
@analytics
느리다나중에
요청완료
느린 칸이 빠른 칸을 붙잡지 않는다

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두 단계 위
(...)folderapp 루트부터

import 문에서 쓰던 ./, ../ 와 닮았다. 다만 파일 시스템이 아니라 라우트 단계를 센다. 라우트 그룹은 단계로 세지 않는다는 것만 주의한다. (marketing) 은 주소에 없으므로 (..) 가 한 칸으로 치지 않는다.

모달의 구조
  • app
    • layout.jschildren 과 modal 을 받는다
    • page.js/ — 사진 목록
    • photo/[id]/page.js/photo/1 — 전용 페이지
    • @modal
      • default.jsnull 을 반환
      • (.)photo/[id]/page.js가로채서 모달로

레이아웃은 슬롯 둘을 나란히 그린다.

export default function RootLayout({ children, modal }) {
  return (
    <html lang="ko">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

같은 주소 /photo/1이 두 갈래로 갈린다.

/photo/1 로 들어가는 두 가지 길
  1. 목록에서 클릭Link href="/photo/1"
  2. @modal 이 가로챈다(.)photo/[id]/page.js
  3. 목록 위에 모달children 은 그대로 남는다
같은 주소, 새로고침하면
  1. 주소창에 직접 입력또는 F5
  2. 가로채기가 없다@modal 은 default.js → null
  3. 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.jsnull을 그린다. 모달이 사라진다.

닫기 버튼이 상태를 끄는 게 아니다. 주소가 모달의 상태다. 6강에서 검색어를 주소에 둔 것과 같은 발상이다.

정리하면

  • (그룹) — 주소에서 이름이 사라진다. 레이아웃을 가르거나 폴더를 정리할 때 쓴다.
  • 서로 다른 그룹이 같은 주소를 만들면 빌드가 터진다.
  • _폴더 — 라우팅에서 통째로 빠진다. 컴포넌트와 유틸을 둔다.
  • app/layout.js를 지우면 그룹마다 루트 레이아웃을 가질 수 있다. 대신 그룹을 넘을 때 전체 새로고침이 일어난다.
  • @슬롯 — 주소를 만들지 않고 레이아웃의 props로 들어간다. 슬롯마다 loading.jserror.js를 가진다.
  • 슬롯에는 default.js가 필수다. 없으면 새로고침할 때 404가 뜬다.
  • (.)폴더 — 클라이언트 네비게이션일 때만 주소를 가로챈다. 모달을 주소로 만드는 방법이다.
  • children도 이름 없는 슬롯이다.

Part 2가 끝났다. 폴더 이름만으로 주소, 레이아웃, 로딩, 에러, 모달까지 만들었다.

다음 강의부터 Part 3다. 3강에서 미뤄둔 질문에 답할 차례다. 이 컴포넌트는 서버에서 도는가, 브라우저에서 도는가. 지금까지 몇 번 스쳐 지나간 "use client" 한 줄이 무엇을 가르는지 본다.

괄호와 골뱅이 — 주소를 만들지 않는 폴더들 — 디코드랩(DCODELAB)