페이지 컴포넌트 작성
URL의 공개 UI인 page.tsx를 작성하고 서버 데이터 페칭과 동적 params를 결합해 목록·상세 페이지를 만듭니다.
5장에서는 페이지와 레이아웃, 템플릿, 로딩 UI 컴포넌트를 작성하는 방법을 자세히 다룹니다. 지금까지 App Router의 기본적인 라우팅 원리와 구조를 익혔으니, 이제 그 중심인 페이지 컴포넌트(Page Component)를 효과적으로 작성하고 활용하는 방법을 깊이 살펴볼 차례입니다. 페이지 컴포넌트는 특정 URL의 고유한 화면을 정의하며, 상위 레이아웃이 제공하는 공통 UI 안에 렌더링됩니다. 이 글에서는 페이지 컴포넌트의 기본 역할부터 데이터 페칭, 동적 매개변수의 활용까지 실제 애플리케이션에 필요한 작성 방법을 살펴봅니다.
페이지 컴포넌트의 역할과 특징
App Router에서 페이지 컴포넌트는 app 디렉터리의 특정 라우트 세그먼트 폴더 안에 있는 page.tsx(또는 .js, .jsx) 파일입니다. app/your-route/page.tsx는 your-route 경로에 들어왔을 때 렌더링되는 UI를 정의하며, 레이아웃과 달리 children prop을 받지 않고 레이아웃의 children 자리에 자신만의 고유한 UI를 렌더링합니다.
따로 "use client" 지시어가 없다면 페이지 컴포넌트는 서버 컴포넌트로 동작하므로, 페이지 안에서 바로 데이터베이스에 접근하거나 서버 전용 코드를 작성할 수 있습니다. 서버 컴포넌트인 페이지 컴포넌트는 async/await 문법으로 비동기 데이터 요청을 직접 할 수도 있습니다.
기본적인 페이지 컴포넌트 작성하기
가장 기본적인 페이지 컴포넌트는 일반 React 함수 컴포넌트와 똑같이 작성합니다. 다만 Next.js가 이 파일을 페이지로 쓰려면 컴포넌트를 export default로 내보내야 하며, 함수 이름은 무엇이든 상관없습니다. 이 페이지가 어느 URL에 연결되는지는 함수 이름이 아니라 파일이 놓인 폴더 경로(src/app/welcome)가 정합니다.
// 이 컴포넌트는 기본적으로 서버 컴포넌트로 동작합니다.
export default function WelcomePage() {
return (
<div>
<h2>새로운 환영 페이지</h2>
<p>Next.js App Router로 만든 간단한 페이지입니다.</p>
</div>
);
}src/app/welcome 폴더를 만들고 그 안에 위 내용으로 page.tsx를 만든 뒤 http://localhost:3000/welcome에 들어가 보세요. 페이지가 정상적으로 렌더링되는 것을 확인할 수 있습니다.
페이지 컴포넌트에서 데이터 요청하기
페이지 컴포넌트가 서버 컴포넌트라는 점은 데이터 페칭에서 큰 장점입니다. 서버에서 데이터를 조회해 내용을 담은 HTML을 보낼 수 있으며, 실제 응답 시간은 API의 지연과 캐시, 렌더링 방식에 따라 달라집니다.
실습을 재현하기 위해 이 글의 API 예시는 로컬 목 API(json-server, http://localhost:4000)를 기준으로 작성합니다. 포트는 4000번을 목 API, 4100번을 별도의 백엔드나 실시간 서버로 나누어 두면 다른 트랙의 실습과 함께 실행해도 충돌을 줄일 수 있습니다. 먼저 명령을 실행할 작업 폴더에 다음 db.json을 만듭니다. 세 항목은 이 글의 최소 실습 데이터이며, id는 숫자이고 title과 body는 문자열이므로 아래 목록의 substring과 상세 화면에 필요한 값이 들어 있습니다.
{
"posts": [
{ "id": 1, "title": "페이지의 역할", "body": "page.tsx는 이 URL에서 보여줄 본문을 만듭니다." },
{ "id": 2, "title": "링크와 경로", "body": "목록의 숫자 id를 링크에 넣으면 상세 경로가 됩니다." },
{ "id": 3, "title": "상세 데이터", "body": "URL의 문자열 id로 같은 게시물의 상세 데이터를 조회합니다." }
]
}이 교재의 Next.js 실습은 json-server를 0.17.4로 고정해 npx [email protected] --watch db.json --port 4000으로 목 API를 띄웁니다. 현행 1.x는 아직 베타이고 _limit 쿼리 대신 _page·_per_page를 쓰며 ID를 문자열로 다루므로, 이 글의 _limit과 숫자 ID를 그대로 쓰려면 0.17.4가 필요합니다. 이 명령은 학습자의 Next.js 프로젝트와 별개로 실행하는 API 안내이며, Next.js 개발 서버와 목 API는 서로 다른 프로세스이므로 둘 다 실행되어 있어야 합니다. 프로젝트에 이미 쓰는 db.json이 있다면 덮어쓰기보다 별도의 실습 폴더나 백업을 쓰고, generateStaticParams를 실행하는 빌드에서도 이 API에 접근할 수 있어야 합니다.
페이지 컴포넌트 안에서 async 함수로 데이터를 가져오고, 데이터가 준비되면 페이지의 내용을 완성합니다. <Suspense>로 감싼 부분이 있으면 그 부분의 로딩 UI를 먼저 보낼 수 있습니다. 아래의 revalidate는 주석이므로 지금의 코드에 재검증 캐시 정책이 적용되지는 않습니다.
import Link from 'next/link';
interface Post {
id: number;
title: string;
body: string;
}
// 이 함수는 서버에서 실행되어 데이터를 가져옵니다.
async function getPosts(): Promise<Post[]> {
const res = await fetch('http://localhost:4000/posts?_limit=20', {
// revalidate 옵션을 사용하여 데이터 캐싱 전략을 지정할 수 있습니다.
// next: { revalidate: 60 } // 60초 이상 지난 뒤 다음 요청을 계기로 재검증
});
if (!res.ok) {
// 에러 발생 시 처리
throw new Error('Failed to fetch posts');
}
const payload = await res.json();
return payload;
}
export default async function PostsPage() { // async 키워드를 붙여 비동기 컴포넌트로 만듭니다.
const posts = await getPosts(); // 서버에서 데이터 페칭
return (
<div>
<h1>모든 게시물</h1>
<ul>
{posts.map((post) => (
<li key={post.id} style={{ marginBottom: '15px', border: '1px solid #eee', padding: '10px' }}>
<h3>{post.title}</h3>
<p>{post.body.substring(0, 100)}...</p>
{/* Link 컴포넌트로 동적 라우트 연결 */}
<Link href={`/posts/${post.id}`}>더 보기</Link>
</li>
))}
</ul>
</div>
);
}getPosts는 목 API가 실패 응답을 주면 res.ok 검사에서 오류를 던지고, API가 꺼져 있으면 fetch 자체가 실패하므로, 어느 쪽이든 목록 대신 오류 처리로 넘어갑니다. 정상이면 받은 배열을 map으로 돌며 게시물마다 제목, 본문의 앞 100자, 상세 링크를 그립니다. substring 뒤의 ...는 본문 길이와 상관없이 붙으므로, 100자보다 짧은 이 글의 시드 본문에도 말줄임표가 표시됩니다.
src/app/posts 폴더를 만들고 그 안에 위 내용으로 page.tsx를 만든 뒤, 동적 라우트를 위한 src/app/posts/[id]/page.tsx도 다음과 같이 만듭니다. 폴더 이름 [id]가 동적 세그먼트이므로, /posts/3으로 들어오면 3이 params의 id로 들어옵니다.
import Link from 'next/link';
interface PostDetailPageProps {
params: Promise<{
id: string; // URL에서 추출될 게시물 ID
}>;
}
interface Post {
id: number;
title: string;
body: string;
}
// 특정 게시물 데이터를 가져오는 함수
async function getPost(id: string): Promise<Post> {
const res = await fetch(`http://localhost:4000/posts/${id}`);
if (!res.ok) {
throw new Error('Failed to fetch post');
}
return res.json();
}
// 동적 라우트를 위한 generateStaticParams (SSG 사용 시)
export async function generateStaticParams() {
const res = await fetch('http://localhost:4000/posts?_limit=20');
const payload = await res.json();
const posts: Post[] = payload;
// 상위 10개의 게시물만 미리 생성하도록 제한 (실제 프로젝트에서는 전체 또는 필요한 부분만)
return posts.slice(0, 10).map((post) => ({
id: post.id.toString(), // id는 문자열이어야 합니다.
}));
}
export default async function PostDetailPage({ params }: PostDetailPageProps) {
const { id } = await params;
const post = await getPost(id); // 서버에서 특정 게시물 데이터 페칭
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link href="/posts">목록으로 돌아가기</Link>
</div>
);
}http://localhost:3000/posts에 들어가 게시물 목록을 확인하고, 각 게시물의 “더 보기” 링크를 눌러 상세 페이지로 이동해 보세요. generateStaticParams의 주석에 적힌 “상위 10개”는 응답 배열의 앞 10개를 뜻할 뿐 인기순으로 정렬한 것이 아니며, 미리 만들지 않은 ID도 기본 정책에서는 요청이 들어왔을 때 처리할 수 있습니다.
지금의 getPost는 404를 포함한 실패 응답에 Error를 던집니다. 없는 게시물을 전용 404 화면으로 보여 주려면 상태를 구분해 notFound()를 불러야 하며, 타입 선언은 외부 JSON의 모양을 실행할 때 검증해 주지 않습니다.
목록의 링크가 상세 조회의 id로 이어집니다
서버 목록의 mock API 결과가 링크가 되고, 링크 이동의 문자열 ID로 상세 mock API를 호출해 출력합니다. 빌드 시 generateStaticParams의 별도 경로는 위 관계도와 표에서 유지하며 목록 요청 뒤 빌드가 실행된다고 설명하지 않습니다.
NEXT · CH5-1
목록의 링크가 상세 조회의 id로 이어집니다
본문의 [email protected] 예제입니다. /posts 목록과 /posts/[id] 상세의 실제 코드 흐름을 연결합니다.
화살표는 조회·응답으로 화면을 만드는 흐름과 URL 탐색을 잇습니다. 아래 빌드 후보 생성은 별도 경로이며 API 응답 오류와 데이터 존재 조건은 본문에서 따로 판단합니다.
목록 page · 서버 조회
getPosts()→/posts?_limit=20응답으로 title·body와 링크를 렌더합니다.Link · 항목별 상세 URL
post.id로/posts/${post.id}를 만듭니다. 예를 들어 id=3이면/posts/3입니다.상세 page · params 다음 조회
await params의id: "3"→getPost(id)→ Mock API/posts/3을 읽고 상세 본문을 렌더합니다.
별도 빌드 경로 · 후보를 앞 10개로 제한
generateStaticParams()는 목록 응답 앞 10개의 id를 문자열로 반환합니다. 인기순 정렬이 아니며 미등록 id도 기본 정책에서는 요청 시 처리할 수 있습니다.
getPost는 404를 포함한 실패 응답에 Error를 던집니다. notFound() 분기는 현재 코드에 없고, revalidate:60은 주석입니다. API 실행·응답 데이터가 전제이며 위 id=3은 설명용 예입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 목록 서버 page가 Mock API 배열로 목록 링크를 만들고 그 링크의 URL 탐색이 상세 서버 page의 문자열 id 조회로 이어진다. Mock API와 Next 서버는 별도 프로세스다. 아래 빌드 후보 생성은 탐색 다음에 호출하는 단계가 아니라 독립 경로이며 응답 앞 10개 id를 문자열로 선택한다. 성공 데이터 조건의 그림이며 API 실패는 본문의 Error 분기로 처리한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 5 | 목록 서버 page가 Mock API 배열로 목록 링크를 만들고 그 링크의 URL 탐색이 상세 서버 page의 문자열 id 조회로 이어진다. Mock API와 Next 서버는 별도 프로세스다. 아래 빌드 후보 생성은 탐색 다음에 호출하는 단계가 아니라 독립 경로이며 응답 앞 10개 id를 문자열로 선택한다. 성공 데이터 조건의 그림이며 API 실패는 본문의 Error 분기로 처리한다. |
| 13 | /posts/3로 탐색 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 목록 자료와 링크 서버 목록 page가 별도 Mock API의 배열로 목록 링크를 만듭니다. | API: 별도 프로세스 · 출력: 목록 링크 · 빌드 후보: 독립 경로 | 사용자가 선택할 상세 링크가 생깁니다. |
| 링크 URL 탐색 선택한 링크의 URL이 상세 서버 page에 문자열 id로 전달됩니다. | 입력: URL 문자열 id · 대상: 상세 server page | 새 탐색 요청의 조회 입력이 준비됩니다. |
| 상세 조회와 출력 상세 page가 mock API를 조회하여 정상 데이터의 상세 화면을 만듭니다. | 조회: 상세 Mock API · 정상: 상세 출력 · API 실패: 본문 Error 분기 | 새 요청에서 목록과 별개의 상세 자료를 표시합니다. |
목록의 링크가 상세 조회의 id로 이어집니다
서버 목록의 mock API 결과가 링크가 되고, 링크 이동의 문자열 ID로 상세 mock API를 호출해 출력합니다. 빌드 시 generateStaticParams의 별도 경로는 위 관계도와 표에서 유지하며 목록 요청 뒤 빌드가 실행된다고 설명하지 않습니다.
NEXT · CH5-1
목록의 링크가 상세 조회의 id로 이어집니다
본문의 [email protected] 예제입니다. /posts 목록과 /posts/[id] 상세의 실제 코드 흐름을 연결합니다.
화살표는 조회·응답으로 화면을 만드는 흐름과 URL 탐색을 잇습니다. 아래 빌드 후보 생성은 별도 경로이며 API 응답 오류와 데이터 존재 조건은 본문에서 따로 판단합니다.
목록 page · 서버 조회
getPosts()→/posts?_limit=20응답으로 title·body와 링크를 렌더합니다.Link · 항목별 상세 URL
post.id로/posts/${post.id}를 만듭니다. 예를 들어 id=3이면/posts/3입니다.상세 page · params 다음 조회
await params의id: "3"→getPost(id)→ Mock API/posts/3을 읽고 상세 본문을 렌더합니다.
별도 빌드 경로 · 후보를 앞 10개로 제한
generateStaticParams()는 목록 응답 앞 10개의 id를 문자열로 반환합니다. 인기순 정렬이 아니며 미등록 id도 기본 정책에서는 요청 시 처리할 수 있습니다.
getPost는 404를 포함한 실패 응답에 Error를 던집니다. notFound() 분기는 현재 코드에 없고, revalidate:60은 주석입니다. API 실행·응답 데이터가 전제이며 위 id=3은 설명용 예입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 목록 서버 page가 Mock API 배열로 목록 링크를 만들고 그 링크의 URL 탐색이 상세 서버 page의 문자열 id 조회로 이어진다. Mock API와 Next 서버는 별도 프로세스다. 아래 빌드 후보 생성은 탐색 다음에 호출하는 단계가 아니라 독립 경로이며 응답 앞 10개 id를 문자열로 선택한다. 성공 데이터 조건의 그림이며 API 실패는 본문의 Error 분기로 처리한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 5 | 목록 서버 page가 Mock API 배열로 목록 링크를 만들고 그 링크의 URL 탐색이 상세 서버 page의 문자열 id 조회로 이어진다. Mock API와 Next 서버는 별도 프로세스다. 아래 빌드 후보 생성은 탐색 다음에 호출하는 단계가 아니라 독립 경로이며 응답 앞 10개 id를 문자열로 선택한다. 성공 데이터 조건의 그림이며 API 실패는 본문의 Error 분기로 처리한다. |
| 13 | /posts/3로 탐색 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 목록 자료와 링크 서버 목록 page가 별도 Mock API의 배열로 목록 링크를 만듭니다. | API: 별도 프로세스 · 출력: 목록 링크 · 빌드 후보: 독립 경로 | 사용자가 선택할 상세 링크가 생깁니다. |
| 링크 URL 탐색 선택한 링크의 URL이 상세 서버 page에 문자열 id로 전달됩니다. | 입력: URL 문자열 id · 대상: 상세 server page | 새 탐색 요청의 조회 입력이 준비됩니다. |
| 상세 조회와 출력 상세 page가 mock API를 조회하여 정상 데이터의 상세 화면을 만듭니다. | 조회: 상세 Mock API · 정상: 상세 출력 · API 실패: 본문 Error 분기 | 새 요청에서 목록과 별개의 상세 자료를 표시합니다. |
위 그림은 목록의 링크가 상세 조회의 id로 이어지는 과정을, 이 글의 json-server 0.17.4 예제로 보여 줍니다. 목록의 서버 page가 getPosts()로 목 API의 /posts?_limit=20을 조회해 받은 배열로 목록과 링크를 만들고, 목록 화면의 Link로 /posts/3에 이동하면 상세 서버 page가 await params로 id: "3"을 읽어 getPost("3")로 같은 게시물의 title과 body를 렌더링합니다. 그림의 캡션이 밝히듯 목 API와 Next 서버는 별개의 프로세스이며, 아래쪽의 빌드 후보 생성은 이동 다음에 부르는 단계가 아니라 독립된 경로로, 응답의 앞 10개 id를 문자열로 고릅니다.
그림 아래의 설명은 각 단계를 코드와 이어 줍니다. 목록 page는 응답으로 title과 body, 링크를 렌더링하고, Link는 post.id로 /posts/${post.id}를 만들어 id=3이면 /posts/3이 됩니다. 상세 page는 params의 id: "3"을 읽고 getPost(id)로 목 API를 조회해 상세 본문을 렌더링하며, 별도의 빌드 경로인 generateStaticParams()는 목록 응답의 앞 10개 id를 문자열로 돌려줍니다. 그림의 마지막 설명처럼 getPost는 404를 포함한 실패 응답에 Error를 던지고, notFound() 분기는 지금의 코드에 없으며, revalidate: 60은 주석입니다. 그림의 id=3은 API가 실행 중이고 데이터가 있다는 조건의 설명용 예입니다.
한 게시물이 목록에서 상세로 이어지는지 따라가 보기
먼저 목 API를 시작한 뒤 http://localhost:4000/posts와 http://localhost:4000/posts/3에서 JSON이 나오는지 확인합니다. 페이지의 오류를 조사하기 전에 API가 실행 중이고 body가 문자열인지 확인하는 것입니다. 그다음 Next.js 프로젝트의 개발 서버를 실행하고 /posts에서 세 제목이 보이는지 확인합니다. 세 번째의 “더 보기”를 누르면 주소가 /posts/3이 되며, await params로 받은 값은 숫자 3이 아니라 문자열 "3"입니다.
상세 화면에 세 번째 body가 보이는지 확인하고 “목록으로 돌아가기”를 누릅니다. 링크는 데이터를 넘기는 함수 호출이 아니라 상세 URL로 이동하는 UI이며, 상세 페이지가 그 URL의 id로 다시 조회합니다. 빌드를 확인할 때도 목 API가 실행 중이어야 하며, 이 세 항목에서는 slice(0, 10)이 세 항목을 모두 고릅니다. API를 끈 상태에서 빌드가 실패했다면, 라우트 폴더의 이름만의 문제로 판단하지 않습니다.
동적 매개변수 params 활용하기
동적 라우트([slug], [id] 등)를 정의했다면 페이지 컴포넌트는 params prop으로 URL에서 꺼낸 동적인 값을 받으며, 이 값에 따라 페이지의 내용을 다르게 렌더링합니다. 위의 src/app/posts/[id]/page.tsx 예시에서는 await params로 id를 꺼내 특정 게시물의 데이터를 가져왔습니다.
다음은 prop의 타입을 읽기 위한 일부 코드입니다. [id]와 다른 키의 폴더를 같은 위치에 더하라는 뜻이 아니며, 생략한 반환 UI를 채워야 완전한 페이지가 됩니다.
// 페이지 컴포넌트의 prop 타입 정의
interface MyPageProps {
params: Promise<{
dynamicParamName: string; // [dynamicParamName]
// 만약 Catch-all 세그먼트 [[...slug]]라면:
// slug?: string[];
}>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; // 쿼리 문자열(?q=...)의 값, 이 글에서는 쓰지 않음
}
export default async function MyDynamicPage({ params }: MyPageProps) {
const { dynamicParamName } = await params;
// ...
}params 객체의 키는 폴더 이름의 대괄호 안에 정의한 이름([dynamicParamName])과 정확히 같아야 합니다. Next.js 16에서는 npx next typegen이 만드는 전역 도우미 타입 PageProps<'/posts/[id]'>로 라우트에 맞는 params와 searchParams의 타입을 쓸 수도 있습니다(Next.js 문서의 page).
페이지 컴포넌트와 함께 쓰는 파일 (선택 사항)
데이터를 요청하는 동안 사용자에게 로딩 UI를 보여 주고 싶다면, 그 페이지와 같은 라우트 세그먼트 폴더에 loading.tsx를 만듭니다. Next.js는 이 파일을 같은 폴더의 page.tsx를 감싸는 Suspense의 대체 화면(fallback)으로 씁니다. 그래서 PostsPage가 await getPosts()를 기다리는 동안에는 레이아웃을 그대로 둔 채 그 자리에 아래 컴포넌트가 먼저 보이고, 데이터가 준비되면 실제 목록으로 바뀝니다.
export default function Loading() {
return <div>게시물 목록을 불러오는 중입니다...</div>;
}/posts 세그먼트가 데이터를 기다리는 동안 이 메시지가 쓰일 수 있으며, 내용이 이미 준비되었거나 미리 불러와져 있다면 메시지가 눈에 보이지 않을 수도 있습니다. 페이지나 그 하위 컴포넌트에서 오류가 났을 때 친절한 오류 메시지를 보여 주고 싶다면 error.tsx를 만들 수 있습니다.
"use client"; // Error Boundaries는 클라이언트 컴포넌트여야 합니다.
import { useEffect } from 'react';
export default function Error({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
// 에러 로깅 서비스 등에 에러를 기록할 수 있습니다.
console.error(error);
}, [error]);
return (
<div>
<h2>문제가 발생했습니다!</h2>
<p>{error.message}</p>
<button
onClick={
// 하위 내용을 서버에서 다시 받아 렌더링합니다.
() => retry()
}
>
다시 시도
</button>
</div>
);
}error.tsx는 클라이언트 컴포넌트여야 하며, 자신이 감싸는 하위 컴포넌트의 렌더링 오류를 처리합니다. 같은 세그먼트의 layout.tsx나 이벤트 처리 함수의 오류까지 모두 잡는 것은 아닙니다. retry()는 그 하위 내용을 서버에서 다시 받아 렌더링해 보려는 시도이며, 원인이 그대로라면 같은 오류가 다시 날 수 있습니다. retry는 Next.js 16.3부터 쓸 수 있습니다. 함께 받는 reset()은 다시 요청하지 않고 오류 상태만 지운 뒤 렌더링하므로, 이 예제처럼 서버의 목록 조회가 실패했을 때는 목 API를 다시 켜도 같은 오류 화면이 남을 수 있습니다.
정리
- 페이지 컴포넌트는 그 URL의 고유한 UI를 정의하는
page.tsx로,children을 받지 않고 기본으로 서버 컴포넌트입니다. - 서버 컴포넌트인 페이지는
async/await로 데이터를 직접 요청할 수 있으며, 이 교재의 목 API는 json-server 0.17.4로 실행합니다. - 동적 라우트의
params는 Promise이며 값은 문자열이므로, 숫자 ID라도"3"처럼 문자열로 다룹니다. - 요청이 실패하면 지금의 코드는
Error를 던지므로, 없는 데이터를 404로 보여 주려면notFound()분기를 따로 둡니다. - 같은 세그먼트에
loading.tsx와error.tsx를 두어 로딩 UI와 오류 UI를 정의하며,error.tsx는 클라이언트 컴포넌트입니다.
연습 문제
1. 미리 만들 후보와 요청 처리
다른 실습 데이터에 숫자 ID가 1부터 12까지 차례로 있고 API가 이 배열을 그대로 돌려준다고 가정합니다. 지금의 generateStaticParams가 돌려주는 항목의 수와 첫 항목의 id 타입은 무엇일까요? 미리 만들 후보에 없는 /posts/12는 곧바로 404가 될까요? API의 /posts/99가 실패 응답을 보내면 지금의 코드는 어떤 경로를 따를까요?
해설 보기
앞 10개를 고르므로 후보는 10개이며, 첫 항목은 { id: "1" }처럼 문자열 값을 가집니다. 이 예제는 dynamicParams = false를 설정하지 않았으므로, 후보 목록에 없다는 이유만으로 /posts/12를 막지 않습니다. 요청을 처리할 수 있는지와 API에 12번 데이터가 있는지는 별개의 조건입니다.
99번 조회의 실패 응답은 getPost의 !res.ok에 걸려 Error를 던지며, 지금의 함수에는 notFound() 분기가 없습니다. /posts 아래의 error.tsx가 이 렌더링 오류를 처리할 수 있지만, 후보 목록에 없다는 사실과 API가 데이터를 찾지 못했다는 사실을 같은 것으로 설명하면 안 됩니다. 개발 모드에서 함수가 불리는 시점과 프로덕션 빌드에서 후보를 만드는 시점도 구별합니다.
2. 없는 게시물을 404로 보여 주기
/posts/99처럼 없는 게시물에 들어오면 오류 화면 대신 찾을 수 없음 화면을 보여 주고 싶습니다. getPost를 어떻게 고치면 될까요?
해설 보기
next/navigation의 notFound를 가져와, 응답 상태가 404일 때는 Error를 던지는 대신 notFound()를 부르고, 그 밖의 실패 응답에서만 Error를 던지도록 나눕니다. 예를 들어 if (res.status === 404) notFound();를 if (!res.ok) 검사보다 앞에 둡니다.
이렇게 하면 없는 게시물은 가장 가까운 not-found.tsx(없으면 기본 404 화면)로, 서버 오류 같은 다른 실패는 error.tsx로 처리되어 두 경우를 사용자에게 다르게 안내할 수 있습니다.
3. 목 API를 끈 채로 빌드하기
목 API를 끈 상태에서 npm run build를 실행했더니 빌드가 실패했습니다. 라우트 폴더의 이름이 잘못된 것일까요?
해설 보기
그렇게 단정할 수 없습니다. posts/[id]/page.tsx의 generateStaticParams는 빌드할 때 목 API의 /posts를 조회하므로, API가 꺼져 있으면 요청이 실패해 빌드가 멈출 수 있습니다. 빌드 로그의 첫 오류가 네트워크 연결 실패인지 확인하고, 목 API를 실행한 뒤 다시 빌드해 봅니다.
다음 글 레이아웃 컴포넌트 생성 및 중첩에서는 레이아웃 컴포넌트를 만들고 중첩해 여러 페이지의 공통 UI를 구성하는 방법을 더 깊이 살펴봅니다.