공유된 따라하기 · 넥스트JS

Next.js 16 실무형 메뉴 목록 페이지 만들기 — SSR, 상태, 이벤트, 컴포넌트 분리

대상 React 기초와 Next.js App Router 구조를 알고, Spring Boot 백엔드 API를 연동해 실제 서비스처럼 동작하는 목록 페이지를 만들고 싶은 개발자

목표 http://localhost:8080/api/menus 데이터를 Next.js 16의 Server Component와 Client Component를 적절히 조합해 조회·검색·필터링하는 실무형 메뉴 목록 페이지를 완성한다

완성물 app/(anon)/menus/page.js 를 중심으로, 서버에서 데이터를 가져와 렌더링하고, 클라이언트에서 검색과 필터 이벤트를 처리하며, 코로케이션된 컴포넌트로 구조를 정리한 메뉴 목록 페이지

준비물

  • Node.js 20.9 이상 (Next.js 16 의 최소 요구 버전), Next.js 16.3 이상 프로젝트 (npx create-next-app@latest 로 생성)
  • Spring Boot 백엔드 서버가 http://localhost:8080 에서 실행 중
  • PostgreSQL 데이터베이스에 menu 테이블이 존재하며, JdbcMenuRepository 가 정상 동작
  • 백엔드 /api/menusname, category, minPrice, maxPrice 쿼리 파라미터를 지원 (4단계부터 사용)
  • app/(anon)/menus/page.module.css 파일 존재 (없으면 새로 생성)

이 따라하기는 next.config.js 에서 cacheComponents 옵션을 켜지 않은 기본 설정을 기준으로 합니다.


STEP 1 / 8서버에서 메뉴 데이터 가져오기 — SSR의 필요성

