☁️ goormTIL | Next.js #60

매루·2025년 12월 3일

goormTIL

목록 보기
58/67
post-thumbnail

📅 2025-12-03

➡️ Next.js 렌더링 실습, Hydration, App 예약어 파일에 대해 새롭게 알게 된 것 또는 헷갈리는 부분 정리


🔎 학습 리마인드

📌 렌더링 실습

  • json-server 설치 및 실행
    • json-server 설치
      npm i json-server
    • db.json
      {
        "books": [
          {
            "id": 1,
            "slug": "clean-code",
            "title": "클린 코드",
            "author": "로버트 C. 마틴",
            "category": "programming",
            "description": "좋은 코드를 작성하기 위한 원칙과 패턴을 다루는 고전적인 개발 서적입니다.",
            "descriptionHtml": "<p>좋은 코드를 작성하기 위한 원칙과 패턴을 다루는 고전적인 개발 서적입니다.</p>",
            "price": {
              "amount": "32000",
              "currencyCode": "KRW"
            },
            "coverImage": "https://picsum.photos/seed/cleancode/400/600",
            "isNew": false,
            "rating": 4.8,
            "tags": ["프로그래밍", "베스트셀러", "클린코드"]
          },
          {
            "id": 2,
            "slug": "refactoring",
            "title": "리팩터링 2판",
            "author": "마틴 파울러",
            "category": "programming",
            "description": "기존 코드를 개선하면서도 동작을 유지하는 리팩터링 기법을 설명합니다.",
            "descriptionHtml": "<p>기존 코드를 개선하면서도 동작을 유지하는 리팩터링 기법을 설명합니다.</p>",
            "price": {
              "amount": "45000",
              "currencyCode": "KRW"
            },
            "coverImage": "https://picsum.photos/seed/refactoring/400/600",
            "isNew": true,
            "rating": 4.7,
            "tags": ["프로그래밍", "리팩터링"]
          },
          {
            "id": 3,
            "slug": "javascript-deep-dive",
            "title": "모던 자바스크립트 Deep Dive",
            "author": "이웅모",
            "category": "programming",
            "description": "자바스크립트의 동작 원리를 깊이 있게 파헤치는 책입니다.",
            "descriptionHtml": "<p>자바스크립트의 동작 원리를 깊이 있게 파헤치는 책입니다.</p>",
            "price": {
              "amount": "42000",
              "currencyCode": "KRW"
            },
            "coverImage": "https://picsum.photos/seed/jsdeepdive/400/600",
            "isNew": true,
            "rating": 4.9,
            "tags": ["자바스크립트", "프론트엔드"]
          },
          {
            "id": 4,
            "slug": "database-design",
            "title": "실전 데이터베이스 설계",
            "author": "홍길동",
            "category": "database",
            "description": "서비스 개발에 바로 적용할 수 있는 데이터베이스 모델링과 설계 전략을 다룹니다.",
            "descriptionHtml": "<p>서비스 개발에 바로 적용할 수 있는 데이터베이스 모델링과 설계 전략을 다룹니다.</p>",
            "price": {
              "amount": "38000",
              "currencyCode": "KRW"
            },
            "coverImage": "https://picsum.photos/seed/dbdesign/400/600",
            "isNew": false,
            "rating": 4.3,
            "tags": ["데이터베이스", "백엔드"]
          },
          {
            "id": 5,
            "slug": "frontend-architecture",
            "title": "프론트엔드 아키텍처 가이드",
            "author": "김프론트",
            "category": "frontend",
            "description": "규모가 커지는 프론트엔드 프로젝트에서 구조를 설계하고 유지보수하는 방법을 소개합니다.",
            "descriptionHtml": "<p>규모가 커지는 프론트엔드 프로젝트에서 구조를 설계하고 유지보수하는 방법을 소개합니다.</p>",
            "price": {
              "amount": "29000",
              "currencyCode": "KRW"
            },
            "coverImage": "https://picsum.photos/seed/frontendarch/400/600",
            "isNew": true,
            "rating": 4.5,
            "tags": ["프론트엔드", "아키텍처"]
          }
        ],
        "cart": []
      }
    • json-server 실행
      npm run json-server db.json --port 4000

  • 타입 정의
    // src/types/book.ts
    
    export interface BookPrice {
      amount: string;        // "32000"
      currencyCode: string;  // "KRW"
    }
    
    export interface Book {
      id: number;
      slug: string;
      title: string;
      author: string;
      category: string;
      description: string;
      descriptionHtml: string;
      price: BookPrice;
      coverImage: string;
      isNew: boolean;
      rating: number;
      tags: string[];
    }

  • BookList 컴포넌트
    import { Book } from '@/types/book';
    import React from 'react';
    
    const BookList = ({ books }: { books: Book[] }) => {
        return (
            <div>
                {books.map((book) => (
                    <div key={book.id} className="mt-8">
                        <div className="border p-4 flex gap-6">
                            <img src={book.coverImage} alt={book.title} width={200} />
    
                            <div>
                                <h2 className="text-xl font-bold">{book.title}</h2>
                                <p className="text-sm text-gray-300">{book.author}</p>
                                <p>{book.description}</p>
                            </div>
                        </div>
                    </div>
                ))}
            </div>
        );
    };
    
    export default BookList;

  • 렌더링 방식 비교
    방식특징fetch 옵션언제 사용?
    SSG빌드 시 HTML 생성cache: "force-cache"변경 없는 정적 페이지 (블로그, 문서)
    SSR요청 시마다 HTML 생성cache: "no-store"항상 최신 데이터 필요 (검색 결과, 관리자 페이지)
    ISRSSG + 일정 주기 업데이트next: { revalidate: 10 }정적이지만 가끔 변경되는 페이지 (상품 목록)
    CSR브라우저에서 fetch하여 렌더링없음 (useEffect로 요청 수행)상호작용 많음, 초기 load 빠르게 SSG와 혼합

