본문으로 건너뛰기

안동민 개발노트

본문 시작

페이지 컴포넌트 작성

URL의 공개 UI인 page.tsx를 작성하고 서버 데이터 페칭과 동적 params를 결합해 목록·상세 페이지를 만듭니다.

이제까지 Next.js App Router의 기본적인 라우팅 원리와 구조에 대해 충분히 익히셨을 겁니다.

이제 그 핵심인 페이지 컴포넌트(Page Component)를 어떻게 효과적으로 작성하고 활용하는지 심도 있게 다룰 차례입니다.

페이지 컴포넌트는 사용자가 웹 브라우저에서 직접 마주하게 되는 UI의 가장 바깥 영역이자, 특정 URL 경로에 매핑되는 Next.js의 핵심 빌딩 블록입니다.

이 절에서는 페이지 컴포넌트의 기본적인 역할부터 데이터 페칭, 그리고 동적인 파라미터 활용까지, 실제 애플리케이션 개발에 필요한 구체적인 작성 방법을 살펴보겠습니다.

먼저 page.tsx가 URL, 서버 데이터, 상태 파일과 어떤 계약을 맺는지 큰 그림으로 봅니다.

page.tsx는 URL 매칭이 도착하는 최종 화면 계약이다

라우터가 segment를 해석하고 layout을 쌓은 뒤 page가 params와 searchParams로 화면을 완성한다.

  1. request
    URL 도착

    path와 query를 분리

  2. match
    segment tree

    정적·동적 폴더를 따라 route 선택

  3. compose
    layout chain

    상위 공통 UI와 경계를 바깥부터 조립

  4. render
    page.tsx

    해당 URL의 마지막 콘텐츠를 반환

  5. state
    loading / error

    준비와 실패를 가까운 경계에서 대체


페이지 컴포넌트의 역할과 특징

아래 다이어그램은 페이지 컴포넌트를 단순 UI 함수가 아니라 라우트의 URL, 데이터, 상태 경계를 묶는 파일로 읽는 법을 정리합니다.

페이지 컴포넌트는 라우트의 URL, 데이터, 상태 경계를 한곳에서 묶는다

page.tsx를 작성할 때는 UI 함수만 보지 말고 라우터가 어떤 값을 넘기고 어떤 상태 파일이 주변을 받치는지 함께 확인한다.

역할구체적 의미코드 신호점검 질문
URL의 끝점해당 경로에 접근했을 때 렌더링되는 최종 UIapp/welcome/page.tsx이 파일의 실제 URL은 무엇인가
layout의 자식상위 layout.tsx의 children 위치에 들어감children prop 없음공통 UI를 page에 넣고 있지 않은가
서버 컴포넌트DB, 파일, 서버 API를 직접 사용할 수 있음use client 없음이 로직을 브라우저로 보낼 필요가 있는가
비동기 렌더fetch 결과를 기다린 뒤 HTML 생성export default async function로딩/오류 파일이 준비되어 있는가
동적 라우트URL 조각을 params로 받아 조회[id]/page.tsxparams를 await하고 검증하는가

App Router에서 페이지 컴포넌트는 app 디렉터리 내의 특정 라우트 세그먼트 폴더 안에 위치한 page.tsx (또는 .js, .jsx) 파일입니다.

주요 특징
  • URL 매핑: app/your-route/page.tsx 파일은 your-route 경로에 접근했을 때 렌더링되는 UI를 정의합니다.
  • 최종 UI 렌더링: 레이아웃 컴포넌트와 달리, 페이지 컴포넌트는 children prop을 받지 않습니다. 대신, 레이아웃의 children prop 위치에 자신만의 고유한 UI를 렌더링합니다.
  • 기본적으로 서버 컴포넌트: 별도의 "use client" 지시어가 없다면, 페이지 컴포넌트는 서버 컴포넌트로 동작합니다. 이는 페이지 컴포넌트 내에서 직접 데이터베이스에 접근하거나 서버 전용 코드를 작성할 수 있음을 의미합니다.
  • 비동기 함수 지원: 서버 컴포넌트인 페이지 컴포넌트는 async / await 문법을 사용하여 비동기 데이터 페칭을 직접 수행할 수 있습니다.

기본적인 페이지 컴포넌트 작성하기

가장 기본적인 페이지 컴포넌트는 단순한 React 함수 컴포넌트와 동일하게 작성됩니다.

src/app/welcome/page.tsx (새로 생성할 페이지)
// 이 컴포넌트는 기본적으로 서버 컴포넌트로 동작합니다.
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을 생성하므로, 사용자는 더 빠른 초기 로딩과 향상된 SEO를 경험할 수 있습니다.

