# [Next.js + TS] 7. 폼과 서버 액션 입문

[Next.js + TS] 7. 폼과 서버 액션 입문

6편까지는 메모를 읽기만 했습니다.
이번 편은 폼으로 메모를 추가합니다.
목표: Server Action · FormData · revalidatePath로 목록 갱신.


이 편에서 할 일

  1. Server Action이 뭔지 짧게 이해하기
  2. createMemo 액션 만들기
  3. /memos/new 작성 폼 페이지 만들기
  4. 저장 후 /memos 목록이 갱신되는지 확인하기

DB는 아직 없습니다.
메모리 배열에 넣고, 새로고침 없이 목록이 바뀌는 흐름만 익힙니다.


왜 Server Action인가

예전에 흔했던 흐름:

  1. 클라이언트에서 fetch('/api/memos', { method: 'POST' ... })
  2. API 라우트에서 저장
  3. 다시 목록 fetch

App Router에서는 더 단순한 길이 있습니다.

폼이 서버 함수를 직접 호출한다 → Server Action

  • 별도의 API 라우트를 꼭 안 만들어도 됨
  • 서버에서만 실행됨 (비밀키/DB 접근에 유리)
  • <form action={createMemo}> 로 연결 가능

전부 Action으로 대체하라는 뜻은 아닙니다.
폼 제출·간단한 mutation부터 배우기 좋습니다.


1) 데이터 레이어에 “추가” 붙이기

lib/memos.ts에 생성 함수를 추가합니다.

import type { Memo } from "@/types/memo";

const memos: Memo[] = [
  {
    id: "1",
    title: "Next.js 시작",
    content: "App Router로 프로젝트 만들었다.",
    createdAt: "2026-07-29",
  },
  {
    id: "2",
    title: "TypeScript props",
    content: "MemoCard에 타입을 붙였다.",
    createdAt: "2026-07-29",
  },
];

function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function getMemos(): Promise<Memo[]> {
  await delay(800);
  return memos;
}

export async function getMemoById(id: string): Promise<Memo | undefined> {
  await delay(500);
  return memos.find((memo) => memo.id === id);
}

export async function addMemo(input: {
  title: string;
  content: string;
}): Promise<Memo> {
  await delay(300);

  const memo: Memo = {
    id: String(Date.now()),
    title: input.title,
    content: input.content,
    createdAt: new Date().toISOString().slice(0, 10),
  };

  memos.unshift(memo); // 맨 앞에 추가
  return memo;
}
  • id는 실습용으로 Date.now() 사용
  • 서버를 재시작하면 배열은 초기값으로 돌아갑니다 (메모리라서 정상)

2) Server Action 파일

경로: app/memos/actions.ts

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { addMemo } from "@/lib/memos";

export async function createMemo(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  const content = String(formData.get("content") ?? "").trim();

  if (!title || !content) {
    throw new Error("제목과 내용을 모두 입력하세요");
  }

  await addMemo({ title, content });

  revalidatePath("/memos");
  redirect("/memos");
}

읽어볼 것:

코드 의미
"use server" 이 파일의 함수는 서버에서 실행
FormData <form>이 보낸 값 묶음
formData.get("title") name="title" 입력값
revalidatePath("/memos") 목록 캐시/화면을 다시 준비
redirect("/memos") 저장 후 목록으로 이동

"use server"는 파일 맨 위에 둡니다.
(함수 단위로 쓸 수도 있지만, 입문에서는 파일 단위가 단순합니다.)


3) 작성 페이지: /memos/new

app/memos/new/page.tsx
import { createMemo } from "@/app/memos/actions";

export default function NewMemoPage() {
  return (
    <main>
      <h1>새 메모</h1>

      <form action={createMemo}>
        <p>
          <label>
            제목
            <br />
            <input type="text" name="title" required />
          </label>
        </p>

        <p>
          <label>
            내용
            <br />
            <textarea name="content" rows={5} required />
          </label>
        </p>

        <button type="submit">저장</button>
      </form>
    </main>
  );
}

포인트:

  • 'use client' 없음 → 서버 컴포넌트 페이지
  • action={createMemo} 로 서버 함수 연결
  • input의 nameFormData 키와 같아야 함

http://localhost:3000/memos/new 에서 작성 → 저장하면 /memos로 이동하고 새 메모가 보여야 합니다.


4) 목록에서 “새 메모” 링크

app/memos/page.tsx에 링크를 하나 추가합니다.

import Link from "next/link";
import MemoCard from "@/components/MemoCard";
import { getMemos } from "@/lib/memos";

export default async function MemosPage() {
  const memos = await getMemos();

  return (
    <main>
      <h1>메모 목록</h1>
      <p>
        <Link href="/memos/new">새 메모 작성</Link>
      </p>

      <section>
        {memos.map((memo) => (
          <MemoCard key={memo.id} memo={memo} />
        ))}
      </section>
    </main>
  );
}

원하면 layout 내비에도 /memos/new를 넣어도 됩니다.


동작 흐름 (한눈에)

/memos/new 에서 제출
    ↓
createMemo (Server Action)
    ↓
addMemo (메모리 배열에 push)
    ↓
revalidatePath("/memos")
    ↓
redirect("/memos")
    ↓
목록이 새 데이터로 다시 렌더

클라이언트에서 useState로 목록을 직접 고치지 않아도 됩니다.
서버가 데이터를 바꾸고, 해당 경로를 다시 그리게 합니다.


최소 검증만 넣은 이유

지금은:

if (!title || !content) {
  throw new Error("...");
}

정도만 했습니다.

실서비스에서는:

  • 필드별 에러 메시지 표시
  • useActionState 같은 패턴
  • zod 등 스키마 검증

으로 확장합니다.
입문 편에서는 폼 → 서버 → 목록 갱신 한 바퀴가 목표입니다.


자주 하는 실수

1. input에 name을 안 줌
formData.get("title")이 비어 있습니다

2. "use server"를 클라이언트 컴포넌트 한가운데에 무작정 붙임
→ Action은 서버 파일(또는 서버 함수)로 분리하세요

3. revalidatePath를 빼먹음
→ 저장은 됐는데 목록이 옛 화면일 수 있습니다

4. 서버 재시작 후 메모가 사라짐
→ 메모리 배열이라 정상입니다. DB 붙이면 유지됩니다

5. action={createMemo()} 처럼 호출해 버림
action={createMemo} (함수 참조)여야 합니다


이 편에서 가져갈 것

  • Server Action은 폼이 호출하는 서버 함수다
  • "use server" + FormData + name 속성이 기본 세트다
  • 저장 후 revalidatePath로 목록을 갱신한다
  • redirect로 다음 화면으로 보낸다
  • 지금은 메모리, 나중엔 DB로 addMemo만 바꾸면 된다

다음 편 예고

[Next.js + TS] 8. 정리 + 다음 학습 로드맵

  • 1~7편 한눈에 복습
  • 지금 앱에서 부족한 점 (DB, 인증, 배포)
  • 다음에 공부하면 좋은 순서

시리즈 마무리다운 체크리스트 + 로드맵으로 끝냅니다.


시리즈 목차

  1. 왜 Next.js + TypeScript인가
  2. 프로젝트 만들고 폴더 구조 이해하기
  3. TypeScript로 컴포넌트 타입 잡기
  4. 라우팅과 레이아웃
  5. Server Component vs Client Component
  6. 데이터 가져오기
  7. 폼과 서버 액션 입문 ← 지금
  8. 정리 + 다음 학습 로드맵