Ch.3에서 배운 개념들을 종합하여, Ch.2의 블로그 프로젝트에 실제 데이터 연동을 적용합니다. 구체적으로:
fetch로 교체useActionState로 폼 완성이번 챕터에서 http://localhost:4000/posts라는 URL이 계속 등장했습니다. 이제 직접 이 서버를 만들어 봅니다. json-server는 JSON 파일 하나로 REST API를 자동 생성해 주는 개발용 도구입니다.
먼저 프로젝트 루트에 db.json 파일을 만듭니다:
{
"posts": [
{
"id": 1,
"slug": "nextjs-routing",
"title": "Next.js 라우팅 이해하기"
},
{
"id": 2,
"slug": "react-server-components",
"title": "React 서버 컴포넌트란?"
}
]
}
Ch.1/Ch.2에서 하드코딩했던 slug와 title을 그대로 사용합니다. 이제 데이터가 코드 안이 아니라 db.json 파일에 저장될 뿐입니다.
json-server를 설치합니다:
npm install -D json-server
package.json의 scripts에 실행 명령을 추가합니다:
{
"scripts": {
"dev": "next dev",
"json-server": "json-server --watch db.json --port 4000"
}
}
설정이 끝났습니다. 실행해서 확인해 봅니다:
npm run json-server
브라우저에서 http://localhost:4000/posts에 접속하면 JSON 응답이 표시됩니다. 이것이 세션 1부터 사용해 온 API의 실체입니다.
세션 1에서 서버 컴포넌트의 fetch를 배웠습니다. 이제 Ch.2에서 만든 블로그 페이지를 직접 변환합니다.
// app/blog/page.tsx - 하드코딩된 데이터
import { SearchablePostList } from '../components/SearchablePostList';
const posts = [
{ id: 1, slug: 'nextjs-routing', title: 'Next.js 라우팅 이해하기' },
{ id: 2, slug: 'react-server-components', title: 'React 서버 컴포넌트란?' },
];
export default function Blog() {
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
// app/blog/page.tsx - 서버에서 데이터 가져오기
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog() {
const res = await fetch('http://localhost:4000/posts');
const posts = await res.json();
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
</div>
);
}
달라진 부분은 두 가지입니다:
function Blog() → async function Blog() (서버 컴포넌트는 async 가능)fetch로 외부 데이터 조회SearchablePostList는 변경 없이 그대로 재사용됩니다. 데이터가 하드코딩이든 서버에서 가져온 것이든, props로 받는 형태가 같으면 하위 컴포넌트는 신경 쓸 필요가 없습니다.
npm run json-server, 터미널 2에서 npm run dev를 실행한 뒤 /blog에 접속합니다. Ch.2와 동일한 글 목록이 표시되면 성공입니다.
Ch.2에서 만든 글 상세 페이지는 URL에 포함된 slug 값을 그대로 화면에 표시하는 간단한 구조였습니다. 이제 이 slug를 이용해 서버에서 실제 글 데이터를 조회하도록 변경해 보겠습니다. 즉 /blog/nextjs-routing 같은 경로로 접속하면, URL의 slug 값을 기준으로 서버에서 해당 글을 찾아 화면에 표시하는 방식입니다.
// app/blog/[slug]/page.tsx
import { LikeButton } from '@/app/components/LikeButton';
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<div>
<h1>{slug}</h1>
<p>이 글의 내용이 여기에 표시됩니다.</p>
<LikeButton />
</div>
);
}
// app/blog/[slug]/page.tsx
import { LikeButton } from '@/app/components/LikeButton';
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`http://localhost:4000/posts?slug=${slug}`);
const posts = await res.json();
const post = posts[0];
return (
<div>
<h1>{post.title}</h1>
<p>이 글의 내용이 여기에 표시됩니다.</p>
<LikeButton />
</div>
);
}
?slug= 쿼리 파라미터를 사용하나요?
json-server는 경로 기반 조회(/posts/1)에 숫자 ID를 사용합니다. slug처럼 문자열 필드로 찾을 때는 쿼리 파라미터(?slug=값)를 사용합니다. 결과가 배열로 반환되므로 posts[0]으로 첫 번째 항목을 꺼냅니다.
/blog/nextjs-routing으로 이동하고, db.json에 작성한 실제 제목이 표시됩니다.
세션 3에서 배운 것처럼, page.tsx와 같은 폴더에 loading.tsx를 놓으면 데이터를 가져오는 동안 자동으로 표시됩니다:
// app/blog/loading.tsx
export default function Loading() {
return <p>불러오는 중...</p>;
}
로컬 환경에서는 네트워크가 빨라서 로딩 UI가 순식간에 지나갑니다. 브라우저 개발자 도구의 네트워크 탭을 이용하면 인터넷 속도를 인위적으로 낮춰 로딩 UI를 눈으로 확인할 수 있습니다.
글 작성 페이지(/blog/new)를 만들고, createPost Server Action과 PostForm을 적용합니다. 전체 흐름은 다음과 같습니다:
redirect('/blog')로 목록 페이지로 이동하면 새 글이 표시됨
// app/actions.ts
'use server';
import { redirect } from 'next/navigation';
export async function createPost(
prevState: { error: string | null },
formData: FormData
) {
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
if (title.length < 2) {
return { error: '제목은 2글자 이상이어야 합니다.' };
}
if (!/^[a-z0-9-]+$/.test(slug)) {
return { error: 'slug는 영문 소문자, 숫자, 하이픈(-)만 사용할 수 있습니다.' };
}
await fetch('http://localhost:4000/posts', {
method: 'POST',
body: JSON.stringify({ title, slug }),
headers: { 'Content-Type': 'application/json' },
});
redirect('/blog');
}
유효성 검사에 실패하면 에러 객체를 반환하여 폼에 메시지를 표시하고, 성공하면 json-server에 저장한 뒤 목록 페이지로 이동합니다.
revalidatePath를 사용하지 않는 이유가 궁금할 수 있습니다. Step 2~3에서 작성한 fetch 코드를 다시 보면, 별도의 캐시 옵션(cache, next.revalidate)을 설정하지 않았습니다. Next.js 15부터 fetch의 기본 동작은 캐시하지 않음(no-store)이므로, 페이지에 접속할 때마다 항상 최신 데이터를 가져옵니다. 따라서 redirect('/blog')만으로 새 글이 반영된 목록을 볼 수 있습니다.
// app/components/PostForm.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export default function PostForm() {
const [state, formAction, isPending] = useActionState<
{ error: string | null },
FormData
>(createPost, { error: null });
return (
<form action={formAction}>
<div>
<label htmlFor="title">제목</label>
<input id="title" name="title" placeholder="제목을 입력하세요" />
</div>
<div>
<label htmlFor="slug">Slug</label>
<input id="slug" name="slug" placeholder="slug를 입력하세요" />
</div>
{state.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '작성 중...' : '작성하기'}
</button>
</form>
);
}
useActionState가 반환하는 state.error로 서버의 검사 결과를 바로 표시하고, isPending으로 제출 중 버튼을 비활성화합니다. 세션 5에서 배운 패턴을 그대로 적용한 것입니다.
글 목록 페이지에서 글 작성 페이지로 이동할 수 있도록 링크를 추가합니다:
export default async function Blog() {
...
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
<Link href="/blog/new">새 글 작성</Link>
</div>
);
}
// app/blog/new/page.tsx
import PostForm from '@/app/components/PostForm';
export default function NewPost() {
return (
<div>
<h1>새 글 작성</h1>
<PostForm />
</div>
);
}
/blog/new에 접속하여 제목과 slug를 입력하고 제출합니다. 글 목록에 새 글이 표시되면 성공입니다. slug에 한글이나 공백을 입력하면 에러 메시지가 표시되는 것도 확인해 보세요.
Ch.2에서 Ch.3으로 넘어오면서 프로젝트가 어떻게 변했는지 전체 구조를 확인합니다:
app/
├── about/
│ └── page.tsx [서버] 소개 페이지
├── blog/
│ ├── [slug]/
│ │ └── page.tsx [서버] fetch로 글 상세 조회
│ ├── new/
│ │ └── page.tsx [서버] PostForm import
│ ├── layout.tsx [서버] 블로그 레이아웃
│ ├── loading.tsx 자동 로딩 UI
│ └── page.tsx [서버] fetch로 글 목록 조회
├── components/
│ ├── CategorySidebar.tsx [클라이언트] 카테고리 사이드바
│ ├── LikeButton.tsx [클라이언트] 좋아요 버튼
│ ├── PostForm.tsx [클라이언트] useActionState + 폼
│ └── SearchablePostList.tsx [클라이언트] 검색 + 목록 표시
├── actions.ts [서버] 'use server' - createPost
├── layout.tsx [서버] 루트 레이아웃
└── page.tsx [서버] 홈 페이지
db.json json-server 데이터
| 실습 단계 | 적용한 Ch.3 개념 | 관련 세션 |
|---|---|---|
| json-server 설정 | 외부 데이터 소스 구축 | 세션 1 |
| 글 목록 fetch | 서버 컴포넌트에서 async/await | 세션 1 |
| 글 상세 fetch | 동적 라우트 + 서버 fetching | 세션 2 |
| loading.tsx | Streaming과 로딩 UI | 세션 3 |
| 글 작성 폼 | Server Actions + useActionState | 세션 5 |
이번 챕터에서는 하드코딩되어 있던 블로그 데이터를 외부로 옮기고, 데이터를 다루는 전체 흐름을 배웠습니다:
| 세션 | 질문 | 핵심 개념 |
|---|---|---|
| 1. 데이터 소스 | 데이터는 어디서 가져오지? | 서버 컴포넌트의 async/await으로 직접 데이터 접근 (fetch, DB, 파일) |
| 2. 렌더링 전략 | 화면은 언제 만들지? | 정적 렌더링이 기본, 동적 함수 사용 시 자동 전환 |
| 3. 로딩 처리 | 느린 데이터는 어떻게? | loading.tsx(페이지 전체) → Suspense(부분 로딩), Streaming |
| 4. 캐싱 | 매번 새로 가져와야 하나? | fetch 캐싱 옵션, 시간/요청 기반 재검증 |
| 5. 데이터 변경 | 데이터를 수정하려면? | 'use server' + form action, useActionState(에러·로딩 처리) |
| 6. 블로그 실습 | 실제로 적용하면? | json-server, fetch 연동, 폼 완성 |
<Link>의 동작 원리, usePathname, useSearchParams, useRouter 등 URL 기반 상태 관리를 배우고, Ch.1~4의 모든 개념을 종합하여 블로그를 고도화하는 실습을 진행합니다.