실습 재현성을 위해 본 절의 API 예시는 로컬 Mock 서버(json-server, http://localhost:4000)를 기준으로 작성합니다.

포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두면 다른 트랙 실습과 동시에 실행해도 충돌을 줄일 수 있습니다.

실행 전 npx json-server --watch db.json --port 4000으로 mock 서버를 먼저 띄워 두세요.

페이지 컴포넌트 내에서 async 함수로 데이터를 가져올 수 있습니다.

Next.js는 이 비동기 작업이 완료될 때까지 기다린 후 페이지를 렌더링합니다.

src/app/posts/page.tsx (새로 생성할 페이지)
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>
  );
}

실습: src/app/posts 폴더를 만들고 그 안에 page.tsx 파일을 위 내용으로 생성합니다.

그리고 동적 라우트를 위한 src/app/posts/[id]/page.tsx 파일도 다음과 같이 생성합니다.

src/app/posts/[id]/page.tsx
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로 접속하여 게시물 목록을 확인하고, 각 게시물의 더 보기 링크를 클릭하여 상세 페이지로 이동해 보세요.

모든 데이터 페칭이 서버에서 이루어져 페이지 로딩이 매우 빠르게 느껴질 것입니다.


동적 파라미터 params 활용하기

동적 라우트([slug], [id] 등)를 정의한 경우, 페이지 컴포넌트는 params라는 prop을 통해 URL에서 추출된 동적인 값을 전달받습니다.

이는 페이지 콘텐츠를 해당 파라미터에 따라 다르게 렌더링할 때 사용됩니다.

src/app/posts/[id]/page.tsx 예시에서는 await paramsid를 꺼내 특정 게시물의 데이터를 가져왔습니다.

src/app/posts/[dynamicParamName]/page.tsx
// 페이지 컴포넌트의 prop 타입 정의
interface MyPageProps {
  params: Promise<{
    dynamicParamName: string; // [dynamicParamName]
    // 만약 Catch-all 세그먼트 [[...slug]]라면:
    // slug?: string[];
  }>;
  searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; // 쿼리 파라미터 (다음 절에서 다룸)
}

export default async function MyDynamicPage({ params }: MyPageProps) {
  const { dynamicParamName } = await params;
  // ...
}

params 객체의 키(key)는 폴더 이름의 대괄호 안에 정의된 이름([dynamicParamName])과 정확히 일치해야 합니다.


페이지 컴포넌트를 작성할 때는 라우트 위치, 서버 데이터 준비, 동적 입력, 로딩/에러 상태 파일을 한 흐름으로 점검하면 좋습니다.

페이지 작성은 URL 결정에서 시작해 데이터와 상태 경계까지 이어진다

새 page.tsx를 만들 때는 파일을 먼저 쓰기보다 URL, 데이터 출처, 동적 입력, 로딩/오류 상태를 순서대로 확정한다.

순서결정할 것작성 위치검증 기준
1. URL사용자가 접근할 경로app/posts/page.tsx/posts에서 렌더링되는가
2. 화면 책임page가 직접 그릴 고유 UIpage.tsx return공통 UI는 layout에 남겼는가
3. 데이터서버에서 미리 가져올 데이터async page + fetch캐시/재검증 정책을 정했는가
4. 동적 값params id, slug, query[id]/page.tsxparams Promise를 await하는가
5. 상태 파일loading, error, not-found같은 세그먼트 폴더느린 요청과 실패 화면이 있는가

실제 페이지를 설계할 때는 page.tsx를 단독 파일로 보지 말고, URL 입력과 서버 데이터 준비, 상태 파일, 필요한 클라이언트 island가 만나는 라우트 계약으로 보면 실수할 지점이 줄어듭니다.

page.tsx는 URL 입력을 데이터 조회 키로 바꾸는 경계다

실제 페이지는 URL을 그대로 화면에 뿌리는 곳이 아니라, 동적 값을 검증해 서버 데이터 조회와 화면 상태로 연결하는 곳이다.

입력page에서 처리결과주의
정적 경로 /welcomeprops 없이 UI 반환환영 페이지 HTML단순 화면이면 async가 필요 없음
목록 /posts서버에서 posts fetch게시물 목록 HTMLLink는 현재 문법으로 사용
상세 /posts/12params를 await해 id 추출id=12 게시물 조회params.id를 동기 접근하지 않기
쿼리 /posts?page=2searchParams를 await해 필터 생성페이지네이션된 목록문자열/배열/undefined 처리
없는 데이터notFound 또는 error 경계404/오류 UI빈 화면으로 실패를 숨기지 않기

아래 다이어그램은 페이지 컴포넌트를 작성할 때 서버에서 끝낼 일과 클라이언트로 넘길 일을 구분하는 기준을 보여줍니다.

page에서 끝낼 일과 클라이언트로 넘길 일을 분리해야 번들이 작아진다

