App Router 구조
App Router의 폴더 세그먼트와 layout·page 파일 규칙을 익히고 서버·클라이언트 컴포넌트의 출발점을 구분합니다.
Next.js 16의 App Router는 파일과 폴더의 위치로 웹 애플리케이션의 라우팅, 레이아웃, 서버 로직을 정의합니다.
2장에서 프로젝트 구조를 간략하게 살펴보았지만, 이 절에서는 App Router의 핵심 원리와 그 구조를 더 깊이 있게 정리하겠습니다.
App Router의 핵심 원리
App Router는 src/app (또는 프로젝트 루트의 app) 디렉터리 내의 파일 시스템을 사용하여 라우트(경로)를 정의합니다.
여기서 가장 중요한 두 가지 규칙이 있습니다.
app디렉터리 안의 일반 폴더는 URL 경로의 한 부분을 나타내는 라우트 세그먼트가 됩니다. 예를 들어,app/dashboard폴더는/dashboard경로의 후보가 됩니다. 다만 실제로 접근 가능한 페이지가 되려면 해당 세그먼트 아래에page.tsx같은 공개 UI 파일이 필요합니다.- 예외도 있습니다.
(marketing)같은 라우트 그룹은 URL에 포함되지 않고,_components같은 private folder는 라우팅에서 제외됩니다.@modal같은 병렬 라우트 슬롯도 URL 세그먼트가 아닙니다.
- 폴더 자체는 UI를 직접 렌더링하지 않습니다. 폴더 안의
page.tsx,layout.tsx와 같은 특정 파일명들이 실제로 브라우저에 표시될 UI를 정의하거나, 해당 라우트에 대한 특별한 동작을 제어합니다.
이 두 가지 규칙을 통해 Next.js는 URL 구조와 UI 역할을 파일 시스템 안에서 함께 표현합니다.
필수 파일: layout.tsx와 page.tsx
App Router 기반의 Next.js 애플리케이션에서 가장 기본이 되는 두 가지 파일은 바로 layout.tsx와 page.tsx입니다.
layout.tsx (공유 레이아웃)
layout.tsx 파일은 해당 폴더와 그 하위 폴더의 모든 라우트 세그먼트에 적용되는 공유 UI(Shared UI)를 정의합니다.
-
최상위
layout.tsx(Root Layout): 이 교재의 기본 단일 라우트 트리에서는src/app/layout.tsx가 가장 상위 레이아웃을 정의합니다.각 라우트 트리에는
<html>과<body>를 반환하는 Root Layout이 필요합니다. 라우트 그룹이나[locale]같은 세그먼트 아래에 서로 다른 Root Layout을 두는 고급 구조에서는 루트의src/app/layout.tsx없이 여러 트리를 구성할 수도 있습니다.src/app/layout.tsx import './globals.css'; // 전역 스타일 임포트 export default function RootLayout({ children, // 필수 prop: 중첩된 라우트 세그먼트 또는 페이지가 여기에 렌더링됨 }: { children: React.ReactNode; }) { return ( <html lang="ko"> <body>{children}</body> </html> ); }childrenProp:layout.tsx컴포넌트는 반드시children이라는 prop을 받아야 합니다. 이children은 해당 레이아웃이 감싸는 하위 라우트 세그먼트 또는page.tsx파일의 내용이 렌더링될 위치를 나타냅니다.<html lang="ko">와<body>태그: 루트 레이아웃은 반드시<html>과<body>태그를 포함해야 합니다.
-
중첩 레이아웃 (Nested Layouts):
app디렉터리 내의 어떤 폴더에서도layout.tsx파일을 생성할 수 있습니다.예를 들어,
src/app/dashboard/layout.tsx를 만들면, 이 레이아웃은/dashboard경로와 그 하위 모든 경로(예:/dashboard/settings)에 적용됩니다.src/app/dashboard/layout.tsx 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으로 실제 페이지 콘텐츠가 들어가는 구조입니다.
page.tsx (페이지 UI)
page.tsx 파일은 특정 라우트 세그먼트의 고유한 UI(Unique UI)를 렌더링합니다.
-
라우트의 최종 UI: 폴더 안에
page.tsx파일이 있어야만 해당 폴더 경로가 접근 가능한 페이지(URL)가 됩니다. -
단독 렌더링:
page.tsx파일은layout.tsx파일과 달리childrenprop을 받지 않습니다. 오직 자신의 UI만을 렌더링합니다.src/app/page.tsx (루트 페이지) export default function HomePage() { return ( <div> <h1>나 혼자 Next.js!</h1> <p>Next.js 16 App Router와 함께하는 웹 개발 여정</p> </div> ); }src/app/dashboard/page.tsx (대시보드 페이지) export default function DashboardPage() { return ( <div> <h2>환영합니다, 대시보드입니다!</h2> <p>여기에 대시보드 콘텐츠가 표시됩니다.</p> </div> ); }
App Router의 파일 컨벤션 (Convention)
layout.tsx와 page.tsx 외에도 App Router는 다양한 특수 파일명들을 제공하여 라우트별로 특정 UI나 로직을 정의할 수 있게 합니다.
-
loading.tsx- 해당 라우트 세그먼트의 데이터 로딩이 완료될 때까지 보여줄 로딩 스피너나 플레이스홀더 UI를 정의합니다.
- React의 Suspense와 함께 작동합니다.
-
error.tsx- 자신이 감싸는 하위 컴포넌트의 렌더 오류를 처리합니다. 같은 세그먼트의
layout.tsx와template.tsx는 이 경계 바깥에 있습니다. - React Error Boundary와 유사하게 작동하여 특정 UI 컴포넌트 내부에서 발생하는 자바스크립트 오류를 잡아낼 수 있습니다.
- 오류 경계는 클라이언트 컴포넌트여야 하므로 파일 상단의
"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가 동시에 같은 경로를 담당할 수는 없으므로, 화면 라우트와 응답 전용 라우트를 분리해 설계합니다.
- 서버 측 HTTP 엔드포인트를 정의합니다.
-
(folder)(라우트 그룹)- 괄호로 감싼 폴더는 URL 경로에 영향을 주지 않고, 라우트들을 논리적으로 그룹화하거나 레이아웃을 공유할 때 사용합니다. 예를 들어,
app/(marketing)/about/page.tsx는/about경로에 매핑됩니다. - 서로 다른 라우트 그룹이 같은 URL을 만들면 충돌이 발생하므로 그룹 이름은 URL에서 빠진다는 점을 항상 확인해야 합니다.
- 괄호로 감싼 폴더는 URL 경로에 영향을 주지 않고, 라우트들을 논리적으로 그룹화하거나 레이아웃을 공유할 때 사용합니다. 예를 들어,
서버 컴포넌트와 클라이언트 컴포넌트
App Router의 가장 큰 변화 중 하나는 서버 컴포넌트(Server Components)와 클라이언트 컴포넌트(Client Components)의 개념입니다.
page.tsx와 layout.tsx는 기본적으로 서버 컴포넌트입니다. 일반 컴포넌트가 어느 쪽에 포함되는지는 지시어뿐 아니라 누가 가져오는지도 함께 봅니다.
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와 이벤트 핸들러는 클라이언트 경계에서 사용합니다.
앞의 Sidebar 임포트는 해당 컴포넌트가 src/components/Sidebar.tsx에 있다는 가정입니다. 아직 그 파일을 만들지 않았다면 중첩 레이아웃 코드는 포함 관계로 읽고, 레이아웃 컴포넌트에서 실제 aside를 작성합니다. 아래 서버 데이터 예시와 그 뒤의 고정 데이터 실습도 서로 다른 단계입니다.
실습 재현성을 위해 아래 예시의 http://localhost:4000은 로컬 Mock API 엔드포인트라고 가정합니다.
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>
);
}"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>
);
}예시
먼저 파일 경계와 클릭을 확인하려면 Mock API 없이도 읽을 수 있는 /structure-lab 경로를 만듭니다. 앞의 데이터 페칭 dashboard/page.tsx는 API가 준비된 경우의 예시로 남기고, API가 없다면 그 파일을 실행 실습에 복사하지 않습니다. 기존 프로젝트의 Root Layout을 유지하고, 위 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으로 넘긴 경우와 직접 import한 경우를 구별합니다. 첫 화면에서는 Client Component도 서버 HTML로 미리 표시될 수 있고, 브라우저의 자바스크립트가 연결된 뒤 클릭에 반응합니다. 이 경계와 최초 렌더링은 공식 서버·클라이언트 컴포넌트 설명에서 확인할 수 있습니다.
독립 연습: 데이터는 서버에, 버튼은 어디에?
원래 DashboardPage의 API 요청은 서버에서 처리한다는 조건을 유지합니다. 화면에 카운터 버튼을 추가하려고 Page 전체에 "use client"를 붙이는 안과, 현재처럼 Counter 파일에만 지시어를 두는 안을 비교하세요. 어느 안이 서버 데이터 코드와 상태·이벤트 경계를 그대로 나누며, Counter를 import한 부모 Page의 경계도 바뀔까요?
풀이: Counter에만 지시어를 두면 서버 Page의 데이터 코드와 클라이언트 상태·이벤트 역할이 분리됩니다. 부모가 Client 진입점을 import하는 것은 허용되는 조합이며 부모까지 자동으로 Client가 되지는 않습니다. Page 전체를 Client 진입점으로 바꾸면 그 파일과 직접 import한 모듈의 경계가 달라져, 기존 서버 데이터 코드의 배치를 그대로 유지하는 해결책이 아닙니다. 화면에 버튼이 있다는 사실보다 지시어와 import 경계를 봅니다.
서버 컴포넌트와 클라이언트 컴포넌트의 개념은 App Router의 핵심이며, 어떤 컴포넌트를 언제 사용해야 하는지에 대한 이해가 프로젝트 구조를 결정합니다.