💡 SSG (Static Site Generation)

  • 빌드할 때 HTML을 미리 만들어 두고, 요청 오면 바로 전달
import { Book } from '@/types/book';
import BookList from '@/components/BookList';

const SSGBooks = async () => {
    const res = await fetch('http://localhost:4000/books', {
        cache: 'force-cache', // 캐싱
    });
    const books: Book[] = await res.json();

    return (
        <div>
            <h1>Books</h1>
            <p>개발 서적</p>
            <BookList books={books} />
        </div>
    );
};

export default SSGBooks;

💡 SSR (Server Side Rendering)

  • 요청이 들어올 때마다 서버가 그 자리에서 렌더링
import { Book } from '@/types/book';
import BookList from '../BookList';

const SSRBooks = async () => {
    const res = await fetch('http://localhost:4000/books', {
        cache: 'no-store', // ✅ 매 요청마다 새로 가져오기 (SSR)
    });
    const books: Book[] = await res.json();

    return (
        <div>
            <h1>Books SSR 예제(항상 최신 목록)</h1>
            <p>개발 서적</p>
            <BookList books={books} />
        </div>
    );
};

export default SSRBooks;

💡 ISR (Incremental Static Regeneration)

  • SSG + 일정 주기마다 최신 데이터로 자동 갱신
import { Book } from '@/types/book';
import BookList from '../BookList';

const ISRBooks = async () => {
    const res = await fetch('http://localhost:4000/books', {
        next: { revalidate: 10 }, // 10초마다 백그라운드에서 재생성 가능
    });
    const books: Book[] = await res.json();

    return (
        <div>
            <h1>Books ISR 예제</h1>
            <p>개발 서적</p>
            <BookList books={books} />
        </div>
    );
};

export default ISRBooks;

💡 CSR (Client Side Rendering)

  • 브라우저(클라이언트)에서 렌더링하는 순수 SPA 스타일
'use client';

import { useEffect, useState } from 'react';

import { Book } from '@/types/book';
import BookList from '../BookList';

const CSRBooks = () => {
    const [books, setBooks] = useState<Book[]>([]);

    useEffect(() => {
        const fetchBooks = async () => {
            try {
                const res = await fetch('http://localhost:4000/books');
                const books: Book[] = await res.json();

                setBooks(books);
            } catch (err) {
                console.log(err);
            }
        };

        fetchBooks();
    }, []);

    return (
        <div>
            <h1>Books CSR 예제</h1>
            <p>개발 서적</p>
            <BookList books={books} />
        </div>
    );
};

export default CSRBooks;

📌 Hydration

  • 서버에서 렌더링된 HTML 마크업에 기반하여 클라이언트 측에서 자바스크립트 이벤트와 상태를 연결하는 과정

