처음부터 만드는 next.js 게시판 튜토리얼

🌱 처음부터 만드는 Next.js 게시판 튜토리얼
읽는 분: useState 만 배운 React 입문자
만드는 것: 회원가입·로그인이 되는 게시판 (글쓰기 / 목록 / 페이지 / 수정 / 삭제 / 내 글)
사용 API:
https://api.fullstackfamily.com/api/edu/ws-283fc1
ws-283fc1 는 https://www.fullstackfamily.com/edu/api-docs 에서 생성한 본인의 slug값을 사용하세요.
(DB 만들 필요 없음. 외부에서 만들어 둔 교육용 API를 그대로 씁니다.)
이 글의 약속:
- 코드는 그대로 따라 치기만 하면 동작해요.
- 어렵게 느껴지는 부분은 그림과 비유로 풀어 설명합니다.
- 각 STEP 끝에는 “확인하기” 가 있어서, 화면에서 직접 결과를 보고 다음으로 넘어갈 수 있어요.
목차
- Next.js 가 뭔가요? — 5분 만에 익히는 핵심 4단어
- STEP 1. 프로젝트 만들기
- STEP 2. 공통 도우미 — API · 토큰 저장
- STEP 3. 회원가입 페이지
- STEP 4. 로그인 페이지
- STEP 5. 헤더 + 로그아웃
- STEP 6. 글쓰기
- STEP 7. 글 목록 보기
- STEP 8. 페이지 번호 (페이징)
- STEP 9. 글 상세 + 삭제
- STEP 10. 글 수정
- STEP 11. 내 글 목록
- 마무리 + 자주 만나는 에러
Next.js 가 뭔가요? — 5분 만에 익히는 핵심 4단어
지금까지 만든 React (Vite) 앱은 모든 코드가 브라우저에서 실행됐어요.
Next.js는 같은 React 인데, 서버에서도 실행할 수 있어서 “페이지가 빨리 떠 보이고 SEO에도 좋다” 는 장점이 있어요.
이 튜토리얼에서 쓰는 4단어만 외우면 충분합니다.
| 단어 | 한 줄 정리 |
|---|---|
| App Router | src/app/ 폴더가 곧 URL. app/login/page.tsx ⇒ /login |
| 서버 컴포넌트 | 파일 맨 위에 아무것도 없으면 기본값. async function 안에서 곧바로 fetch 가능 |
| 클라이언트 컴포넌트 | 파일 첫 줄에 "use client" 가 있는 파일. useState/클릭/입력 가능 |
| 동적 라우트 | 폴더 이름에 대괄호 — app/posts/[id]/page.tsx ⇒ /posts/123 |
비유:
🍵 서버 컴포넌트 = 카페에서 미리 음료를 다 만들어 손님에게 줌
🥤 클라이언트 컴포넌트 = 손님이 받은 다음 빨대 꽂고 흔들기
조회/목록/상세처럼 “이미 만들어진 데이터를 보여주기만 하는 화면” → 서버 컴포넌트
입력/클릭이 일어나는 화면 → 클라이언트 컴포넌트
STEP 1. 프로젝트 만들기
1-1. 터미널에서 명령 한 줄
cd ~/devel/edu_study # 작업 폴더로 이동 (없으면 mkdir) npx create-next-app@latest next-board \ --typescript --tailwind --eslint --app --src-dir \ --import-alias "@/*" --use-npm --yes
next-board 라는 폴더가 새로 생기고 안에 Next.js 기본 코드가 자동으로 채워져요.
옵션 한 줄 설명:
| 옵션 | 의미 |
|---|---|
--typescript | 타입스크립트로 만들기 |
--tailwind | Tailwind CSS 자동 설치 |
--app | App Router 사용 (위에서 익힌 그것!) |
--src-dir | 모든 코드를 src/ 안에 두기 (정리에 좋음) |
--import-alias "@/*" | @/lib/api 같은 깔끔한 import 경로 |
1-2. 폴더로 들어가서 실행
cd next-board npm run dev
화면에 이런 줄이 뜨면 성공:
✓ Ready in 295ms - Local: http://localhost:3000
1-3. 폴더 구조 한눈에
next-board/ ├─ src/ │ ├─ app/ │ │ ├─ layout.tsx ← 모든 페이지 공통 액자 │ │ ├─ page.tsx ← / (홈) │ │ └─ globals.css ← 전역 CSS │ ├─ components/ ← (지금은 없음. 우리가 만들 것) │ └─ lib/ ← (지금은 없음. 우리가 만들 것) ├─ package.json └─ tsconfig.json
기억할 규칙 1개:
src/app/안의 폴더 = URL. 폴더 안에page.tsx가 있으면 그게 그 URL의 페이지.
1-4. 시작 화면 깔끔하게 비우기
기본 화면은 너무 화려해서 우리 게시판과 안 어울려요. 두 파일만 갈아 끼워줍니다.
📄 src/app/globals.css (전체 내용을 아래로 교체)
@import "tailwindcss"; html, body { height: 100%; background: #f9fafb; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", "Noto Sans KR", sans-serif; }
📄 src/app/layout.tsx (전체 내용을 아래로 교체)
import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "Next 게시판", description: "Next.js + Tailwind 로 만든 첫 번째 게시판 실습", }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="ko"> <body className="min-h-screen flex flex-col"> <main className="flex-1">{children}</main> </body> </html> ); }
metadata의title은 브라우저 탭 이름이 됩니다. 조금 있다 SEO에 도움이 돼요.
📄 src/app/page.tsx (홈 화면도 임시로 단순하게)
export default function HomePage() { return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold">Next 게시판</h1> <p className="mt-2 text-gray-600">시작합시다 👋</p> </div> ); }
✅ 확인하기
npm run dev 가 켜져 있는 상태에서 http://localhost:3000 새로고침.
“Next 게시판” 과 “시작합시다 👋” 만 깔끔하게 보이면 OK.
STEP 2. 공통 도우미 — API · 토큰 저장
페이지를 만들기 전에, 모든 페이지가 같이 쓰는 작은 함수들을 미리 만들어 둡니다.
2-1. API 함수 모음 — src/lib/api.ts
src/lib/ 폴더는 직접 만들어주세요. 그 안에 새 파일 api.ts 를 만들고 아래 내용을 넣습니다.
/** * 게시판 API 모음. * 모든 함수는 fullstackfamily.com 의 교육용 API(ws-283fc1)를 직접 호출합니다. */ export const API_BASE = "https://api.fullstackfamily.com/api/edu/ws-283fc1"; // ===== 응답 타입 ===== export type ApiEnvelope<T> = { success: boolean; message: string; data: T; }; export type PostListItem = { id: number; title: string; contentPreview: string | null; authorNickname: string; viewCount: number; commentCount: number; thumbnailUrl: string | null; publishedAt: string | null; createdAt: string; }; export type PostDetail = { id: number; title: string; content: string; status: "DRAFT" | "PUBLISHED" | "DELETED"; images: { id: number; url: string }[]; authorNickname: string; viewCount: number; commentCount: number; publishedAt: string | null; createdAt: string; }; export type PageResponse<T> = { content: T[]; totalElements: number; totalPages: number; number: number; // 현재 페이지 (0부터 시작) size: number; first: boolean; last: boolean; }; export type EduUser = { id: number; username: string; nickname: string; createdAt: string; }; // ===== 공통 fetch 헬퍼 ===== async function request<T>( path: string, options: RequestInit & { token?: string | null } = {} ): Promise<T> { const { token, headers, ...rest } = options; const res = await fetch(`${API_BASE}${path}`, { ...rest, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(headers || {}), }, cache: "no-store", // 게시판은 항상 최신 데이터로 }); let body: ApiEnvelope<T> | null = null; try { body = (await res.json()) as ApiEnvelope<T>; } catch { throw new Error(`서버 응답이 JSON이 아닙니다 (${res.status})`); } if (!res.ok || !body || body.success === false) { const msg = body?.message || `요청 실패 (${res.status})`; throw new Error(msg); } return body.data; } // ===== 게시글 ===== export function listPosts(page: number, size = 10) { return request<PageResponse<PostListItem>>( `/posts?page=${page}&size=${size}` ); } export function getPost(id: number) { return request<PostDetail>(`/posts/${id}`); } export function createDraft(token: string) { return request<PostDetail>(`/posts`, { method: "POST", token }); } export function updatePost( id: number, body: { title: string; content: string }, token: string ) { return request<PostDetail>(`/posts/${id}`, { method: "PUT", token, body: JSON.stringify(body), }); } export function publishPost( id: number, body: { title: string; content: string }, token: string ) { return request<PostDetail>(`/posts/${id}/publish`, { method: "PUT", token, body: JSON.stringify(body), }); } export function deletePost(id: number, token: string) { return request<null>(`/posts/${id}`, { method: "DELETE", token }); } export function listMyPosts(token: string) { return request<PostListItem[]>(`/posts/my`, { token }); } // ===== 인증 ===== export function signup(body: { username: string; password: string; nickname: string; }) { return request<EduUser>(`/auth/signup`, { method: "POST", body: JSON.stringify(body), }); } export function login(body: { username: string; password: string }) { return request<{ token: string; user: EduUser }>(`/auth/login`, { method: "POST", body: JSON.stringify(body), }); }
핵심 키워드 두 개만 짚을게요:
request<T>— 공통fetch래퍼. URL, Authorization 헤더, JSON 파싱을 한 번에 해 줍니다. 모든 다른 함수가 이걸 부르기만 하면 끝.cache: "no-store"— Next.js 가 결과를 캐시(저장) 못 하게 막아요. 게시판은 새 글이 자주 올라오니 캐시하면 안 됨.
2-2. 토큰 저장 — src/lib/auth.ts
로그인하면 받은 토큰을 어디 저장해야 다음에 쓸 수 있어요. 브라우저의 localStorage 에 저장합니다.
src/lib/auth.ts 를 만들고:
/** * 로그인 토큰과 유저 정보를 브라우저 localStorage 에 저장/조회. * - localStorage 는 브라우저에서만 동작합니다. 서버에선 절대 부르지 마세요. */ import type { EduUser } from "./api"; const TOKEN_KEY = "next-board.token"; const USER_KEY = "next-board.user"; export function saveAuth(token: string, user: EduUser) { localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, JSON.stringify(user)); } export function clearAuth() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); } export function readToken(): string | null { if (typeof window === "undefined") return null; // 서버에서는 null return localStorage.getItem(TOKEN_KEY); } export function readUser(): EduUser | null { if (typeof window === "undefined") return null; const raw = localStorage.getItem(USER_KEY); if (!raw) return null; try { return JSON.parse(raw) as EduUser; } catch { return null; } }
⚠️
if (typeof window === "undefined") return null;이 한 줄을 빼먹으면 서버 컴포넌트에서 바로 에러가 납니다. localStorage 는 브라우저에만 있어요.
✅ 확인하기
화면에서는 아무 변화 없습니다. 파일 두 개를 만들기만 한 단계예요.
타입 에러가 빨간 줄로 안 보이면 OK.
STEP 3. 회원가입 페이지
이제 본격적으로 페이지를 만들어요. 회원가입을 먼저 만드는 이유: 로그인을 테스트하려면 계정이 필요하니까요.
3-1. 페이지 만들기
src/app/signup/page.tsx 를 만듭니다. (signup 폴더부터 새로 만들어야 해요)
src/app/ ├─ layout.tsx ├─ page.tsx └─ signup/ └─ page.tsx ← 새로 만들 파일
"use client"; /** * 회원가입 페이지 ( /signup ) * - useState 로 입력값을 관리합니다. * - 회원가입 성공하면 /login 으로 이동합니다. */ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { signup } from "@/lib/api"; export default function SignupPage() { const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [nickname, setNickname] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); try { await signup({ username, password, nickname }); alert("회원가입 완료! 이제 로그인해주세요."); router.push("/login"); } catch (err) { setError(err instanceof Error ? err.message : "회원가입 실패"); setSubmitting(false); } } return ( <div className="max-w-sm mx-auto px-4 py-12"> <h1 className="text-2xl font-bold text-gray-900 mb-6">회원가입</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 아이디 (영문/숫자 4~20자) </label> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required minLength={4} maxLength={20} pattern="[a-zA-Z0-9]+" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 비밀번호 (4~20자) </label> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={4} maxLength={20} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 닉네임 (2~20자) </label> <input type="text" value={nickname} onChange={(e) => setNickname(e.target.value)} required minLength={2} maxLength={20} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && ( <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2"> {error} </p> )} <button type="submit" disabled={submitting} className="w-full py-2 rounded-md bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-50" > {submitting ? "가입 중..." : "회원가입"} </button> </form> <p className="mt-6 text-sm text-gray-600 text-center"> 이미 계정이 있나요?{" "} <Link href="/login" prefetch={false} className="text-blue-600 hover:underline"> 로그인 </Link> </p> </div> ); }
3-2. 코드 해부
이 페이지에서 처음 보는 게 4가지 정도예요. 차근차근.
🔹 "use client";
파일 맨 첫 줄. “이 파일은 클라이언트(브라우저)에서 동작해” 라는 표시. useState, onChange 같은 게 들어가는 모든 파일에는 이 한 줄이 필요합니다.
🔹 useRouter()
URL을 바꿔주는 도구. router.push("/login") ⇒ /login 페이지로 이동.
🔹 <Link href="/login">
일반 <a> 태그 대신 써요. 페이지 전체가 새로 떠지지 않고, 앱 안에서만 빠르게 이동합니다.
🔹 useState 5개
username,password,nickname— 입력값 3개error— 에러 메시지 (실패했을 때 빨간 박스)submitting— “가입 중...” 표시용 boolean
💡 폼 만들 때 패턴은 항상 같아요: 입력 하나당 useState 하나.
✅ 확인하기
브라우저 주소창에 http://localhost:3000/signup 직접 입력 → 폼 등장.
아무거나 입력해서 실제로 가입해 보세요. 성공하면 /login 으로 자동 이동합니다.
(아직 /login 페이지를 안 만들어서 404가 떠도 정상이에요. 다음 STEP에서 만듭니다.)
⚠️ 흔한 실수: "use client" 빼먹기 → “useState is not a function” 에러.
STEP 4. 로그인 페이지
회원가입과 거의 똑같아요. 차이점:
- 닉네임 칸 없음
- 성공하면 받은 토큰을
localStorage에 저장 → 홈으로 이동
src/app/login/page.tsx:
"use client"; /** * 로그인 페이지 ( /login ). * - 성공하면 토큰/유저 정보를 localStorage 에 저장하고 홈으로. */ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { login } from "@/lib/api"; import { saveAuth } from "@/lib/auth"; export default function LoginPage() { const router = useRouter(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setSubmitting(true); try { const { token, user } = await login({ username, password }); saveAuth(token, user); router.push("/"); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "로그인 실패"); setSubmitting(false); } } return ( <div className="max-w-sm mx-auto px-4 py-12"> <h1 className="text-2xl font-bold text-gray-900 mb-6">로그인</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">아이디</label> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && ( <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2"> {error} </p> )} <button type="submit" disabled={submitting} className="w-full py-2 rounded-md bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-50" > {submitting ? "로그인 중..." : "로그인"} </button> </form> <p className="mt-6 text-sm text-gray-600 text-center"> 아직 계정이 없나요?{" "} <Link href="/signup" prefetch={false} className="text-blue-600 hover:underline"> 회원가입 </Link> </p> </div> ); }
코드 핵심
const { token, user } = await login({ username, password }); saveAuth(token, user); // localStorage 에 저장 router.push("/"); // 홈으로 router.refresh(); // (서버 컴포넌트 다시 그리기)
router.refresh() 가 뭔가요? 곧 만들 “목록 화면(서버 컴포넌트)”을 한 번 더 새로 그려주려고 부릅니다. 지금은 “이렇게 쓰는 거구나” 정도로만 넘어가도 OK.
✅ 확인하기
- 브라우저에
http://localhost:3000/login - 방금 만든 계정으로 로그인 시도
- 홈(
/)으로 자동 이동하면 성공 - 개발자도구 → Application → Local Storage 에
next-board.token,next-board.user가 보이면 진짜 성공
STEP 5. 헤더 + 로그아웃
로그인은 됐지만, 로그인 됐는지 화면에 안 보이죠? 위쪽에 “닉네임 / 로그아웃” 메뉴를 보여주는 헤더를 만듭니다.
5-1. Header 컴포넌트
src/components/ 폴더를 만들고 Header.tsx 를 추가:
"use client"; /** * 상단 헤더. * 로그인 상태에 따라 "로그인/회원가입" ↔ "내 글/로그아웃" 이 바뀝니다. */ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import type { EduUser } from "@/lib/api"; import { clearAuth, readUser } from "@/lib/auth"; export default function Header() { const [user, setUser] = useState<EduUser | null>(null); const router = useRouter(); const pathname = usePathname(); // 페이지가 처음 그려질 때, 그리고 페이지를 이동할 때마다 // localStorage 에서 로그인 정보를 다시 읽어 옵니다. useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setUser(readUser()); }, [pathname]); function handleLogout() { clearAuth(); setUser(null); router.push("/"); router.refresh(); } return ( <header className="bg-white border-b border-gray-200"> <div className="max-w-3xl mx-auto px-4 h-14 flex items-center justify-between"> <Link href="/" className="text-lg font-bold text-gray-900"> Next 게시판 </Link> <nav className="flex items-center gap-3 text-sm"> {user ? ( <> <span className="text-gray-600"> <span className="font-semibold text-gray-900">{user.nickname}</span>님 </span> <Link href="/my" className="text-gray-700 hover:text-blue-600"> 내 글 </Link> <Link href="/posts/new" className="px-3 py-1.5 rounded-md bg-blue-600 text-white hover:bg-blue-700" > 글쓰기 </Link> <button onClick={handleLogout} className="text-gray-500 hover:text-gray-800"> 로그아웃 </button> </> ) : ( <> <Link href="/login" className="text-gray-700 hover:text-blue-600"> 로그인 </Link> <Link href="/signup" className="px-3 py-1.5 rounded-md bg-blue-600 text-white hover:bg-blue-700" > 회원가입 </Link> </> )} </nav> </div> </header> ); }
5-2. layout 에 끼워넣기
src/app/layout.tsx 에서 <Header /> 를 추가:
import type { Metadata } from "next"; import "./globals.css"; import Header from "@/components/Header"; // ⬅️ 추가 export const metadata: Metadata = { title: "Next 게시판", description: "Next.js + Tailwind 로 만든 첫 번째 게시판 실습", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body className="min-h-screen flex flex-col"> <Header /> {/* ⬅️ 추가 */} <main className="flex-1">{children}</main> </body> </html> ); }
5-3. 코드 해부 — useEffect 가 처음 등장!
const pathname = usePathname(); useEffect(() => { setUser(readUser()); }, [pathname]);
이게 무슨 뜻이냐면:
“처음 화면이 그려진 직후에 한 번, 그리고 URL 이 바뀔 때마다,
localStorage를 다시 읽어user를 새로 채워라.”
왜 필요한가?
- 서버가 화면을 그릴 땐
localStorage를 못 읽어요. (서버에는 그게 없으니까) - 그래서 브라우저에 화면이 그려진 뒤에야 읽을 수 있어요. 이게
useEffect의 역할. - Header 는 layout 안에 있어서 페이지 이동 중에도 살아남아요. 그러니
pathname이 바뀔 때(=다른 페이지로 이동할 때)마다 다시 읽어줘야 “로그인 → 홈으로 이동” 직후 닉네임이 곧바로 표시됩니다.
💡 외워두세요: localStorage 같은 “브라우저 전용 값” 을 화면에 반영하려면
useEffect가 필요해요. 그것 말고는 거의 다useState로 됩니다.
✅ 확인하기
/login에서 로그인- 자동으로
/로 이동 → 헤더 오른쪽에 “○○○님 / 내 글 / 글쓰기 / 로그아웃” 표시 - 로그아웃 누르면 → 다시 “로그인 / 회원가입” 으로 바뀜
- 로그아웃 후에도 새로고침 없이 헤더가 즉시 바뀌는 걸 확인하세요.
⚠️ 흔한 실수: useEffect 의 의존성 배열을 [] 로 비워두면 → 새로고침 해야만 헤더가 바뀜. 반드시 [pathname].
STEP 6. 글쓰기
이번엔 글을 쓰러 갑니다. 이 게시판 API의 특이한 점 하나:
글쓰기는 API 두 번 호출로 이루어져요.
1️⃣POST /posts— 빈 초안 만들기 (id 받음)
2️⃣PUT /posts/{id}/publish— 제목/본문을 담아 발행
이게 좀 이상해 보일 수 있어요. 하지만 “자동 임시저장” 같은 기능을 붙일 때 무척 유용한 패턴이에요.
src/app/posts/new/page.tsx 를 만듭니다. (posts/new 폴더 둘 다 새로 만들어야 해요)
"use client"; /** * 새 글 작성 페이지 ( /posts/new ). * 1) POST /posts → 빈 초안 생성, id 받기 * 2) PUT /posts/{id}/publish → 제목/본문 담아 발행 */ import { useRouter } from "next/navigation"; import { useState } from "react"; import { createDraft, publishPost } from "@/lib/api"; import { readToken } from "@/lib/auth"; export default function NewPostPage() { const router = useRouter(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); const token = readToken(); if (!token) { alert("로그인이 필요합니다."); router.push("/login"); return; } if (!title.trim()) { setError("제목을 입력해주세요."); return; } setSubmitting(true); try { const draft = await createDraft(token); // 1단계 const published = await publishPost(draft.id, { title, content }, token); // 2단계 router.push(`/posts/${published.id}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "발행 실패"); setSubmitting(false); } } return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold text-gray-900 mb-6">새 글 작성</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">제목</label> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="제목을 입력하세요" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 본문 <span className="text-xs text-gray-400">({content.length}자)</span> </label> <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={14} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="본문을 입력하세요" /> </div> {error && ( <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2"> {error} </p> )} <div className="flex justify-end gap-2"> <button type="button" onClick={() => router.back()} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 hover:bg-gray-100" > 취소 </button> <button type="submit" disabled={submitting} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 disabled:opacity-50" > {submitting ? "발행 중..." : "발행"} </button> </div> </form> </div> ); }
코드 해부
🔹 글자수 표시
<span>({content.length}자)</span> — content 가 useState 라서 입력할 때마다 자동으로 숫자가 바뀝니다.
🔹 로그인 가드
const token = readToken(); if (!token) { alert("로그인이 필요합니다."); router.push("/login"); return; }
토큰이 없으면 더 진행하지 말고 로그인 페이지로 돌립니다.
🔹 API 두 번 호출
const draft = await createDraft(token); const published = await publishPost(draft.id, { title, content }, token);
첫 호출에서 받은 draft.id 를 두 번째 호출에 그대로 넘겨요. JavaScript의 await 가 순서를 지켜주니, 1번이 끝난 다음에야 2번이 실행됩니다.
✅ 확인하기
- 헤더의 글쓰기 클릭 →
/posts/new - 제목/본문 입력 후 발행
- 자동으로
/posts/123같은 주소로 이동 (아직 그 페이지가 없으니 404가 떠요. 곧 만듭니다.) - 새로고침 한 번 하고 다음 STEP 진행.
STEP 7. 글 목록 보기 — Next.js의 진가
이게 이 튜토리얼의 하이라이트 입니다.
지금까지 React에서 “목록 보여주기” 는 보통 이렇게 했었죠:
// React (Vite) 식 const [posts, setPosts] = useState([]); useEffect(() => { fetch("/api/posts").then(r => r.json()).then(setPosts); }, []);
Next.js 의 서버 컴포넌트 에선 이렇게 됩니다:
// Next.js 서버 컴포넌트 식 export default async function HomePage() { const posts = await fetch("/api/posts").then(r => r.json()); return <div>{posts.map(...)}</div>; }
useState도 useEffect도 없어요! 함수가 async 라서 그냥 await 한 줄이면 끝.
서버에서 데이터를 다 받은 뒤 이미 완성된 HTML 이 브라우저로 도착합니다.
7-1. 홈을 목록으로 바꾸기
src/app/page.tsx 를 통째로 교체:
/** * 게시판 목록 페이지 ( / ). * 서버 컴포넌트 입니다 ("use client" 가 없음). */ import Link from "next/link"; import { listPosts } from "@/lib/api"; export default async function HomePage() { const data = await listPosts(0, 10); // 첫 페이지 10개 return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold text-gray-900 mb-6">전체 글</h1> {data.content.length === 0 ? ( <p className="text-gray-500 text-center py-12">아직 등록된 글이 없습니다.</p> ) : ( <ul className="bg-white rounded-lg border border-gray-200 divide-y divide-gray-100"> {data.content.map((post) => ( <li key={post.id} className="p-4 hover:bg-gray-50"> <Link href={`/posts/${post.id}`} prefetch={false} className="block"> <h2 className="text-base font-semibold text-gray-900 line-clamp-1"> {post.title || "(제목 없음)"} </h2> {post.contentPreview && ( <p className="mt-1 text-sm text-gray-600 line-clamp-2"> {post.contentPreview} </p> )} <div className="mt-2 flex items-center gap-3 text-xs text-gray-500"> <span>{post.authorNickname}</span> <span>·</span> <span>조회 {post.viewCount}</span> <span>·</span> <span>댓글 {post.commentCount}</span> {post.publishedAt && ( <> <span>·</span> <span>{post.publishedAt.slice(0, 10)}</span> </> )} </div> </Link> </li> ))} </ul> )} </div> ); }
7-2. 코드 해부
🔹 "use client" 가 없습니다 → 서버 컴포넌트
🔹 async function + await listPosts(...) — 서버에서 fetch 끝낸 뒤 HTML로 바뀜
🔹 <Link href={/posts/${post.id}} prefetch={false}>
- prefetch={false} — 마우스만 올려도 미리 데이터를 받아오는 동작을 끔. 게시판처럼 클릭 많은 곳에선 이게 좋아요.
✅ 확인하기
http://localhost:3000 에 가면 진짜 글 목록이 뜹니다.
방금 STEP 6에서 쓴 글이 맨 위에 있을 거예요!
STEP 8. 페이지 번호 (페이징)
아까 목록은 첫 10개만 보여줬죠. 1, 2, 3 페이지 번호를 만들어서 다음 글들도 볼 수 있게 합니다.
8-1. Pagination 컴포넌트
src/components/Pagination.tsx:
/** * 1, 2, 3 ... 형태의 페이지 번호. * "use client" 가 없습니다 → 서버 컴포넌트. 단순히 <Link> 들을 그릴 뿐. */ import Link from "next/link"; type Props = { current: number; // 0부터 시작하는 현재 페이지 totalPages: number; // 전체 페이지 수 }; export default function Pagination({ current, totalPages }: Props) { if (totalPages <= 1) return null; // 현재 페이지 근처 5개만 보여줍니다 (예: 3 4 5 6 7). const windowSize = 5; const start = Math.max(0, current - Math.floor(windowSize / 2)); const end = Math.min(totalPages, start + windowSize); const pages: number[] = []; for (let i = start; i < end; i++) pages.push(i); function href(page: number) { return page === 0 ? "/" : `/?page=${page}`; } return ( <nav className="flex items-center justify-center gap-1 mt-8"> <Link href={href(Math.max(0, current - 1))} prefetch={false} aria-disabled={current === 0} className={`px-3 py-1.5 rounded-md text-sm border ${ current === 0 ? "text-gray-300 border-gray-200 pointer-events-none" : "text-gray-700 border-gray-300 hover:bg-gray-100" }`} > 이전 </Link> {pages.map((p) => ( <Link key={p} href={href(p)} prefetch={false} className={`px-3 py-1.5 rounded-md text-sm border ${ p === current ? "bg-blue-600 text-white border-blue-600" : "text-gray-700 border-gray-300 hover:bg-gray-100" }`} > {p + 1} </Link> ))} <Link href={href(Math.min(totalPages - 1, current + 1))} prefetch={false} aria-disabled={current >= totalPages - 1} className={`px-3 py-1.5 rounded-md text-sm border ${ current >= totalPages - 1 ? "text-gray-300 border-gray-200 pointer-events-none" : "text-gray-700 border-gray-300 hover:bg-gray-100" }`} > 다음 </Link> </nav> ); }
💡 페이지네이션은 “Link 만 그리는 일” 이라 서버 컴포넌트로 충분합니다.
useState가 필요 없어요.
8-2. 홈에서 URL 의 ?page=N 읽기
src/app/page.tsx 를 다시 고쳐요. searchParams 라는 개념이 새로 등장.
import Link from "next/link"; import { listPosts } from "@/lib/api"; import Pagination from "@/components/Pagination"; type SearchParams = Promise<{ page?: string }>; export default async function HomePage(props: { searchParams: SearchParams }) { const sp = await props.searchParams; const page = Math.max(0, Number(sp.page ?? 0) || 0); const data = await listPosts(page, 10); return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold text-gray-900 mb-6">전체 글</h1> {data.content.length === 0 ? ( <p className="text-gray-500 text-center py-12">아직 등록된 글이 없습니다.</p> ) : ( <ul className="bg-white rounded-lg border border-gray-200 divide-y divide-gray-100"> {data.content.map((post) => ( <li key={post.id} className="p-4 hover:bg-gray-50"> <Link href={`/posts/${post.id}`} prefetch={false} className="block"> <h2 className="text-base font-semibold text-gray-900 line-clamp-1"> {post.title || "(제목 없음)"} </h2> {post.contentPreview && ( <p className="mt-1 text-sm text-gray-600 line-clamp-2"> {post.contentPreview} </p> )} <div className="mt-2 flex items-center gap-3 text-xs text-gray-500"> <span>{post.authorNickname}</span> <span>·</span> <span>조회 {post.viewCount}</span> <span>·</span> <span>댓글 {post.commentCount}</span> {post.publishedAt && ( <> <span>·</span> <span>{post.publishedAt.slice(0, 10)}</span> </> )} </div> </Link> </li> ))} </ul> )} <Pagination current={data.number} totalPages={data.totalPages} /> </div> ); }
코드 해부
type SearchParams = Promise<{ page?: string }>; const sp = await props.searchParams; const page = Math.max(0, Number(sp.page ?? 0) || 0);
- 주소창의
?page=2같은 값을 받는 게searchParams. - Next.js 15+ 부터는 Promise 라서
await가 필요해요. 한 번만 외우면 됩니다. Number(sp.page ?? 0) || 0— 값이 없거나 이상하면 0(첫 페이지)로 안전하게 처리.
✅ 확인하기
- 홈에서 페이지네이션이 보여요 (글이 10개 이상일 때만)
- 2 클릭 → URL 이
/?page=1로 바뀌고, 11번째 글부터 표시 (page는 0부터 시작!) - 이전 / 다음 버튼도 동작
- 가장 첫 페이지에선 이전 이 회색으로 비활성화
STEP 9. 글 상세 + 삭제
이제 목록의 글 제목을 클릭하면 상세 화면으로 가야 해요.
9-1. 동적 라우트 — [id]
폴더 이름을 대괄호 로 감싸면 그 자리에 어떤 값이든 들어올 수 있어요.
src/app/posts/[id]/page.tsx ← /posts/123 으로 들어오면 id="123"
9-2. 상세 페이지 (서버 컴포넌트)
src/app/posts/[id]/page.tsx:
/** * 글 상세 페이지 ( /posts/[id] ). * 서버 컴포넌트. async 함수에서 곧바로 fetch. */ import Link from "next/link"; import { notFound } from "next/navigation"; import { getPost } from "@/lib/api"; import OwnerActions from "./_components/OwnerActions"; type Params = Promise<{ id: string }>; export default async function PostDetailPage(props: { params: Params }) { const { id } = await props.params; const postId = Number(id); if (!postId) notFound(); let post; try { post = await getPost(postId); } catch { notFound(); } if (post.status === "DELETED") notFound(); return ( <article className="max-w-3xl mx-auto px-4 py-8"> <Link href="/" prefetch={false} className="text-sm text-gray-500 hover:text-gray-800"> ← 목록으로 </Link> <header className="mt-4 pb-4 border-b border-gray-200"> <h1 className="text-2xl font-bold text-gray-900 break-words"> {post.title || "(제목 없음)"} </h1> <div className="mt-2 flex items-center gap-3 text-xs text-gray-500"> <span>{post.authorNickname}</span> <span>·</span> <span>조회 {post.viewCount}</span> {post.publishedAt && ( <> <span>·</span> <span>{post.publishedAt.slice(0, 10)}</span> </> )} </div> </header> <div className="mt-6 whitespace-pre-wrap leading-7 text-gray-800"> {post.content} </div> <OwnerActions postId={post.id} authorNickname={post.authorNickname} /> </article> ); }
🔹 params 도 Promise — searchParams와 똑같이 await 가 필요해요.
🔹 notFound() — 호출하면 자동으로 404 페이지로 이동
🔹 whitespace-pre-wrap — 줄바꿈을 그대로 보여주는 Tailwind 클래스
9-3. 본인 글일 때만 보이는 “수정/삭제” 버튼
상세 페이지는 서버 컴포넌트라서 localStorage (= 로그인한 사람이 누군지) 를 모릅니다. 그래서 “수정/삭제 버튼” 만 따로 클라이언트 컴포넌트 로 떼어내요.
폴더 이름을 _components 처럼 언더스코어로 시작하면 Next.js 가 “이건 URL이 되면 안 되는 폴더야” 라고 인식합니다.
src/app/posts/[id]/_components/OwnerActions.tsx:
"use client"; /** * 글 상세 화면 하단의 "수정 / 삭제" 버튼. * 본인이 쓴 글일 때만 보이도록 합니다. */ import { useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState } from "react"; import { deletePost } from "@/lib/api"; import { readToken, readUser } from "@/lib/auth"; type Props = { postId: number; authorNickname: string; }; export default function OwnerActions({ postId, authorNickname }: Props) { const router = useRouter(); const [myNickname, setMyNickname] = useState<string | null>(null); const [deleting, setDeleting] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setMyNickname(readUser()?.nickname ?? null); }, []); const isMine = myNickname !== null && myNickname === authorNickname; if (!isMine) return null; async function handleDelete() { if (deleting) return; if (!confirm("정말 이 글을 삭제할까요?")) return; const token = readToken(); if (!token) { alert("로그인이 만료되었습니다."); return; } setDeleting(true); try { await deletePost(postId, token); router.push("/"); router.refresh(); } catch (e) { alert(e instanceof Error ? e.message : "삭제 실패"); setDeleting(false); } } return ( <div className="mt-8 flex justify-end gap-2"> <Link href={`/posts/${postId}/edit`} prefetch={false} className="px-3 py-1.5 rounded-md border border-gray-300 text-sm text-gray-700 hover:bg-gray-100" > 수정 </Link> <button onClick={handleDelete} disabled={deleting} className="px-3 py-1.5 rounded-md bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-50" > {deleting ? "삭제 중..." : "삭제"} </button> </div> ); }
🔹 confirm("...") — 브라우저 기본 “예/아니오” 창
🔹 isMine = myNickname === authorNickname — 로그인한 사람의 닉네임이랑 글쓴이 닉네임이 같으면 본인
🔹 if (!isMine) return null; — 본인이 아니면 아무것도 그리지 않음
✅ 확인하기
- 홈에서 글 제목 클릭 → 상세 페이지 이동
- 본인이 쓴 글에서만 "수정 / 삭제" 버튼이 보여야 합니다.
- 다른 사람 글에는 안 보임.
- 삭제 누르고 확인 → 홈으로 가서 그 글이 사라짐.
⚠️ 흔한 실수: _components 가 아닌 components 로 만들면 → /posts/123/components 라는 URL 이 생겨버려요. 반드시 _ 로 시작.
STEP 10. 글 수정
수정 페이지는 두 가지가 동시에 필요해요:
- 기존 글 데이터 를 미리 폼에 채워야 함 (서버에서 가져오기)
- 사용자 입력 을 받아야 함 (클라이언트)
가장 깔끔한 패턴: 두 파일로 나누기.
src/app/posts/[id]/edit/ ├─ page.tsx ← 서버 컴포넌트, 글 fetch └─ EditForm.tsx ← 클라이언트 컴포넌트, 폼
10-1. 서버 쪽 페이지
src/app/posts/[id]/edit/page.tsx:
/** * 글 수정 페이지 ( /posts/[id]/edit ). * 서버에서 기존 글을 fetch 한 뒤, EditForm 에 props 로 넘깁니다. */ import { notFound } from "next/navigation"; import { getPost } from "@/lib/api"; import EditForm from "./EditForm"; type Params = Promise<{ id: string }>; export default async function EditPostPage(props: { params: Params }) { const { id } = await props.params; const postId = Number(id); if (!postId) notFound(); let post; try { post = await getPost(postId); } catch { notFound(); } if (post.status === "DELETED") notFound(); return ( <EditForm postId={post.id} initialTitle={post.title} initialContent={post.content} authorNickname={post.authorNickname} /> ); }
서버 컴포넌트가 데이터를 잘 가져와서 클라이언트 컴포넌트에 “이걸 가지고 폼 그려” 라고 넘겨주는 흐름이에요.
10-2. 클라이언트 쪽 폼
src/app/posts/[id]/edit/EditForm.tsx:
"use client"; /** * 글 수정 폼. * page.tsx 에서 미리 받은 글 정보를 props 로 받습니다. * → useState 의 초깃값으로 바로 폼에 표시 (useEffect 불필요!) */ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { updatePost } from "@/lib/api"; import { readToken, readUser } from "@/lib/auth"; type Props = { postId: number; initialTitle: string; initialContent: string; authorNickname: string; }; export default function EditForm({ postId, initialTitle, initialContent, authorNickname, }: Props) { const router = useRouter(); const [title, setTitle] = useState(initialTitle); const [content, setContent] = useState(initialContent); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [allowed, setAllowed] = useState<boolean | null>(null); useEffect(() => { const me = readUser(); // eslint-disable-next-line react-hooks/set-state-in-effect setAllowed(me !== null && me.nickname === authorNickname); }, [authorNickname]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); const token = readToken(); if (!token) { alert("로그인이 필요합니다."); router.push("/login"); return; } if (!title.trim()) { setError("제목을 입력해주세요."); return; } setSubmitting(true); try { await updatePost(postId, { title, content }, token); router.push(`/posts/${postId}`); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "수정 실패"); setSubmitting(false); } } if (allowed === false) { return ( <div className="max-w-3xl mx-auto px-4 py-12 text-center text-gray-600"> 본인이 작성한 글만 수정할 수 있습니다. </div> ); } return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold text-gray-900 mb-6">글 수정</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">제목</label> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 본문 <span className="text-xs text-gray-400">({content.length}자)</span> </label> <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={14} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && ( <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2"> {error} </p> )} <div className="flex justify-end gap-2"> <button type="button" onClick={() => router.back()} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 hover:bg-gray-100" > 취소 </button> <button type="submit" disabled={submitting} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 disabled:opacity-50" > {submitting ? "저장 중..." : "저장"} </button> </div> </form> </div> ); }
코드 해부
🔹 useState(initialTitle) 처럼 props 를 초깃값으로 넣기
이러면 폼이 처음 그려질 때 이미 기존 값으로 채워진 상태가 됩니다.
🔹 권한 체크용 useEffect 한 번
다른 사람이 직접 URL /posts/123/edit 을 입력해도 막아줘야 하니까, “나의 닉네임 == 글쓴이 닉네임” 인지 한 번 체크합니다.
✅ 확인하기
- 본인 글의 상세 페이지 → 수정 클릭
- 폼이 기존 제목/본문으로 미리 채워져 있어야 함
- 일부 고치고 저장 → 다시 상세 페이지로 이동, 내용이 바뀌어 있음
- 로그아웃하고
/posts/123/edit직접 접속 → "본인이 작성한 글만…" 메시지
STEP 11. 내 글 목록
마지막! “내가 쓴 글들(임시저장 + 발행)” 을 모아서 보는 페이지.
이 페이지는 토큰이 필요해서 서버 컴포넌트로는 만들 수 없어요. (서버는 localStorage를 못 읽음)
그래서 클라이언트 컴포넌트로 만들고, 화면이 그려진 직후 한 번 fetch 합니다.
src/app/my/page.tsx:
"use client"; /** * 내가 쓴 글 목록 ( /my ). * 토큰이 localStorage 에만 있어서 클라이언트 컴포넌트로 만듭니다. */ import Link from "next/link"; import { useEffect, useState } from "react"; import { listMyPosts, type PostListItem } from "@/lib/api"; import { readToken } from "@/lib/auth"; export default function MyPostsPage() { const [posts, setPosts] = useState<PostListItem[] | null>(null); const [error, setError] = useState(""); const [needLogin, setNeedLogin] = useState(false); useEffect(() => { const token = readToken(); if (!token) { // eslint-disable-next-line react-hooks/set-state-in-effect setNeedLogin(true); return; } listMyPosts(token) .then((data) => setPosts(data)) .catch((e) => setError(e instanceof Error ? e.message : "불러오기 실패")); }, []); if (needLogin) { return ( <div className="max-w-3xl mx-auto px-4 py-12 text-center"> <p className="text-gray-600 mb-4">로그인이 필요한 메뉴입니다.</p> <Link href="/login" prefetch={false} className="inline-block px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700" > 로그인하러 가기 </Link> </div> ); } return ( <div className="max-w-3xl mx-auto px-4 py-8"> <h1 className="text-2xl font-bold text-gray-900 mb-6">내 글</h1> {error && ( <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4"> {error} </p> )} {posts === null && !error ? ( <p className="text-gray-500 text-center py-12">불러오는 중...</p> ) : posts && posts.length === 0 ? ( <p className="text-gray-500 text-center py-12">아직 작성한 글이 없습니다.</p> ) : ( <ul className="bg-white rounded-lg border border-gray-200 divide-y divide-gray-100"> {posts?.map((post) => ( <li key={post.id} className="p-4 hover:bg-gray-50"> <Link href={`/posts/${post.id}`} prefetch={false} className="block"> <div className="flex items-start justify-between gap-3"> <div className="flex-1 min-w-0"> <h2 className="text-base font-semibold text-gray-900 line-clamp-1"> {post.title || "(제목 없음)"} </h2> {post.contentPreview && ( <p className="mt-1 text-sm text-gray-600 line-clamp-2"> {post.contentPreview} </p> )} <p className="mt-1 text-xs text-gray-500"> {(post.publishedAt ?? post.createdAt).slice(0, 10)} </p> </div> {!post.publishedAt && ( <span className="shrink-0 text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-700"> 임시저장 </span> )} </div> </Link> </li> ))} </ul> )} </div> ); }
코드 해부
🔹 3가지 상태
posts === null⇒ 아직 불러오는 중 → "불러오는 중..."posts.length === 0⇒ 다 받았는데 비어 있음 → "아직 작성한 글이 없습니다."- 그 외 ⇒ 실제 목록 표시
🔹 임시저장 표시
글의 publishedAt 이 비어 있으면 = 아직 발행 안 한 초안 = 노란 배지로 “임시저장”
✅ 확인하기
- 헤더의 내 글 클릭 →
/my - 내가 쓴 글들이 보임
- 로그아웃하고
/my직접 접속 → "로그인이 필요한 메뉴입니다."
🎉 마무리
여기까지 따라왔다면 완전한 게시판이 만들어졌어요!
# 개발용 npm run dev # 코드 검사 npm run lint # 빌드 (배포 전 검증) npm run build
우리가 배운 것
| 개념 | 어디서 썼지? |
|---|---|
App Router (src/app/...) | 모든 페이지 |
| 서버 컴포넌트 (async fetch) | 홈, 글 상세, 글 수정 페이지 |
클라이언트 컴포넌트 ("use client") | 모든 폼, 헤더, 내 글, OwnerActions |
동적 라우트 ([id]) | /posts/[id], /posts/[id]/edit |
searchParams | 페이지네이션 (?page=N) |
useRouter(), <Link> | 페이지 이동 |
useState | 모든 폼 입력값 |
useEffect | localStorage 읽기 (단 4곳!) |
metadata | 브라우저 탭 제목 (SEO 시작점) |
자주 만나는 에러
Q. localStorage is not defined
→ 서버 컴포넌트에서 readUser() / readToken() 을 부른 거예요. 그 함수들은 "use client" 파일에서만 부를 수 있습니다.
Q. Cannot destructure property '...' of '(intermediate value)'
→ params, searchParams 를 await 안 했을 때. Next.js 15+ 에선 둘 다 Promise 입니다.
Q. 로그인 후 새로고침 해야만 닉네임이 표시됨
→ Header 의 useEffect 의존성 배열을 [] 로 비웠을 때. 반드시 [pathname].
Q. useState is not a function
→ 파일 첫 줄에 "use client"; 안 쓴 경우.
Q. 글쓰기 한 번에 안 됨 / "발행 실패"
→ createDraft 와 publishPost 두 번 호출 흐름이에요. 둘 다 await 했는지 확인.
다음 도전
이 게시판에 더 추가해 볼 것들:
- 댓글 기능 —
/api/edu/ws-283fc1/posts/{id}/comments사용 - 이미지 업로드 —
multipart/form-data학습 - 검색 —
?q=keyword쿼리 - 북마크/좋아요
- 다크 모드 —
dark:Tailwind 클래스
행복한 코딩 되세요 🌿



댓글
댓글을 작성하려면 이 필요합니다.