RSS듀오랩스
Next.js

Next.js 레이아웃과 라우트 그룹: loading.tsx 적용 범위

작성자
듀오랩스 대표·9분 읽기

Next.js 앱 라우터에서 한 폴더에는 이런 파일들이 함께 놓입니다.

app/tags/
├── layout.tsx
├── loading.tsx
├── error.tsx
├── page.tsx          ← /tags
└── [tag]/
    └── page.tsx      ← /tags/react

loading.tsx/tags 목록을 불러오는 동안 보여 줄 스켈레톤으로 만들었습니다. 그런데 이 파일은 /tags/react 같은 하위 페이지에도 적용됩니다. 이 블로그에서는 그 결과 없는 태그 주소가 404 가 아니라 200 을 돌려주고 있었습니다.

파일은 그 페이지에만 붙는다는 생각

파일 이름이 역할을 말해 주니, 흔히 이렇게 이해합니다. page.tsx 옆에 둔 loading.tsx 는 그 페이지의 로딩 화면이고, error.tsx 는 그 페이지의 오류 화면이다. 파일 하나가 같은 폴더의 페이지 하나에 붙는다고 보는 것입니다.

실제로 이 파일들은 페이지에 붙는 것이 아니라 세그먼트를 감싸는 컴포넌트가 됩니다. 폴더 하나가 URL 의 한 조각(세그먼트)이고, 특수 파일들은 그 세그먼트 아래 모든 것을 정해진 순서로 감쌉니다.

특수 파일이 겹치는 순서

프로젝트 구조 문서는 한 세그먼트 안의 특수 파일이 렌더링되는 계층을 이렇게 적습니다.

  • layout.js
  • template.js
  • error.js (React Error Boundary)
  • loading.js (React Suspense 경계)
  • not-found.js (「찾을 수 없음」을 위한 Error Boundary)
  • page.js 또는 하위 layout.js

위의 파일이 아래의 파일을 감쌉니다. 그리고 문서는 이 계층이 중첩된 경로에서 재귀적으로 반복된다고 덧붙입니다. 하위 세그먼트의 컴포넌트들은 부모 세그먼트의 컴포넌트들 안에 들어갑니다.

앞의 폴더에 대입하면 /tags/react 는 이렇게 렌더링됩니다.

tags/layout
  └ tags/error (경계)
      └ tags/loading (Suspense 경계)
          └ [tag]/page

tags/loading.tsx 가 하위 경로의 페이지까지 Suspense 로 감쌉니다. loading 문서도 같은 폴더의 page.js 와 그 아래 모든 자식을 감싼다고 적습니다. Suspense 경계가 로딩 화면을 먼저 스트리밍하기 시작하면 상태 코드를 바꿀 수 없어서, 하위 페이지의 notFound() 가 200 이 됩니다.

같은 계층에서 한 가지 더 읽을 수 있습니다. error.jslayout.js 안쪽에 있습니다. error 문서error.js 가 같은 세그먼트 위쪽의 layout.jstemplate.js 는 감싸지 않는다고 적습니다. 그래서 같은 세그먼트의 레이아웃에서 난 오류는 그 세그먼트의 error.js 가 잡지 못하고, 부모 세그먼트의 경계로 올라갑니다. 루트 레이아웃의 오류는 global-error.js 가 맡습니다.

라우트 그룹으로 감싸는 범위를 좁히기

주소는 그대로 두고 감싸는 범위만 바꾸고 싶을 때 쓰는 것이 라우트 그룹입니다. 라우트 그룹 문서는 괄호로 감싼 폴더 이름이 URL 경로에 포함되지 않는다고 설명하고, 쓰임새로 「특정 세그먼트만 레이아웃을 공유하게 하고 나머지는 빼는 것」을 꼽습니다.

앞의 문제는 목록 페이지와 그 로딩 화면을 그룹으로 한 칸 내려서 풀었습니다.

app/tags/
├── (index)/
│   ├── loading.tsx   ← /tags 에만 적용
│   └── page.tsx      ← 여전히 /tags
└── [tag]/
    └── page.tsx      ← 로딩 경계 밖