💡 CSR 관점 (순수 SPA)

CSR 방식에서는 전체 화면이 브라우저에서만 렌더링

  1. 서버는 거의 비어 있는 index.html 하나만 전달
  2. 브라우저는 JavaScript 번들을 다운로드하고 실행
  3. JS 코드가 실행된 후 UI가 처음부터 렌더링되고 상호작용 가능

처음부터 끝까지 JS가 직접 그리는 방식

  • TTV(Time to Visual)TTI(Time to Interactive) → 화면이 보이기 시작하는 시점과 실제로 클릭/입력 등의 인터랙션이 가능한 시점이 거의 동시에 발생

💡 SSR / SSG / ISR 관점

공통적으로 2단계를 거침

  1. pre-rendering
    • 서버에서 미리 HTML을 렌더링해서 브라우저에 전달
    • 사용자는 아주 빠르게 내용을 볼 수 있음 → TTV 짧음
    • 하지만 이 시점의 HTML은 아직 빈 껍데기이므로 버튼 눌러도 반응 없음
  2. hydration
    • 브라우저가 JS 번들을 다운 + 실행
    • 기존 HTML을 다시 그리지 않고, 그 위에 이벤트 핸들러, 상태 관리, 인터랙션 등을 연결

📌 Next.js App 예약어 파일

💡 layout.tsx

  • 해당 경로(폴더)와 그 하위 경로에 공유되는 UI를 정의하는 파일
  • 프로젝트 생성 시 app/layout.tsx가 자동 생성되며, 이는 전역(Global) 레이아웃으로 동작

Root Layout의 규칙

  • 반드시 <html><body> 태그를 포함해야 함
  • <head>에 직접 <title>이나 <meta>를 작성하는 것은 권장되지 않음 → 대신 Metadata API를 사용하여 관리

왜 Metadata API를 쓰나?

  • 중복 제거 : 여러 페이지에서 메타 정보를 정의해도 Next.js가 알아서 조합 & 중복 제거
  • 스트리밍 최적화 : 메타데이터도 스트리밍 렌더링 파이프라인에 포함되어 초기 로딩 속도 개선

children props

  • 부모 컴포넌트가 감싸는 자식 컴포넌트를 의미

💡 template

  • layout과 역할은 비슷하지만 렌더링 타이밍과 상태 유지 측면에서 차이가 있음
    • 라우트 이동 시마다 새로운 인스턴스가 생성
    • 즉, 상태가 유지되지 않음
    • 이동할 때마다 DOM이 재생성되며, useEffect, animation 등의 사이드 이펙트도 다시 실행
  • 기본적으로 서버 컴포넌트지만 "use client"로 클라이언트 컴포넌트로 변경 가능

언제 사용하나?

  • 페이지 전환 시 애니메이션을 매번 다시 실행하고 싶을 때
  • 페이지 뷰 로깅, 추적 코드를 경로 이동 시마다 새로 실행하고 싶을 때
  • 특정 구간에서 상태 리셋을 의도적으로 하고 싶을 때

💡 not-found.tsx

  • 존재하지 않는 페이지이거나 notFound() 함수가 호출된 경우 렌더링되는 UI
  • 특정 경로(세그먼트)에서 notFound()가 호출되면 해당 세그먼트의 not-found.tsx가 렌더링됨
  • 기본적으로 서버 컴포넌트

HTTP 상태 코드 동작

  • 스트리밍 응답인 경우: 200
  • 비스트리밍 응답 또는 실제로 없는 페이지: 404

루트 app/not-found.tsx

  • 전역에서 notFound()가 발생했을 때
  • 앱에서 처리하지 않는 URL 접근 시 → 전역 404 페이지 역할
// app/not-found.tsx
export default function NotFound() {
  return <div>존재하지 않는 페이지입니다.</div>;
}

💡 error.tsx (에러 경계, Error Boundary)

  • 특정 세그먼트(폴더)와 그 하위 경로 전체에서 발생한 예상치 못한 런타임 에러를 잡아 UI로 보여주는 컴포넌트
  • 에러가 발생한 부분만 UI를 교체하고, 상위 레이아웃은 그대로 유지