0개 완료 · 8개 남음

  1. 이 단계에서는 Next.js Server Component 안에서 fetch 로 Spring Boot API에서 메뉴 목록을 가져와 화면에 바인딩합니다. 왜 클라이언트에서 useEffect 로 데이터를 가져오지 않고 서버에서 가져오는지, SSR의 이점을 체감합니다.

    1-1. 백엔드 API 응답 구조 확인

    터미널에서 다음 명령을 실행해 API가 정상 동작하는지 확인합니다.

    curl http://localhost:8080/api/menus

    Windows PowerShell 에서는 curlInvoke-WebRequest 의 별칭일 수 있으므로 curl.exe http://localhost:8080/api/menus 로 실행합니다.

    응답은 JSON 배열이며, 각 항목은 다음과 같은 구조입니다:

    [
      {
        "id": 1,
        "korName": "아메리카노",
        "engName": "Americano",
        "price": 3500,
        "description": "진한 에스프레소와 물",
        "imgSrc": "/images/menus/americano.svg",
        "categoryId": 1,
        "createTime": "2026-01-15"
      }
    ]

    1-2. page.js 를 Server Component 로 수정

    파일: app/(anon)/menus/page.js

    import Link from "next/link";
    import styles from "./page.module.css";
    
    // Server Component 는 async 함수를 지원합니다.
    // 이 함수는 서버에서 실행되며, 그 결과는 HTML(첫 요청)과
    // RSC Payload(클라이언트 내비게이션) 형태로 브라우저에 전달됩니다.
    export default async function MenusPage() {
      // 서버에서 직접 fetch. 이 코드는 브라우저에서 실행되지 않습니다.
      const res = await fetch("http://localhost:8080/api/menus", {
        // Next.js 15 부터 fetch 는 기본적으로 캐시되지 않습니다.
        // 다만 요청 시점 API(searchParams, cookies 등)를 쓰지 않는 페이지는
        // next build 때 정적으로 미리 렌더링될 수 있습니다.
        // "no-store" 는 이 페이지를 요청마다 렌더링하도록(동적 렌더링) 명시합니다.
        cache: "no-store",
      });
    
      if (!res.ok) {
        throw new Error(`API 요청 실패: ${res.status}`);
      }
    
      const menus = await res.json();
    
      return (
        <div className="m3-site">
          {/* ===== 헤더 ===== */}
          <header className="site-header">
            <div className="site-container site-header-inner">
              <Link href="/" className="site-logo">
                NCafe
              </Link>
              <nav className="site-nav" aria-label="주 메뉴">
                <Link href="/menus" aria-current="page">
                  메뉴
                </Link>
                <Link href="/about">소개</Link>
                <Link href="/my/favorites">좋아요</Link>
                <Link href="/my/basket">
                  장바구니
                  <span className="m3-badge badge:inline badge-color:primary">3</span>
                </Link>
              </nav>
              <div className="site-actions">
                <Link href="/login" className="m3-btn btn:outlined">
                  로그인
                </Link>
                <Link href="/signup" className="m3-btn">
                  회원가입
                </Link>
              </div>
            </div>
          </header>
    
          {/* ===== 본문 ===== */}
          <main className="site-main">
            <div className="site-container">
              <div className={styles.pageHead}>
                <h1 className={styles.pageTitle}>메뉴</h1>
                <p className={styles.pageDesc}>
                  취향에 맞는 음료와 디저트를 찾아보세요.
                </p>
              </div>
    
              <div className="site-body site-body:aside">
                {/* 좌측: 카테고리 필터 (이 단계에서는 정적) */}
                <aside className="site-aside">
                  <section className="m3-card card:outlined card-padding:self">
                    <div className="card-header">
                      <div className="card-titles">
                        <h2 className="card-subhead">카테고리</h2>
                      </div>
                    </div>
                    <ul className="m3-list list-size:compact">
                      <li>
                        <Link href="/menus" className="list-item list-active">
                          <span className="list-content">전체</span>
                          <span className="list-trailing">{menus.length}</span>
                        </Link>
                      </li>
                    </ul>
                  </section>
                </aside>
    
                {/* 우측: 메뉴 목록 */}
                <section className="site-content">
                  <div className="m3-toolbar">
                    <p className="toolbar-start"><strong>{menus.length}</strong>개의 메뉴
                    </p>
                  </div>
    
                  <ul className="m3-grid grid-cols:3">
                    {menus.map((m) => (
                      <li
                        key={m.id}
                        className="m3-card card:outlined card-size:compact"
                      >
                        <Link
                          href={`/menus/${m.id}`}
                          className="card-media card-media:landscape"
                        >
                          <img src={m.imgSrc} alt={m.korName} />
                        </Link>
                        <div className="card-header">
                          <div className="card-titles">
                            <p className="card-subhead">
                              {m.categoryId === 1
                                ? "커피"
                                : m.categoryId === 2
                                  ? "티"
                                  : m.categoryId === 3
                                    ? "에이드 · 스무디"
                                    : "디저트"}
                            </p>
                            <h3 className="card-headline">
                              <Link href={`/menus/${m.id}`}>{m.korName}</Link>
                            </h3>
                          </div>
                        </div>
                        <p className="card-content">{m.engName}</p>
                        <div className="card-actions">
                          <strong className={styles.price}>{m.price}원</strong>
                          <div className="card-actions-end">
                            <button
                              type="button"
                              className="m3-icon-btn icon-btn:outlined icon-btn-size:sm"
                              aria-label="좋아요"
                            >
                              <i
                                className="m3-icon icon:favorite"
                                aria-hidden="true"
                              ></i>
                            </button>
                            <button
                              type="button"
                              className="m3-btn btn-size:xs"
                            >
                              담기
                            </button>
                          </div>
                        </div>
                      </li>
                    ))}
                  </ul>
                </section>
              </div>
            </div>
          </main>
    
          {/* ===== 푸터 ===== */}
          <footer className="site-footer">
            <div className="site-container site-footer-inner">
              <p className="site-copyright">
                <strong className={styles.footerLogo}>NCafe</strong> © 2026 NCafe.
                All rights reserved.
              </p>
              <nav className="site-footer-links" aria-label="바닥글 링크">
                <Link href="/about">소개</Link>
                <Link href="/">이용약관</Link>
                <Link href="/">개인정보처리방침</Link>
                <Link href="/admin">관리자</Link>
              </nav>
            </div>
          </footer>
        </div>
      );
    }

    1-3. 실행

    npm run dev

    Next.js 16 부터 next devnext build 는 기본으로 Turbopack 을 사용하므로 --turbopack 옵션은 필요 없습니다.

    브라우저에서 http://localhost:3000/menus 로 이동합니다.

    ✅ 확인

    • 브라우저에서 메뉴 카드가 표시됩니다.
    • 각 카드에 korName, engName, price 가 백엔드에서 온 값으로 렌더링됩니다.
    • DevTools → Network 탭에서 http://localhost:3000/menus 문서 요청의 응답(Response) HTML 에 이미 메뉴 데이터가 포함되어 있는지 확인합니다. (SSR 이므로, 초기 HTML 에 데이터가 들어 있습니다.)
    • 같은 Network 탭에 http://localhost:8080/api/menus 요청은 보이지 않습니다. 백엔드 호출은 브라우저가 아니라 Next.js 서버가 했기 때문입니다.

    🎓 newlec 쌤의 코멘트

    useEffect + useState 로 클라이언트에서 데이터를 가져오지 않고, 서버에서 fetch 를 하는 걸까요? 첫 번째 이유는 초기 렌더링 속도입니다. 클라이언트에서 데이터를 가져오면, 브라우저가 HTML 을 받아도 빈 화면을 먼저 그리고, JavaScript 가 실행된 후에야 데이터가 채워집니다. 이 사이에 "로딩 중..." 이나 빈 화면이 보이죠. 서버에서 데이터를 가져와 HTML 에 직접 넣으면, 브라우저가 HTML 을 받는 순간 이미 데이터가 채워진 화면을 볼 수 있습니다. 두 번째 이유는 SEO입니다. 검색 엔진 크롤러는 JavaScript 를 실행하지 않을 수 있습니다. 서버에서 렌더링된 HTML 에 데이터가 없으면, 검색 엔진은 그 페이지의 내용을 알 수 없습니다. 세 번째 이유는 보안입니다. 서버에서만 실행되는 코드에는 API 키나 내부 URL 을 안전하게 둘 수 있습니다. 이 단계에서 우리는 "데이터를 서버에서 가져와 HTML 에 넣는다"는 SSR 의 기본 패턴을 배웠습니다.

    ❌ 자주 하는 실수

    증상 원인 해결
    터미널/에러 화면에 TypeError: fetch failed (ECONNREFUSED) Spring Boot 서버가 http://localhost:8080 에서 실행 중이 아님 Spring Boot 앱을 실행하고, curl http://localhost:8080/api/menus 로 응답 확인. 서버가 켜져 있는데도 ECONNREFUSED ::1:8080 이 보이면 localhost 대신 127.0.0.1 로 요청
    에러 화면에 API 요청 실패: 500 백엔드에서 예외 발생 Spring Boot 콘솔 로그를 확인하고, JdbcMenuRepository 와 데이터베이스 연결 확인
    img 태그가 깨진 아이콘으로 표시 imgSrc 값이 잘못된 경로이거나 파일이 없음 public/images/menus/ 폴더에 해당 SVG 파일이 있는지 확인, 또는 imgSrc 값을 임시로 https://placehold.co/300x200 로 변경
    개발 중에는 잘 되는데 next build && next start 후 DB 를 바꿔도 목록이 안 바뀜 요청 시점 API 를 쓰지 않는 페이지가 빌드 때 정적으로 렌더링됨 fetchcache: "no-store" 옵션 추가 (3단계부터는 searchParams 를 사용하므로 자동으로 동적 렌더링)

    📝 이 단계의 핵심

    • Server Component 는 async 함수를 지원하며, 서버에서 fetch 로 데이터를 가져와 HTML 에 직접 렌더링합니다.
    • SSR 은 초기 렌더링 속도, SEO, 보안을 위해 클라이언트 사이드 렌더링보다 유리한 경우가 많습니다.

이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.