공유된 따라하기 · 넥스트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/menus가name,category,minPrice,maxPrice쿼리 파라미터를 지원 (4단계부터 사용) app/(anon)/menus/page.module.css파일 존재 (없으면 새로 생성)
이 따라하기는
next.config.js에서cacheComponents옵션을 켜지 않은 기본 설정을 기준으로 합니다.
STEP 1 / 8서버에서 메뉴 데이터 가져오기 — SSR의 필요성
0개 완료 · 8개 남음
이 단계에서는 Next.js Server Component 안에서
fetch로 Spring Boot API에서 메뉴 목록을 가져와 화면에 바인딩합니다. 왜 클라이언트에서useEffect로 데이터를 가져오지 않고 서버에서 가져오는지, SSR의 이점을 체감합니다.1-1. 백엔드 API 응답 구조 확인
터미널에서 다음 명령을 실행해 API가 정상 동작하는지 확인합니다.
curl http://localhost:8080/api/menusWindows PowerShell 에서는
curl이Invoke-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.jsimport 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 devNext.js 16 부터
next dev와next 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 를 쓰지 않는 페이지가 빌드 때 정적으로 렌더링됨 fetch에cache: "no-store"옵션 추가 (3단계부터는searchParams를 사용하므로 자동으로 동적 렌더링)📝 이 단계의 핵심
- Server Component 는
async함수를 지원하며, 서버에서fetch로 데이터를 가져와 HTML 에 직접 렌더링합니다. - SSR 은 초기 렌더링 속도, SEO, 보안을 위해 클라이언트 사이드 렌더링보다 유리한 경우가 많습니다.
이 주소는 글의 저작자가 공개한 링크입니다. 뉴렉처 스터디의 다른 자료는 로그인 후 볼 수 있어요.