페이지 컴포넌트는 기본 서버 컴포넌트이므로 데이터 조회와 정적 렌더는 서버에서 끝내고, 이벤트가 필요한 부분만 작은 클라이언트 컴포넌트로 분리한다.

작업page.tsx에 둔다클라이언트로 분리한다판단 기준
데이터 조회DB/API fetch, 파일 읽기브라우저 전용 API가 필요할 때만비밀 값이 필요한가
목록 렌더초기 HTML 목록 출력검색 입력, 정렬 토글이벤트 핸들러가 있는가
상세 조회params await 후 id 검증좋아요 버튼, 공유 버튼상태 변경이 필요한가
오류 처리throw, notFoundreset 버튼이 있는 error.tsx사용자 재시도가 필요한가
스타일/구조layout과 page에서 정적 구조드래그, 모달, 애니메이션상호작용이 핵심인가

페이지 컴포넌트의 추가 기능 (선택 사항)

  • loading.tsx 와 함께 사용: 데이터 페칭 중인 동안 사용자에게 로딩 UI를 보여주고 싶다면, 해당 페이지 컴포넌트와 동일한 라우트 세그먼트 폴더에 loading.tsx 파일을 생성하면 됩니다.

    src/app/posts/loading.tsx
    export default function Loading() {
      return <div>게시물 목록을 불러오는 중입니다...</div>;
    }

    이제 /posts로 접속하면 데이터 로딩이 완료되기 전까지 게시물 목록을 불러오는 중입니다... 메시지가 잠시 표시될 것입니다.

  • error.tsx 와 함께 사용: 페이지 컴포넌트나 그 하위 컴포넌트에서 에러가 발생했을 때 사용자에게 친절한 에러 메시지를 보여주고 싶다면, error.tsx 파일을 생성할 수 있습니다.

    src/app/posts/error.tsx
    "use client"; // Error Boundaries는 클라이언트 컴포넌트여야 합니다.
    
    import { useEffect } from 'react';
    
    export default function Error({
      error,
      reset,
    }: {
      error: Error & { digest?: string };
      reset: () => void;
    }) {
      useEffect(() => {
        // 에러 로깅 서비스 등에 에러를 기록할 수 있습니다.
        console.error(error);
      }, [error]);
    
      return (
        <div>
          <h2>문제가 발생했습니다!</h2>
          <p>{error.message}</p>
          <button
            onClick={
              // 에러를 재설정하고 다시 시도합니다.
              () => reset()
            }
          >
            다시 시도
          </button>
        </div>
      );
    }

    error.tsx 파일은 클라이언트 컴포넌트여야 하며, React Error Boundary처럼 동작합니다.

페이지 컴포넌트는 라우트 단위 UI를 담당합니다.

서버 컴포넌트와 파라미터 처리 방식에 맞춰 작성해야 빠른 응답과 안정적인 데이터 흐름을 유지할 수 있습니다.

페이지 품질은 라우팅, 데이터, 상태 파일의 순서로 결정된다

page.tsx가 안정적으로 동작하려면 URL 입력이 조회 키로 바뀌고, 로딩/오류/404가 같은 라우트 세그먼트에서 자연스럽게 이어져야 한다.

품질 지점좋은 신호나쁜 신호개선 방향
라우팅파일 위치와 URL이 바로 대응그룹/동적 세그먼트가 섞여 URL이 모호최종 URL 표를 먼저 작성
데이터서버에서 필요한 만큼 fetch클라이언트에서 다시 전체 fetchpage async와 캐시 정책 정리
paramsawait params 후 검증params.id 동기 접근Promise 타입과 notFound 분기 적용
로딩loading.tsx가 느린 구간을 받음빈 화면으로 대기세그먼트별 loading 파일 추가
오류error.tsx와 reset 경계가 있음throw가 전체 앱을 깨뜨림오류 경계와 로깅 위치 분리

마지막으로 page.tsx가 화면 UI, 데이터 페칭, params 활용을 어디까지 맡는지 정리합니다.

page.tsx는 화면 UI, 서버 데이터, URL 입력을 모두 받지만 전부 혼자 해결하지 않는다

페이지 컴포넌트는 라우트의 중심 파일이지만 공통 레이아웃, 상태 경계, 클라이언트 상호작용은 주변 파일과 나눠야 한다.

책임page.tsx주변 파일분리 기준
공통 껍데기직접 담당하지 않음layout.tsx여러 페이지가 공유하면 layout
고유 화면해당 URL의 본문 UIpage.tsx그 경로에만 필요한 내용
데이터 준비서버 fetch, params 검증loading.tsx, error.tsx느림/실패 상태는 주변 파일
동적 URLparams/searchParams 처리[id] 폴더, generateStaticParams빌드 시 만들 경로가 있는가
상호작용초기 상태만 전달Client ComponentonClick, useState, useRouter가 필요한가