공유된 따라하기 · 스프링부트

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

대상 Next.js App Router 기초(컴포넌트 종류, 생명주기)를 익히고, 실제 프로젝트에서 데이터를 가져와 화면을 그리는 방법을 배우고 싶은 분

목표 목업 데이터 대신 Spring Boot API(http://localhost:8080/api/menus)를 호출해 메뉴 목록을 표시하고, 필터·검색·정렬 기능을 갖춘 실무형 페이지를 완성한다

완성물 app/(anon)/menus/page.jsapp/(anon)/menus/_components/ 디렉토리에 분리된 컴포넌트들이 포함된, API 연동된 메뉴 목록 페이지

준비물

  • Next.js 14+ 프로젝트 (App Router 사용)
  • Spring Boot 백엔드 서버가 http://localhost:8080 에서 실행 중 (GET /api/menus 응답 확인)
  • app/(anon)/menus/page.js 파일이 이미 존재 (아래 1단계에서 내용을 교체)
  • app/(anon)/menus/page.module.css 파일이 이미 존재 (스타일 클래스 유지)
  • public/images/menus/ 폴더에 메뉴 이미지 SVG 파일들 (없으면 1단계에서 placeholder 생성)

STEP 1 / 9SSR으로 API에서 메뉴 목록 가져오기

0개 완료 · 9개 남음

  1. 목업 데이터를 제거하고, 서버 컴포넌트에서 직접 Spring Boot API를 호출해 메뉴 데이터를 가져와 화면에 바인딩하는 실습입니다. 이 단계에서 왜 서버 컴포넌트에서 fetch를 하는지를 이해합니다.

    1-1. 기존 frontend 프로젝트 변경하기

    채원쌤이 자료실에 올린 파일을 받아서 기존 frontend 프로젝트를 새로운 프로젝트로 바꾸고 실행해봅니다. 아마 /menus 페이지는 비어 있을 겁니다. 이제 그 페이지를 완성해가면서 우리가 공부해야 할 내용을 함께 하겠습니다.

    이미지 placeholder 생성

    이미지 파일이 없으면 빌드가 깨지므로, 먼저 placeholder SVG를 생성합니다. (물론 이미 이미지가 있을 겁니다.) 지금 상태에서는 slug 컬럼이 없어서 이미지가 깨져 보일 수 있습니다.

    mkdir -p public/images/menus
    for slug in americano iced-americano latte vanilla-latte cappuccino green-tea earl-grey lemonade mango-smoothie strawberry-smoothie cheesecake croissant; do
      echo '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#e0e0e0"/><text x="100" y="100" text-anchor="middle" font-size="14" fill="#666">placeholder</text></svg>' > "public/images/menus/${slug}.svg"
    done

    Windows PowerShell에서는 for 대신 foreach ($slug in @("americano","iced-americano","latte","vanilla-latte","cappuccino","green-tea","earl-grey","lemonade","mango-smoothie","strawberry-smoothie","cheesecake","croissant")) { ... } 로 작성하세요.

    1-2. page.js를 SSR 컴포넌트로 작성해보기

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

    import Link from "next/link";
    import styles from "./page.module.css";
    
    const CATEGORIES = [
      { id: null, name: "전체", count: 31 },
      { id: 1, name: "커피", count: 12 },
      { id: 2, name: "티", count: 6 },
      { id: 3, name: "에이드 · 스무디", count: 8 },
      { id: 4, name: "디저트", count: 5 },
    ];
    
    async function fetchMenus() {
      const res = await fetch("http://localhost:8080/api/menus", {
        cache: "no-store",
      });
      if (!res.ok) {
        throw new Error(`API 오류: ${res.status}`);
      }
      return res.json();
    }
    
    export default async function MenusPage() {
      const menus = await fetchMenus();
    
      return (
            <div className="m3-site">
          {/* ===== 방: 헤더 (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" className={styles.navBasket}>
                  장바구니
                  <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">
              {/* ----- 좌측 필터: m3-card + m3-list / m3-checkbox (가구·물품) ----- */}
              <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">
                    {CATEGORIES.map((c) => (
                      <li key={c.name}>
                        <Link
                          href={c.id === null ? "/menus" : `/menus?category=${c.id}`}
                          className={`list-item${c.id === null ? " list-active" : ""}`}
                          aria-current={c.id === null ? "page" : undefined}
                        >
                          <span className="list-content">{c.name}</span>
                          <span className="list-trailing">{c.count}</span>
                        </Link>
                      </li>
                    ))}
                  </ul>
                </section>
    
                <section className="m3-card card:outlined card-padding:self">
                  <div className="card-header">
                    <div className="card-titles">
                      <h2 className="card-subhead">가격</h2>
                    </div>
                  </div>
                  <div className={styles.priceOptions}>
                    <label className="m3-checkbox">
                      <input type="checkbox" name="price" value="0-5000" /> ~5,000원
                    </label>
                    <label className="m3-checkbox">
                      <input type="checkbox" name="price" value="5000-7000" /> 5,000~7,000원
                    </label>
                    <label className="m3-checkbox">
                      <input type="checkbox" name="price" value="7000-" /> 7,000원~
                    </label>
                  </div>
                </section>
              </aside>
    
              {/* ----- 우측 목록: 툴바 + m3-grid(m3-card) + m3-pager ----- */}
              <section className="site-content">
                <div className="m3-toolbar">
                  <p className="toolbar-start"><strong>31</strong>개의 메뉴
                  </p>
                  <div className={`toolbar-end ${styles.sort}`}>
                    <div className="m3-text-field field:outlined field-label:none">
                      <select name="sort" defaultValue="popular" aria-label="정렬">
                        <option value="popular">인기순</option>
                        <option value="latest">최신순</option>
                        <option value="priceAsc">낮은 가격순</option>
                        <option value="priceDesc">높은 가격순</option>
                      </select>
                    </div>
                  </div>
                </div>
    
                <ul className="m3-grid grid-cols:3">
                  {menus.map((m) => (
                    <li key={m.slug} className="m3-card card:outlined card-size:compact">
                      <Link href={`/menus/${m.slug}`} className="card-media card-media:landscape">
                        <img src={`/images/menus/${m.slug}.svg`} alt={m.name} />
                        {m.soldOut && (
                          <span className="card-media-badge">
                            <span className="m3-badge badge:inline badge-color:neutral">품절</span>
                          </span>
                        )}
                      </Link>
                      <div className="card-header">
                        <div className="card-titles">
                          <p className="card-subhead">{m.category}</p>
                          <h3 className="card-headline">
                            <Link href={`/menus/${m.slug}`}>{m.name}</Link>
                          </h3>
                        </div>
                      </div>
                      <p className="card-content">{m.eng}</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>
                          {m.soldOut ? (
                            <button type="button" className="m3-btn btn-size:xs" disabled>
                              품절
                            </button>
                          ) : (
                            <button type="button" className="m3-btn btn-size:xs">
                              담기
                            </button>
                          )}
                        </div>
                      </div>
                    </li>
                  ))}
                </ul>
    
                <nav className={`m3-pager ${styles.pager}`} aria-label="페이지">
                  <Link href="/menus?page=1" className="pager-item pager-prev" aria-disabled="true" tabIndex={-1}>
                    이전
                  </Link>
                  <Link href="/menus?page=1" className="pager-item" aria-current="page">
                    1
                  </Link>
                  <Link href="/menus?page=2" className="pager-item">
                    2
                  </Link>
                  <Link href="/menus?page=3" className="pager-item">
                    3
                  </Link>
                  <Link href="/menus?page=2" className="pager-item pager-next">
                    다음
                  </Link>
                </nav>
              </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

    브라우저에서 http://localhost:3000/menus 를 열면, Spring Boot API에서 온 메뉴 목록이 표시됩니다.

    ✅ 확인

    • 브라우저에서 http://localhost:3000/menus 를 열면, API에서 온 메뉴 카드들이 3열 그리드로 표시됩니다.
    • "총 N개의 메뉴" 텍스트에 API에서 온 실제 개수가 표시됩니다.
    • 각 카드의 카테고리명, 영문명, 가격이 API 응답 필드(categoryName, engName, price)에서 바인딩됩니다.
    • soldOut 이 true인 메뉴(크루아상)에는 "품절" 배지가 표시됩니다.

    🎓 newlec 쌤의 코멘트

    왜 서버 컴포넌트에서 fetch를 하는지부터 짚어볼게요. 이 페이지는 로그인 여부나 사용자별 상태와 무관한 공개 데이터를 보여줍니다. 사용자가 페이지를 열 때마다 서버에서 최신 데이터를 받아와야 하고, SEO(검색엔진 최적화)를 위해 HTML에 이미 렌더링된 데이터를 내려줘야 합니다. 서버 컴포넌트는 이 요구사항을 정확히 충족합니다. await fetch() 를 서버 컴포넌트에서 쓰면, Next.js가 요청 시점에 서버에서 데이터를 가져와 HTML에 직렬화해서 클라이언트로 보냅니다. 클라이언트는 이미 데이터가 들어간 HTML을 받아서 즉시 표시합니다. 만약 이 fetch를 클라이언트 컴포넌트에서 했다면, 페이지가 먼저 빈 상태로 렌더링되고, 그 다음에 JavaScript가 실행되어 fetch를 호출하고, 데이터가 도착한 후에야 화면이 갱신됩니다. 이 "빈 화면 → 로딩 → 데이터 표시" 과정이 바로 클라이언트 사이드 렌더링의 단점입니다. 공개 데이터, SEO가 중요한 페이지, 초기 로딩 속도가 중요한 페이지에서는 서버 컴포넌트에서 fetch하는 것이 정석입니다.

    ❌ 자주 하는 실수

    증상 원인 해결
    Failed to fetch 또는 ECONNREFUSED Spring Boot 서버가 localhost:8080 에서 실행 중이 아님 Spring Boot 앱을 먼저 실행하고, http://localhost:8080/api/menus 를 브라우저에서 직접 열어 JSON이 오는지 확인
    Cannot read properties of undefined (reading 'length') API 응답이 배열이 아닌 객체(예: { "data": [...] })인 경우 API 응답 구조를 확인하고, res.json() 후 필요한 필드를 추출하도록 수정
    이미지가 깨져 표시됨 public/images/menus/ 에 해당 slug의 SVG가 없음 1-1의 명령을 다시 실행하거나, 실제 이미지 파일을 해당 경로에 배치
    price.toLocaleString() 이 에러 API의 price 필드가 문자열("3,500")로 오는 경우 Number(m.price).toLocaleString() 또는 m.price 를 그대로 표시하도록 수정

    📝 이 단계의 핵심

    • 공개 데이터 + SEO + 초기 로딩 속도 → 서버 컴포넌트에서 fetch
    • cache: "no-store" 는 매 요청마다 서버에 새로 데이터를 요청하도록 함
    • API 응답 필드명(categoryName, engName, price, soldOut)은 백엔드 DTO와 일치해야 함

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