공유된 따라하기 · 스프링부트
Next.js 실무 메뉴 목록 페이지 만들기 — SSR, 상태, 이벤트, 컴포넌트 분리
대상 Next.js App Router 기초(컴포넌트 종류, 생명주기)를 익히고, 실제 프로젝트에서 데이터를 가져와 화면을 그리는 방법을 배우고 싶은 분
목표 목업 데이터 대신 Spring Boot API(
http://localhost:8080/api/menus)를 호출해 메뉴 목록을 표시하고, 필터·검색·정렬 기능을 갖춘 실무형 페이지를 완성한다완성물
app/(anon)/menus/page.js와app/(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개 남음
목업 데이터를 제거하고, 서버 컴포넌트에서 직접 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" doneWindows 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.jsimport 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또는ECONNREFUSEDSpring 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와 일치해야 함
- 브라우저에서
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.