(index) 는 주소에 나타나지 않으므로 /tags 는 그대로입니다. 로딩 화면은 이제 (index) 세그먼트 아래에만 있고, 형제인 [tag] 는 감싸지 않습니다.

반대 방향으로도 씁니다. 여러 경로가 같은 레이아웃을 공유하게 하고 싶은데 주소 구조는 제각각일 때입니다. 이 블로그는 전체 글 목록(/, /page/2)과 카테고리 목록(/category/React)이 같은 왼쪽 메뉴를 써야 했습니다. 둘을 (archive) 그룹으로 묶고 그 레이아웃에 메뉴를 두었습니다. 주소는 바뀌지 않았고, 글 상세 페이지처럼 메뉴가 필요 없는 경로는 그룹 밖에 남았습니다.

레이아웃이 다시 렌더링되지 않아서 생기는 제약

레이아웃을 공유하면 얻는 것이 있습니다. 레이아웃 문서에 따르면 페이지를 이동해도 레이아웃은 state 를 유지하고 다시 렌더링되지 않습니다. 메뉴의 스크롤 위치나 펼침 상태가 그대로 남고, 이동할 때 레이아웃을 다시 그리는 비용도 없습니다.

같은 성질이 제약이 됩니다. layout 레퍼런스는 레이아웃이 이동 중에 다시 렌더링되지 않으므로 현재 경로(pathname)와 검색 파라미터를 받을 수 없다고 적습니다. 받을 수 있게 하면 이동한 뒤에도 옛 값에 머물기 때문입니다.

그래서 레이아웃에서 「지금 어느 메뉴가 선택됐는가」를 표시하려면 그 부분만 클라이언트 컴포넌트로 뺍니다. 문서의 설명대로 클라이언트 컴포넌트는 이동할 때 다시 렌더링되므로 usePathname 이나 useSelectedLayoutSegments 로 최신 경로를 읽을 수 있습니다.

"use client";
export function NavLink({ href, category, children }) {
  const segments = useSelectedLayoutSegments();
  const active = segments[0] === "category" && decodeURIComponent(segments[1] ?? "") === category;
  return <Link href={href} aria-current={active ? "page" : undefined}>{children}</Link>;
}

메뉴 목록 자체는 서버 컴포넌트인 레이아웃에서 데이터를 읽어 그리고, 선택 표시만 이 작은 컴포넌트가 맡습니다. 서버 컴포넌트 글에서 경계를 말단으로 내리라고 한 것과 같은 구조입니다.

파일을 둘 폴더를 고르는 질문

제가 보기에 특수 파일을 어디에 둘지는 질문 하나로 정해집니다. 이 파일이 감싸도 되는 경로가 이 폴더 아래 전부인가.

파일 폴더 아래 전부를 감싸면 곤란한 경우
loading.tsx 하위 페이지가 notFound() 로 404 를 돌려줘야 할 때
layout.tsx 일부 하위 경로에는 그 틀이 필요 없을 때
error.tsx 하위 영역마다 다른 복구 화면이 필요할 때

곤란하다면 그 파일과 해당 페이지만 라우트 그룹으로 한 칸 내리고, 공유가 필요하다면 여러 경로를 한 그룹으로 묶습니다. 주소를 바꾸지 않고 감싸는 범위만 조절할 수 있다는 점이 라우트 그룹의 쓸모라고 봅니다.

여기까지가 확실한 부분

특수 파일의 계층 순서와 재귀적 중첩, loading.js 가 하위 경로를 감싼다는 점, 라우트 그룹이 URL 에 포함되지 않는다는 점, 레이아웃이 이동 중에 다시 렌더링되지 않아 pathname 과 검색 파라미터를 받을 수 없다는 점은 Next.js 16 문서 기준입니다. 태그 페이지의 200 과 (index), (archive) 그룹은 2026년 9월 18일 이 블로그에 적용하고 운영에서 확인한 구조입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.