App Router 구조
App Router의 폴더 세그먼트와 layout·page 파일 규칙을 익히고 서버·클라이언트 컴포넌트의 출발점을 구분합니다.
3장에서는 App Router의 라우팅을 본격적으로 다룹니다. Next.js 16의 App Router는 파일과 폴더의 위치로 웹 애플리케이션의 라우팅과 레이아웃, 서버 로직을 정의합니다. 2장에서 프로젝트 구조를 간단히 살펴보았으니, 이 글에서는 App Router의 핵심 원리와 구조를 더 깊이 정리합니다.
App Router의 핵심 원리
App Router는 src/app(또는 프로젝트 루트의 app) 디렉터리 안의 파일 시스템으로 라우트(경로)를 정의하며, 여기에는 가장 중요한 규칙 두 가지가 있습니다.
첫째, 폴더(Folder)는 라우트 세그먼트(Route Segment)를 만듭니다. app 안의 일반 폴더는 URL 경로의 한 부분인 라우트 세그먼트가 되므로, 예를 들어 app/dashboard 폴더는 /dashboard 경로의 후보가 됩니다. 다만 실제로 접근할 수 있는 페이지가 되려면 그 세그먼트 아래에 page.tsx 같은 공개 UI 파일이 있어야 합니다. 예외도 있어서, (marketing) 같은 라우트 그룹은 URL에 들어가지 않고, _components 같은 비공개 폴더(private folder)는 라우팅에서 빠지며, @modal 같은 병렬 라우트의 슬롯도 URL의 세그먼트가 아닙니다.
둘째, 정해진 파일 이름이 UI를 렌더링하거나 특정 로직을 정의합니다. 폴더 자체는 UI를 직접 렌더링하지 않으며, 폴더 안의 page.tsx나 layout.tsx 같은 정해진 이름의 파일이 실제로 브라우저에 보일 UI를 정의하거나 그 라우트의 특별한 동작을 정합니다. 이 두 규칙으로 Next.js는 URL의 구조와 UI의 역할을 파일 시스템 안에서 함께 표현합니다.
필수 파일: layout.tsx와 page.tsx
App Router 기반의 Next.js 애플리케이션에서 가장 기본이 되는 두 파일은 layout.tsx와 page.tsx입니다. page.tsx는 그 경로에서만 보이는 화면을, layout.tsx는 여러 경로가 함께 쓰는 틀을 정의합니다. 레이아웃은 그 폴더와 하위 경로의 페이지를 children으로 받아 감싸므로, 한 URL의 최종 화면은 바깥 레이아웃부터 안쪽 페이지까지 차례로 중첩된 결과입니다. 아래에서는 레이아웃과 페이지를 차례로 만들며 이 중첩을 확인합니다.
layout.tsx (공유 레이아웃)
layout.tsx는 그 폴더와 하위 폴더의 모든 라우트 세그먼트에 적용되는 공유 UI(Shared UI)를 정의합니다. 이 교재의 기본 단일 라우트 트리에서는 src/app/layout.tsx가 가장 위의 레이아웃, 곧 루트 레이아웃(Root Layout)을 정의합니다. 각 라우트 트리에는 <html>과 <body>를 반환하는 루트 레이아웃이 있어야 하며, 라우트 그룹이나 [locale] 같은 세그먼트 아래에 서로 다른 루트 레이아웃을 두는 고급 구조에서는 src/app/layout.tsx 없이 여러 트리를 만들 수도 있습니다.
import './globals.css'; // 전역 스타일 임포트
export default function RootLayout({
children, // 필수 prop: 중첩된 라우트 세그먼트 또는 페이지가 여기에 렌더링됨
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}layout.tsx 컴포넌트는 반드시 children prop을 받아야 하며, 이 children은 그 레이아웃이 감싸는 하위 라우트 세그먼트나 page.tsx의 내용이 렌더링될 자리를 나타냅니다. 루트 레이아웃은 반드시 <html>과 <body> 태그를 담아야 하고, 이 교재에서는 <html lang="ko">를 씁니다.
app 안의 어떤 폴더에서도 layout.tsx를 만들 수 있으며, 이를 중첩 레이아웃(Nested Layouts)이라고 합니다. 예를 들어 src/app/dashboard/layout.tsx를 만들면 이 레이아웃은 /dashboard와 그 아래의 모든 경로(/dashboard/settings 등)에 적용됩니다.
import Sidebar from '../../components/Sidebar'; // 가정: 사이드바 컴포넌트
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
);
}이 경우 /dashboard와 그 하위 페이지들은 RootLayout 안에 DashboardLayout이 중첩된 형태로 렌더링됩니다. RootLayout의 children으로 DashboardLayout이 들어가고, DashboardLayout의 children으로 실제 페이지의 내용이 들어가는 구조입니다. 여기서 가져오는 Sidebar는 src/components/Sidebar.tsx에 있다고 가정한 것이므로, 아직 그 파일을 만들지 않았다면 이 코드는 포함 관계로만 읽고 레이아웃 컴포넌트에서 실제 aside를 작성합니다.
page.tsx (페이지 UI)
page.tsx는 특정 라우트 세그먼트만의 고유한 UI(Unique UI)를 렌더링합니다. 폴더 안에 page.tsx가 있어야만 그 폴더의 경로가 접근할 수 있는 페이지(URL)가 되며, page.tsx는 layout.tsx와 달리 children prop을 받지 않고 자신의 UI만 렌더링합니다.
export default function HomePage() {
return (
<div>
<h1>나 혼자 Next.js!</h1>
<p>Next.js 16 App Router와 함께하는 웹 개발 여정</p>
</div>
);
}루트 페이지가 돌려준 이 화면은 루트 레이아웃의 <body> 안, children 자리에 들어갑니다. 다음 대시보드 페이지는 앞에서 만든 DashboardLayout의 children 자리에 들어가므로, /dashboard에서는 사이드바 옆의 <main> 안에 이 내용이 나타납니다.
export default function DashboardPage() {
return (
<div>
<h2>환영합니다, 대시보드입니다!</h2>
<p>여기에 대시보드 콘텐츠가 표시됩니다.</p>
</div>
);
}두 페이지 모두 자기 내용만 돌려주고 <html>이나 사이드바는 다시 쓰지 않습니다. 그런 공통 부분은 레이아웃이 맡으므로, /와 /dashboard 사이를 이동해도 루트 레이아웃은 다시 만들어지지 않고 바뀌는 부분만 새로 그려집니다.
App Router의 파일 규칙 (Convention)
App Router는 layout.tsx와 page.tsx 말고도 여러 특수 파일 이름을 제공해, 라우트마다 특정 UI나 로직을 정의할 수 있게 합니다.
loading.tsx는 그 라우트 세그먼트의 데이터를 다 불러올 때까지 보여 줄 로딩 스피너나 자리 표시 UI를 정의하며 React의 Suspense와 함께 동작합니다. error.tsx는 자신이 감싸는 하위 컴포넌트의 렌더링 오류를 처리하며, React의 에러 바운더리처럼 특정 UI 안에서 생긴 JavaScript 오류를 잡습니다. 같은 세그먼트의 layout.tsx와 template.tsx는 이 오류 처리 범위 바깥에 있고, 에러 바운더리는 클라이언트 컴포넌트여야 하므로 파일 맨 위에 "use client"를 선언해야 합니다. not-found.tsx는 그 라우트에서 내용을 찾을 수 없을 때(404) 보여 줄 UI를 정의합니다.
template.tsx는 레이아웃과 비슷하지만, 그 수준의 세그먼트 키가 바뀌면 새 인스턴스로 마운트되므로 애니메이션처럼 상태를 초기화해야 할 때 쓸모가 있습니다. 다만 더 깊은 세그먼트나 검색 매개변수만 바뀌는 모든 이동에서 상위 template이 다시 마운트되는 것은 아닙니다. default.tsx는 병렬 라우트에서 쓰는 파일로, 전체 페이지를 불러올 때 현재 URL과 맞지 않는 병렬 라우트 슬롯의 활성 상태를 되살릴 수 없으면 보여 줄 UI를 정의하며, 고급 주제라서 뒤에서 자세히 다룹니다.
route.ts(Route Handler)는 서버 쪽 HTTP 엔드포인트를 정의하는 파일로, GET, POST, PUT, DELETE 같은 HTTP 메서드를 처리하는 함수를 작성합니다. 같은 라우트 세그먼트에서 page.tsx와 route.ts가 같은 경로를 동시에 맡을 수는 없으므로, 화면 라우트와 응답 전용 라우트를 나누어 설계합니다. (folder)처럼 괄호로 감싼 라우트 그룹은 URL 경로에 영향을 주지 않고 라우트를 논리적으로 묶거나 레이아웃을 함께 쓸 때 씁니다. 예를 들어 app/(marketing)/about/page.tsx는 /about에 연결되며, 그룹 이름은 URL에서 빠지므로 서로 다른 라우트 그룹이 같은 URL을 만들면 충돌한다는 점을 언제나 확인합니다.
더 깊이: 특수 파일이 서로를 감싸는 순서와 error.tsx가 잡지 못하는 오류
한 세그먼트의 특수 파일은 정해진 순서로 겹쳐 렌더링됩니다. Next.js 문서의 컴포넌트 계층에 따르면 가장 바깥이 layout이고, 그 안에 template, 에러 바운더리인 error, Suspense인 loading, 찾을 수 없음 UI를 처리하는 not-found가 차례로 놓이며, 가장 안쪽에 page나 하위 세그먼트의 layout이 들어갑니다. 하위 세그먼트의 파일들은 이 묶음 전체가 부모 세그먼트의 묶음 안쪽에 다시 들어가는 식으로 중첩됩니다. 본문에서 같은 세그먼트의 layout.tsx와 template.tsx가 error.tsx의 처리 범위 바깥에 있다고 한 것은 이 순서 때문입니다.
이 순서를 따라가면 오류가 어디서 잡히는지 알 수 있습니다. app/dashboard/layout.tsx에서 던진 오류는 app/dashboard/error.tsx가 아니라, 그 레이아웃을 안쪽에 감싸고 있는 부모 세그먼트 app/error.tsx가 처리합니다. 루트 레이아웃 위에는 부모가 없으므로, 루트 레이아웃이나 루트 템플릿의 오류는 app/global-error.tsx가 맡습니다. 이 파일은 활성화되면 루트 레이아웃을 대신하므로 직접 <html>과 <body>를 그려야 하고, 전역 스타일도 자동으로 들어오지 않습니다.
오류 화면에 무엇이 전달되는지도 개발과 프로덕션이 다릅니다. 개발 모드에서는 원래 오류의 메시지가 클라이언트까지 전달되지만, 프로덕션에서 서버 컴포넌트가 던진 오류는 민감한 정보가 새지 않도록 일반적인 메시지와 함께 digest라는 식별자로만 전달됩니다. 이 digest는 서버 로그의 같은 오류를 찾는 데 씁니다. 오류 화면이 받는 복구 함수로는 오류 상태만 지우고 다시 렌더링하는 reset 외에, 하위 내용을 다시 요청해 렌더링하는 retry가 있습니다. retry는 16.2에서 unstable_retry로 추가되어 16.3에서 안정 기능이 되었습니다(Next.js 문서의 error.js).
서버 컴포넌트와 클라이언트 컴포넌트
App Router의 가장 큰 변화 가운데 하나는 서버 컴포넌트(Server Components)와 클라이언트 컴포넌트(Client Components)라는 개념입니다. page.tsx와 layout.tsx는 기본으로 서버 컴포넌트이며, 일반 컴포넌트가 어느 쪽에 속하는지는 지시어뿐 아니라 누가 그 컴포넌트를 가져오는지도 함께 봐야 합니다. 아래 예시의 http://localhost:4000은 실습을 재현하기 위한 로컬 목 API(mock API)의 엔드포인트라고 가정합니다. 목 API는 실제 백엔드 대신 미리 정해 둔 가짜 데이터를 돌려주는 API이며, json-server로 띄우는 방법은 5장 1절에서 다룹니다.
import Counter from '../components/Counter';
// 데이터 페칭 등 서버에서 처리할 로직 작성 가능
export default async function DashboardPage() {
const data = await fetch('http://localhost:4000/dashboard/data');
const jsonData = await data.json();
return (
<div>
<h1>대시보드 데이터:</h1>
<p>{jsonData.message}</p>
{/* 클라이언트 컴포넌트 사용 */}
<Counter />
</div>
);
}이 페이지는 async 함수라서 렌더링 중에 await fetch(...)로 데이터를 기다릴 수 있고, 이 코드는 서버에서만 실행되므로 브라우저는 localhost:4000에 직접 요청하지 않습니다. 반면 화면 안의 Counter는 클릭으로 상태를 바꾸는 상호작용이 필요하므로, 아래처럼 "use client"를 선언한 별도 파일로 둡니다.
"use client"; // 이 지시어가 있으면 클라이언트 컴포넌트로 동작
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}NEXT · CH3-1
서버 Page와 Client Counter의 실행을 나눕니다
본문의 Page는 서버에서 Mock API를 읽고, use client Counter는 상태와 클릭을 처리합니다. 서버의 import가 Client 경계를 없애지는 않습니다.
| 경계 | 포함되는 코드 | 실행·전달되는 것 |
|---|---|---|
| 서버 컴포넌트 | 기본 page/layout과 Server 모듈 그래프의 컴포넌트 | 서버에서 데이터와 UI를 구성; 해당 코드 자체는 클라이언트 번들에 넣지 않음 |
| 클라이언트 진입점 | use client 파일과 그 파일이 가져오는 모듈 | 브라우저에서 상태·이벤트 처리; 첫 로드에서는 서버 HTML로 미리 렌더링될 수 있음 |
| 서버에서 넘긴 UI | Client 컴포넌트의 children/props로 넘긴 Server UI | 클라이언트가 직접 import한 모듈과 다름; Server UI는 서버에서 렌더링 |
| 데이터 전달 | 서버에서 Client 컴포넌트로 주는 props | React가 직렬화할 수 있는 값이어야 하며 브라우저에서 읽을 수 있음 |
- 서버 컴포넌트
- 포함되는 코드: 기본 page/layout과 Server 모듈 그래프의 컴포넌트실행·전달되는 것: 서버에서 데이터와 UI를 구성; 해당 코드 자체는 클라이언트 번들에 넣지 않음
- 클라이언트 진입점
- 포함되는 코드: use client 파일과 그 파일이 가져오는 모듈실행·전달되는 것: 브라우저에서 상태·이벤트 처리; 첫 로드에서는 서버 HTML로 미리 렌더링될 수 있음
- 서버에서 넘긴 UI
- 포함되는 코드: Client 컴포넌트의 children/props로 넘긴 Server UI실행·전달되는 것: 클라이언트가 직접 import한 모듈과 다름; Server UI는 서버에서 렌더링
- 데이터 전달
- 포함되는 코드: 서버에서 Client 컴포넌트로 주는 props실행·전달되는 것: React가 직렬화할 수 있는 값이어야 하며 브라우저에서 읽을 수 있음
DB 접근과 비밀값은 서버에 두되, 렌더된 HTML이나 전달 데이터에 비밀을 포함하지 않습니다. useState·useEffect와 이벤트 핸들러는 클라이언트 경계에서 사용합니다.
위 그림은 서버의 DashboardPage와 클라이언트의 Counter가 실행되는 곳을 나누어 보여 줍니다. 그림의 요약처럼 본문의 Page는 서버에서 목 API를 읽고, "use client"를 단 Counter는 상태와 클릭을 처리하며, 서버 컴포넌트가 가져온다고 클라이언트 쪽이 사라지지는 않습니다. 실선은 import, 점선은 서버의 fetch 요청으로, 기본 서버 컴포넌트인 DashboardPage는 Counter를 가져오고 목 API의 /dashboard/data에 요청합니다. 클라이언트 모듈 그래프에는 Counter와 그것이 직접 가져온 react의 useState가 들어가며, 목 API는 Next의 모듈 바깥에 있습니다. 클라이언트 컴포넌트의 첫 HTML을 미리 보여 주는 일과 브라우저에서 이벤트를 연결하는 일은 별개의 단계입니다.
그림 아래의 표는 네 가지 구분을 정리합니다. 서버 컴포넌트에는 기본 page·layout과 서버 모듈 그래프의 컴포넌트가 들어가며 서버에서 데이터와 UI를 구성하고, 그 코드 자체는 클라이언트 번들에 들어가지 않습니다. 클라이언트 진입점에는 use client 파일과 그 파일이 가져오는 모듈이 들어가며 브라우저에서 상태와 이벤트를 처리하고, 첫 로드에서는 서버 HTML로 미리 렌더링될 수 있습니다. 클라이언트 컴포넌트의 children이나 props로 넘긴 Server UI는 클라이언트가 직접 가져온 모듈과 달리 서버에서 렌더링되며, 서버에서 클라이언트 컴포넌트로 넘기는 props는 React가 직렬화할 수 있는 값이어야 하고 브라우저에서 읽을 수 있습니다. 그림의 마지막 설명처럼 DB 접근과 비밀값은 서버에 두되 렌더된 HTML이나 넘기는 데이터에 비밀을 담지 않으며, useState와 useEffect, 이벤트 처리 함수는 클라이언트 쪽에서 씁니다.
목 API 없이 구조 확인하기
먼저 파일의 구분과 클릭을 확인하려면, 목 API 없이도 실행할 수 있는 /structure-lab 경로를 만듭니다. 앞의 데이터를 가져오는 dashboard/page.tsx는 API가 준비되었을 때의 예로 남겨 두고, API가 없다면 그 파일을 실행 실습에 복사하지 않습니다. 기존 프로젝트의 루트 레이아웃을 그대로 두고, 위의 Counter 코드를 src/app/components/Counter.tsx에 저장한 뒤 다음 파일 하나를 더합니다.
import Counter from '../components/Counter';
export default function StructureLabPage() {
const data = { message: '서버 Page 안의 클라이언트 Counter' };
return (
<section>
<h1>컴포넌트 경계 실습</h1>
<p>{data.message}</p>
<Counter />
</section>
);
}실행 중인 개발 서버의 /structure-lab에 들어가면 메시지 아래에 “현재 카운트: 0”이 보이고, 증가 버튼을 두 번 누르면 2가 됩니다. 이 실습의 data는 코드에 적은 고정값이며 API를 요청한 결과가 아닙니다. 서버의 Page는 UI를 구성하고, Counter의 useState와 클릭 처리는 클라이언트 쪽에 놓았습니다.
Counter를 가져온다고 부모 Page 전체가 클라이언트 컴포넌트로 바뀌지는 않습니다. 반대로 "use client" 파일이 직접 가져온 모듈은 그 클라이언트 모듈 그래프에 들어가므로, Server UI를 children으로 넘긴 경우와 직접 가져온 경우를 구별합니다. 처음 화면에서는 클라이언트 컴포넌트도 서버 HTML로 미리 보일 수 있고, 브라우저의 JavaScript가 연결된 뒤에 클릭에 반응합니다. 이 구분과 첫 렌더링은 Next.js 문서의 서버·클라이언트 컴포넌트에서 확인할 수 있습니다. 서버 컴포넌트와 클라이언트 컴포넌트의 개념은 App Router의 핵심이며, 어떤 컴포넌트를 언제 쓰는지에 대한 이해가 프로젝트의 구조를 정합니다.
정리
- App Router에서 폴더는 URL 세그먼트를 만들고,
page.tsx같은 정해진 파일이 그 세그먼트의 UI를 공개합니다. - 라우트 그룹
(name), 비공개 폴더_name, 병렬 라우트 슬롯@name은 URL의 세그먼트가 되지 않습니다. layout.tsx는children을 받아 하위 라우트를 감싸는 공유 UI이고, 루트 레이아웃은<html>과<body>를 담아야 합니다.loading.tsx,error.tsx,not-found.tsx,template.tsx,route.ts같은 특수 파일이 라우트마다 로딩·오류·찾을 수 없음 UI와 엔드포인트를 정의하며,error.tsx는 클라이언트 컴포넌트여야 합니다.page.tsx와layout.tsx는 기본으로 서버 컴포넌트이고, 상태와 이벤트가 필요한 부분만"use client"로 클라이언트 컴포넌트로 만듭니다.
연습 문제
1. 데이터는 서버에, 버튼은 어디에
DashboardPage의 API 요청은 서버에서 처리한다는 조건을 유지합니다. 화면에 카운터 버튼을 더하려고 Page 전체에 "use client"를 붙이는 방법과, 지금처럼 Counter 파일에만 지시어를 두는 방법을 비교해 보세요. 어느 쪽이 서버 데이터 코드와 상태·이벤트를 그대로 나누며, Counter를 가져온 부모 Page의 구분도 바뀔까요?
해설 보기
Counter에만 지시어를 두면 서버 Page의 데이터 코드와 클라이언트의 상태·이벤트 역할이 나뉩니다. 부모가 클라이언트 진입점을 가져오는 것은 허용되는 조합이며, 부모까지 저절로 클라이언트 컴포넌트가 되지는 않습니다.
Page 전체를 클라이언트 진입점으로 바꾸면 그 파일과 그것이 직접 가져온 모듈의 구분이 달라져, 기존 서버 데이터 코드의 배치를 그대로 유지하는 해결책이 아닙니다. 화면에 버튼이 있다는 사실보다 지시어와 import의 관계를 봅니다.
2. URL이 되는 폴더와 되지 않는 폴더
src/app/(shop)/products/page.tsx, src/app/_lib/format.ts, src/app/blog/page.tsx가 있습니다. 각 파일이 만드는 URL은 무엇일까요?
해설 보기
(shop)은 라우트 그룹이라 URL에서 빠지므로 src/app/(shop)/products/page.tsx는 /products가 됩니다. _lib은 비공개 폴더라 라우팅에서 빠지고 format.ts는 일반 소스이므로 URL을 만들지 않습니다. src/app/blog/page.tsx는 /blog입니다.
3. 레이아웃이 받는 children
src/app/layout.tsx와 src/app/dashboard/layout.tsx가 있을 때, /dashboard/settings에 들어가면 어떤 순서로 컴포넌트가 중첩될까요? src/app/dashboard/settings/page.tsx는 children을 받을까요?
해설 보기
RootLayout의 children으로 DashboardLayout이 들어가고, DashboardLayout의 children으로 settings/page.tsx의 내용이 들어갑니다. 곧 RootLayout → DashboardLayout → SettingsPage 순서로 중첩됩니다.
page.tsx는 그 세그먼트의 최종 UI이므로 children을 받지 않고 자신의 UI만 렌더링합니다.
다음 글 페이지 생성에서는 새 라우트 세그먼트에 page.tsx를 만들어 페이지를 더하는 방법을 자세히 살펴봅니다.