특징

  • 위치: app/(segment)/error.tsx
  • 반드시 클라이언트 컴포넌트 ("use client" 필수)
  • 두 가지 props를 받음
    • error: 서버/클라이언트에서 던져진 Error 객체 (프로덕션에서는 민감 정보는 제거됨)
    • reset(): 에러 경계를 초기화해서 해당 세그먼트를 다시 렌더링 시도하는 함수
// app/rendering/error.tsx
"use client";

import { useEffect } from "react";

export default function ErrorBoundary({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 에러 로깅 등
    console.error(error);
  }, [error]);

  return (
    <main className="flex min-h-[300px] flex-col items-center justify-center gap-4">
      <h2 className="text-xl font-semibold">문제가 발생했습니다.</h2>
      <p className="text-sm text-gray-500">
        잠시 후 다시 시도해주세요. (에러 메시지: {error.message})
      </p>
      <button
        onClick={() => reset()}
        className="rounded bg-blue-600 px-4 py-2 text-sm text-white"
      >
        다시 시도
      </button>
    </main>
  );
}

💡 global-error.tsx (전역 에러)

  • 루트 레이아웃 전체에서 발생하는 에러를 처리하는 전역 에러 경계
  • 앱에서 치명적인 오류가 발생하면, Root Layout 전체를 fallback UI로 통째로 대체

특징

  • 위치: 루트 app/global-error.tsx
  • app/layout.tsx / app/template.tsx 에서 발생하는 모든 에러 처리 가능
  • 반드시 클라이언트 컴포넌트 ("use client" 필수)
  • Root Layout 전체를 대체하므로 <html> / <body> 태그를 직접 작성해야 함
// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html lang="ko">
      <body className="min-h-screen bg-gray-50">
        <main className="flex min-h-screen flex-col items-center justify-center gap-4">
          <h1 className="text-2xl font-bold">앱에 문제가 발생했습니다.</h1>
          <p className="text-sm text-gray-500">
            잠시 후 다시 시도해주세요. ({error.message})
          </p>
          <button
            onClick={() => reset()}
            className="rounded bg-red-600 px-4 py-2 text-sm text-white"
          >
            다시 시도
          </button>
        </main>
      </body>
    </html>
  );
}

💡 Metadata

SEO란?

  • 웹사이트나 웹페이지가 검색 엔진 결과에서 상위에 노출되도록 최적화하는 작업

Next.js에서의 Metadata

  • <head> 태그를 직접 작성하지 않고 Metadata API를 사용
  1. Static Metadata (정적 메타데이터)

    • 빌드 시점에서 고정되는 메타데이터
    • page.tsx 또는 layout.tsx에서 export const metadata로 정의

    예시: page.tsx

    // src/app/page.tsx
    import type { Metadata } from "next";
    
    export const metadata: Metadata = {
      title: "Goorm Front End Next App",
      description: "This is awesome Website",
    };
    
    export default function Home() {
      return <div>안녕하세요 구름 프론트엔드 입니다.</div>;
    }
    • page.tsx에서만 해당 metadata 적용

    예시: layout.tsx

    // src/app/layout.tsx
    import type { Metadata } from "next";
    import { Inter } from "next/font/google";
    import "./globals.css";
    
    const inter = Inter({ subsets: ["latin"] });
    
    export const metadata: Metadata = {
      title: "Sparta Next App",
      description: "This is awesome Website",
    };
    
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <html lang="ko">
          <body className={inter.className}>{children}</body>
        </html>
      );
    }
    • 해당 레이아웃의 모든 하위 경로에 공통으로 적용

  1. Dynamic Metadata (동적 메타데이터)

    • 동적 라우트(/posts/[id])에서 URL 파라미터나 외부 데이터에 따라 메타데이터를 변경하고 싶을 때 사용
    • generateMetadata 함수 이용

    예시

    // 예: src/app/test/[id]/page.tsx
    type Props = {
      params: {
        id: string;
      };
    };
    
    export function generateMetadata({ params }: Props) {
      return {
        title: `Detail 페이지 : ${params.id}`,
        description: `Detail 페이지 : ${params.id}`,
      };
    }
    
    const TestDetailPage = ({ params }: Props) => {
      return <div>Detail 페이지 : {params.id}</div>;
    };
    
    export default TestDetailPage;
    • URL에 따라 메타데이터가 동적으로 변환

0개의 댓글