Next.js로 블로그 만들기 #4 댓글 (Server Actions)

17 분 소요

지난 시간까지 우리 블로그는 읽기 전용이었습니다. 이번에는 글마다 댓글을 달 수 있게 합니다. 모던 리액트 시리즈에서 배운 Server Actions가 본격적으로 등장하는 글이에요.

데이터 저장 — 어디에 둘까?

댓글을 어딘가에 저장해야 합니다. 옵션:

  1. DB (PostgreSQL, SQLite, Supabase) — 실제 서비스라면 표준 선택
  2. JSON 파일 — fs로 읽고 쓰기. 학습용으로 단순
  3. 메모리 변수 — 프로세스가 살아있는 동안만 유지

이 시리즈는 메모리 변수로 갑니다. 이유:

  • DB 셋업이 필요 없어 학습 단순화
  • Server Actions의 핵심(검증 / 호출 / 갱신)에 집중 가능
  • 실전 DB 연동은 별도 주제 (Prisma, Drizzle 등)

단점은 명확합니다 — 서버를 재시작하면 댓글이 사라집니다. 학습용으로는 충분하고, 실제 배포할 땐 DB 또는 외부 서비스로 교체하면 돼요.

노트

운영 시 가장 가벼운 옵션은 Vercel KV (Redis 기반) 또는 Vercel Postgres입니다. Supabase의 무료 tier도 학습/소규모 운영에 충분해요. 이 시리즈에서는 다루지 않지만, 프로덕션에 갈 때 이 메모리 저장소를 바꿔 끼우는 것이 마지막 작업이 될 거예요.

댓글 데이터 모듈

src/app/lib/comments.js를 만듭니다.

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를 만듭니다.

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로 인자를 미리 묶어 사용할 수 있어요 (아래에서 사용). 이 패턴을 쓰면 어떤 글의 댓글인지 정보가 안전하게 전달됩니다.

prevStateformData

useActionState로 호출되는 Action은 첫 인자가 이전 state, 두 번째가 FormData입니다. 검증 결과를 객체로 반환하면 그게 다음 state가 됩니다.

검증

서버에서 검증하는 게 중요합니다. 클라이언트 검증은 UX 개선용이지 보안용이 아니에요. 누군가 직접 fetch로 우회 호출해도 서버에서 막혀야 합니다.

revalidatePath

댓글이 추가되면 /posts/${slug} 페이지의 캐시를 무효화합니다. 다음 렌더에서 새 댓글이 화면에 반영됩니다.

댓글 폼 — Client Component

src/app/posts/[slug]/CommentForm.jsx:

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.successtrue가 되면 formRef.current.reset()으로 폼의 입력값을 초기화합니다. UX상 자연스러운 동작.

useFormStatus로 제출 중 표시

SubmitButton을 별도 컴포넌트로 빼서 useFormStatus로 부모 폼의 pending 상태를 받았습니다. 폼이 제출 중이면 버튼이 비활성화 + 텍스트 변경.

댓글 목록

src/app/posts/[slug]/CommentList.jsx:

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 수정:

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에서 가져온다면 댓글이 살짝 늦게 나타나는 동안 글 본문은 즉시 보일 수 있어 좋은 패턴이에요. 점진적 로딩이 자연스럽게 들어가는 거죠.

동작 확인

저장하고 다음을 시도해보세요.

  1. 글 상세 페이지 이동
  2. 본문 아래 "댓글" 섹션이 보이고 "아직 댓글이 없습니다" 안내
  3. 폼에 작성자/내용 입력 후 제출
  4. 자동으로 댓글 목록에 추가됨 (revalidatePath 효과)
  5. 폼 입력값이 자동 리셋
  6. 빈 입력으로 제출 시도 → 에러 메시지
  7. 500자 넘는 입력 시 → 에러 메시지
  8. 다른 글로 이동했다가 돌아와도 (서버 재시작 안 했다면) 댓글 유지
  9. 다른 글에 댓글 달면 글마다 댓글이 따로 보관됨

/posts/hello-world/posts/learning-react 양쪽에 댓글을 달아보면 글별로 분리되어 있는 게 확인됩니다 — commentsBySlug 객체의 키가 slug라서 자연스럽게 그렇게 동작해요.

발전 방향 — 더 만들 수 있는 것들

지금 댓글 시스템의 여러 개선 여지:

  • 삭제 — 작성자(또는 관리자)가 자기 댓글 삭제. UUID로 타깃 식별
  • 편집 — 인라인 편집 (Todo 시리즈 #4의 패턴 재활용 가능)
  • 좋아요 — 댓글에 좋아요 누르기 (낙관적 UI = useOptimistic 좋은 예시)
  • 대댓글parentId 필드 추가
  • 인증 — 로그인 사용자만 작성, 작성자 자동 입력
  • 스팸 방지 — reCAPTCHA, rate limiting

각각이 별도 학습 거리이고, 이 시리즈에서는 핵심 흐름(폼 → Action → revalidate)만 다뤘습니다. 본인이 직접 발전시키기 좋은 영역들이에요.

흔한 함정

1. revalidatePath를 빼먹으면 화면이 갱신 안 됨

🚫 revalidate 누락
'use server';
export async function postComment(slug, prevState, formData) {
  // ... 검증 ...
  addComment(slug, { ... });
  return { success: true };
  // revalidatePath 호출 누락 → 화면이 갱신 안 됨
}

데이터는 서버에서 추가됐는데 클라이언트의 페이지 캐시가 그대로라 새 댓글이 안 보입니다. 사용자가 페이지를 새로고침해야 보이는 어색한 UX가 되니 revalidatePath를 잊지 마세요.

2. Action 안에서 직접 throw

🚫 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편의 큰 그림을 회고하며 마무리합니다.