Next.js로 블로그 만들기 #4 댓글 (Server Actions)
지난 시간까지 우리 블로그는 읽기 전용이었습니다. 이번에는 글마다 댓글을 달 수 있게 합니다. 모던 리액트 시리즈에서 배운 Server Actions가 본격적으로 등장하는 글이에요.
데이터 저장 — 어디에 둘까?
댓글을 어딘가에 저장해야 합니다. 옵션:
- DB (PostgreSQL, SQLite, Supabase) — 실제 서비스라면 표준 선택
- JSON 파일 — fs로 읽고 쓰기. 학습용으로 단순
- 메모리 변수 — 프로세스가 살아있는 동안만 유지
이 시리즈는 메모리 변수로 갑니다. 이유:
- DB 셋업이 필요 없어 학습 단순화
- Server Actions의 핵심(검증 / 호출 / 갱신)에 집중 가능
- 실전 DB 연동은 별도 주제 (Prisma, Drizzle 등)
단점은 명확합니다 — 서버를 재시작하면 댓글이 사라집니다. 학습용으로는 충분하고, 실제 배포할 땐 DB 또는 외부 서비스로 교체하면 돼요.
운영 시 가장 가벼운 옵션은 Vercel KV (Redis 기반) 또는 Vercel Postgres입니다. Supabase의 무료 tier도 학습/소규모 운영에 충분해요. 이 시리즈에서는 다루지 않지만, 프로덕션에 갈 때 이 메모리 저장소를 바꿔 끼우는 것이 마지막 작업이 될 거예요.
댓글 데이터 모듈
src/app/lib/comments.js를 만듭니다.
const commentsBySlug = {};
export function getComments(slug) {
return commentsBySlug[slug] ?? [];
}
export function addComment(slug, comment) {
if (!commentsBySlug[slug]) commentsBySlug[slug] = [];
commentsBySlug[slug].push({
id: crypto.randomUUID(),
...comment,
createdAt: new Date().toISOString(),
});
}데이터 모양은:
{
id: '...uuid...',
author: '철수',
text: '좋은 글이네요',
createdAt: '2026-05-15T10:30:00.000Z',
}개발 중에는 hot reload 때 메모리가 비워질 수 있다는 점 참고하세요. dev 서버 재시작 시에도 마찬가지고요.
Server Action 정의
src/app/posts/[slug]/actions.js를 만듭니다.
'use server';
import { revalidatePath } from 'next/cache';
import { addComment } from '../../lib/comments';
export async function postComment(slug, prevState, formData) {
const author = formData.get('author')?.trim();
const text = formData.get('text')?.trim();
if (!author) return { error: '작성자 이름을 입력해주세요' };
if (author.length > 30) return { error: '작성자 이름은 30자 이내로' };
if (!text) return { error: '댓글 내용을 입력해주세요' };
if (text.length > 500) return { error: '댓글은 500자 이내로' };
addComment(slug, { author, text });
revalidatePath(`/posts/${slug}`);
return { success: true };
}핵심 포인트들:
'use server'
파일 맨 위에 두면 그 파일의 모든 export가 Server Action이 됩니다.
첫 번째 인자 slug
Server Action은 bind로 인자를 미리 묶어 사용할 수 있어요 (아래에서 사용). 이 패턴을 쓰면 어떤 글의 댓글인지 정보가 안전하게 전달됩니다.
prevState와 formData
useActionState로 호출되는 Action은 첫 인자가 이전 state, 두 번째가 FormData입니다. 검증 결과를 객체로 반환하면 그게 다음 state가 됩니다.
검증
서버에서 검증하는 게 중요합니다. 클라이언트 검증은 UX 개선용이지 보안용이 아니에요. 누군가 직접 fetch로 우회 호출해도 서버에서 막혀야 합니다.
revalidatePath
댓글이 추가되면 /posts/${slug} 페이지의 캐시를 무효화합니다. 다음 렌더에서 새 댓글이 화면에 반영됩니다.
댓글 폼 — Client Component
src/app/posts/[slug]/CommentForm.jsx:
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postComment } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
{pending ? '등록 중...' : '댓글 달기'}
</button>
);
}
export default function CommentForm({ slug }) {
const action = postComment.bind(null, slug);
const [state, formAction] = useActionState(action, {});
const formRef = useRef(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form
ref={formRef}
action={formAction}
style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '24px' }}
>
<input
name="author"
placeholder="작성자"
required
maxLength={30}
style={{ padding: '6px' }}
/>
<textarea
name="text"
placeholder="댓글 내용"
rows={3}
required
maxLength={500}
style={{ padding: '6px' }}
/>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<SubmitButton />
{state.error && (
<span style={{ color: 'tomato', fontSize: '14px' }}>{state.error}</span>
)}
{state.success && (
<span style={{ color: 'green', fontSize: '14px' }}>등록 완료!</span>
)}
</div>
</form>
);
}핵심:
bind로 slug 묶기
const action = postComment.bind(null, slug);postComment(slug, prevState, formData) 시그니처에서 slug를 미리 묶어 (prevState, formData) => ... 형태로 만듭니다. useActionState는 (prevState, formData) => newState 모양을 기대하니까요.
이 패턴 덕에 클라이언트가 slug를 폼 hidden input으로 넘기지 않아도 됩니다 (그래도 됐겠지만, hidden input은 누구나 조작 가능하니 서버 안에서 결정되는 게 더 안전).
useActionState
const [state, formAction] = useActionState(action, {});- 첫 반환값(
state): Action의 마지막 반환값. 우리 경우{ error: '...' }또는{ success: true } - 둘째 반환값(
formAction): 폼의action에 넘기는 래핑된 함수 - 초기 state는
{}(아무 메시지도 없는 초기 상태)
성공 시 폼 리셋
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);state.success가 true가 되면 formRef.current.reset()으로 폼의 입력값을 초기화합니다. UX상 자연스러운 동작.
useFormStatus로 제출 중 표시
SubmitButton을 별도 컴포넌트로 빼서 useFormStatus로 부모 폼의 pending 상태를 받았습니다. 폼이 제출 중이면 버튼이 비활성화 + 텍스트 변경.
댓글 목록
src/app/posts/[slug]/CommentList.jsx:
import { getComments } from '../../lib/comments';
export default function CommentList({ slug }) {
const comments = getComments(slug);
if (comments.length === 0) {
return <p style={{ color: '#888', marginTop: '16px' }}>아직 댓글이 없습니다. 첫 댓글을 달아보세요!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{comments.map(comment => (
<li key={comment.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{comment.author}</strong>
<small style={{ color: '#888' }}>
{new Date(comment.createdAt).toLocaleString('ko-KR')}
</small>
</div>
<p style={{ margin: '4px 0', whiteSpace: 'pre-wrap' }}>{comment.text}</p>
</li>
))}
</ul>
);
}이건 Server Component입니다 ('use client' 없음). 서버에서 getComments로 메모리 저장소에서 댓글을 읽어 그대로 그립니다.
white-space: pre-wrap은 사용자가 입력한 줄바꿈을 화면에 그대로 표시해주는 CSS 속성이에요.
글 상세 페이지에 댓글 영역 끼우기
src/app/posts/[slug]/page.js 수정:
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllSlugs } from '../../lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
import remarkGfm from 'remark-gfm';
import rehypePrettyCode from 'rehype-pretty-code';
import CommentList from './CommentList';
import CommentForm from './CommentForm';
export async function generateStaticParams() {
return getAllSlugs().map(slug => ({ slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post || post.frontmatter.draft) {
notFound();
}
const { content } = await compileMDX({
source: post.content,
options: {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode, { theme: 'github-light' }]],
},
},
});
return (
<article style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>{post.frontmatter.title}</h1>
<small style={{ color: '#888' }}>{post.frontmatter.date}</small>
<p style={{ color: '#555' }}>{post.frontmatter.description}</p>
<hr />
{content}
<section style={{ marginTop: '48px' }}>
<h2>댓글</h2>
<Suspense fallback={<p>댓글 불러오는 중...</p>}>
<CommentList slug={slug} />
</Suspense>
<CommentForm slug={slug} />
</section>
</article>
);
}<Suspense>로 댓글 영역을 감쌌습니다. 우리 메모리 저장소는 빨라서 fallback이 안 보일 수도 있는데, 진짜 DB에서 가져온다면 댓글이 살짝 늦게 나타나는 동안 글 본문은 즉시 보일 수 있어 좋은 패턴이에요. 점진적 로딩이 자연스럽게 들어가는 거죠.
동작 확인
저장하고 다음을 시도해보세요.
- 글 상세 페이지 이동
- 본문 아래 "댓글" 섹션이 보이고 "아직 댓글이 없습니다" 안내
- 폼에 작성자/내용 입력 후 제출
- 자동으로 댓글 목록에 추가됨 (revalidatePath 효과)
- 폼 입력값이 자동 리셋
- 빈 입력으로 제출 시도 → 에러 메시지
- 500자 넘는 입력 시 → 에러 메시지
- 다른 글로 이동했다가 돌아와도 (서버 재시작 안 했다면) 댓글 유지
- 다른 글에 댓글 달면 글마다 댓글이 따로 보관됨
/posts/hello-world와 /posts/learning-react 양쪽에 댓글을 달아보면 글별로 분리되어 있는 게 확인됩니다 — commentsBySlug 객체의 키가 slug라서 자연스럽게 그렇게 동작해요.
발전 방향 — 더 만들 수 있는 것들
지금 댓글 시스템의 여러 개선 여지:
- 삭제 — 작성자(또는 관리자)가 자기 댓글 삭제. UUID로 타깃 식별
- 편집 — 인라인 편집 (Todo 시리즈 #4의 패턴 재활용 가능)
- 좋아요 — 댓글에 좋아요 누르기 (낙관적 UI =
useOptimistic좋은 예시) - 대댓글 —
parentId필드 추가 - 인증 — 로그인 사용자만 작성, 작성자 자동 입력
- 스팸 방지 — reCAPTCHA, rate limiting
각각이 별도 학습 거리이고, 이 시리즈에서는 핵심 흐름(폼 → Action → revalidate)만 다뤘습니다. 본인이 직접 발전시키기 좋은 영역들이에요.
흔한 함정
1. revalidatePath를 빼먹으면 화면이 갱신 안 됨
'use server';
export async function postComment(slug, prevState, formData) {
// ... 검증 ...
addComment(slug, { ... });
return { success: true };
// revalidatePath 호출 누락 → 화면이 갱신 안 됨
}데이터는 서버에서 추가됐는데 클라이언트의 페이지 캐시가 그대로라 새 댓글이 안 보입니다. 사용자가 페이지를 새로고침해야 보이는 어색한 UX가 되니 revalidatePath를 잊지 마세요.
2. Action 안에서 직접 throw
export async function postComment(slug, prevState, formData) {
if (!formData.get('text')) throw new Error('내용 없음');
// ...
}throw하면 가까운 error.js가 가로채서 페이지 전체가 에러 화면으로 바뀝니다. 검증 실패는 폼 안에서 문구로 보여주는 게 자연스러우니 return으로 에러 메시지 객체를 돌려주세요. 진짜로 예상치 못한 에러(DB 다운 등)는 throw가 맞습니다.
3. Client Component에서 직접 메모리 저장소 import
'use client';
import { getComments } from '../../lib/comments';
// ...commentsBySlug는 서버 메모리에 사는 변수입니다. Client Component는 브라우저에서 실행되니 그 변수에 접근할 수 없어요. 서버 데이터 접근은 항상 Server Component 또는 Server Action을 거쳐야 합니다.
마무리
이번 글에서는 댓글 기능을 통해 Server Actions를 실전에 적용했습니다.
- 메모리 저장소 +
getComments/addComment함수 'use server'Action에서 검증 + 저장 +revalidatePath- 클라이언트 폼:
useActionState로 결과 받기 +useFormStatus로 pending 표시 bind로 Server Action에 추가 인자 묶기- 성공 시 폼 자동 리셋, 에러 메시지 인라인 표시
- 댓글 목록은 Server Component로 단순하게
지금 우리 블로그는 기본 기능을 다 갖췄습니다. 글쓰기, 보기, 분류, 검색, 댓글까지. 다음 글이자 시리즈의 마지막인 "Next.js로 블로그 만들기 #5 SEO와 배포"에서는 metadata API로 검색 엔진 최적화를 하고, sitemap과 RSS를 만들고, Vercel에 배포해 실제로 인터넷에 띄우는 곳까지 갑니다. 그리고 시리즈 전체 + 리액트 콘텐츠 26편의 큰 그림을 회고하며 마무리합니다.