본문 바로가기
튜토리얼/웹

[Next.js] Supabase로 방명록 만들기 (풀스택)

by 루까(Luka) 2026. 2. 27.
반응형

Next.js + Supabase 방명록 — 풀스택 한 번에 끝내기

왜 만들었나

방명록은 풀스택 입문으로 딱 좋은 예제다. DB 읽기/쓰기/삭제, 서버-클라이언트 경계, 폼 처리까지 한 프로젝트에 다 들어있다. Next.js App Router + Supabase 조합으로 백엔드 서버 없이 풀스택을 구현했다. 별도 API 서버 없이 Server Action 하나로 DB 쓰기까지 처리된다.

구현 기능:

  • 방명록 목록 조회 (Server Component로 SSR)
  • 이름 + 내용 입력 후 저장 (Server Action)
  • 메시지 삭제 (Server Action + useTransition)
  • ISR (30초마다 재생성)

기술 상세

아키텍처

app/
├── page.tsx          ← Server Component: 목록 fetch + 레이아웃
├── actions.ts        ← Server Actions: addMessage, deleteMessage
├── layout.tsx
└── globals.css

components/
├── MessageForm.tsx   ← Client Component: 폼 입력 + 에러 처리
└── MessageList.tsx   ← Client Component: 목록 + 삭제

lib/
└── supabase.ts       ← Supabase 클라이언트 + Message 타입

Server Component(page.tsx)가 DB를 읽어 초기 HTML을 서버에서 생성하고, Client Component들이 인터랙션을 담당한다. Server Action(actions.ts)이 폼 제출과 삭제를 처리한다.

Supabase 테이블 설정

Supabase Dashboard에서 SQL Editor로 아래를 실행한다.

create table messages (
  id         bigserial primary key,
  name       text      not null,
  content    text      not null,
  created_at timestamptz default now()
);

-- 누구나 읽기 가능
alter table messages enable row level security;
create policy "Read all" on messages for select using (true);
create policy "Insert all" on messages for insert with check (true);
create policy "Delete all" on messages for delete using (true);

.env.local 파일에 Supabase 키를 설정한다.

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

핵심 소스 코드

Server Component에서 DB 직접 조회

// app/page.tsx
import { supabase } from '@/lib/supabase'

export const revalidate = 30  // 30초 ISR

async function getMessages() {
  const { data, error } = await supabase
    .from('messages')
    .select('*')
    .order('created_at', { ascending: false })
    .limit(50)

  if (error) return []
  return data
}

export default async function Home() {
  const messages = await getMessages()

  return (
    <main>
      <MessageForm />
      <MessageList messages={messages} />
    </main>
  )
}

Server Component는 async/await를 직접 쓸 수 있다. API 라우트 없이 DB를 바로 조회하고 HTML로 렌더링한다.

Server Action — 메시지 저장

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { supabase } from '@/lib/supabase'

export async function addMessage(formData: FormData) {
  const name    = (formData.get('name') as string).trim()
  const content = (formData.get('content') as string).trim()

  if (!name || !content) return { error: '이름과 내용을 모두 입력해주세요.' }
  if (name.length > 20)   return { error: '이름은 20자 이내로 입력해주세요.' }
  if (content.length > 200) return { error: '내용은 200자 이내로 입력해주세요.' }

  const { error } = await supabase
    .from('messages')
    .insert({ name, content })

  if (error) return { error: '저장에 실패했습니다.' }

  revalidatePath('/')  // ISR 캐시 무효화 → 즉시 반영
  return { success: true }
}

'use server' 지시어 하나로 이 함수가 서버에서만 실행되도록 격리된다. revalidatePath('/')가 캐시를 무효화해서 저장 직후 목록에 새 메시지가 보인다.

Client Component에서 Server Action 호출

// components/MessageForm.tsx
'use client'

import { useRef, useState, useTransition } from 'react'
import { addMessage } from '@/app/actions'

export default function MessageForm() {
  const formRef  = useRef<HTMLFormElement>(null)
  const [error, setError]            = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      const result = await addMessage(formData)
      if (result?.error) {
        setError(result.error)
      } else {
        setError(null)
        formRef.current?.reset()
      }
    })
  }

  return (
    <form ref={formRef} action={handleSubmit}>
      {/* ... */}
      <button disabled={isPending}>
        {isPending ? '저장 중...' : '남기기'}
      </button>
    </form>
  )
}

useTransition으로 Server Action 호출 중 isPending 상태를 얻는다. 버튼 비활성화, 로딩 텍스트 변경을 isPending 하나로 처리한다.


기술 선택 비교

항목 이번 선택 대안
DB Supabase (PostgreSQL managed) PlanetScale, Neon, Firebase
쓰기 처리 Server Action API Route (/api/messages)
읽기 방식 Server Component SSR + ISR Client-side fetch, SWR
삭제 UI 상태 useTransition useState + 별도 loading 상태

Server Action은 API Route 대비 보일러플레이트가 적다. fetch('/api/messages', { method: 'POST', body: ... }) 대신 함수 하나 import해서 호출하면 끝이다.


삽질 기록

1. Server Action에서 return 값을 쓰려면 클라이언트가 직접 호출해야 함

<form action={serverAction}> 방식은 에러 메시지를 돌려받기 어렵다. useTransition과 함께 action={handleSubmit}으로 래핑해서 startTransition 안에서 호출하는 구조로 바꿨다.

2. revalidatePath 없으면 목록이 안 갱신됨

Server Action으로 insert 성공해도 ISR 캐시가 살아있어서 목록이 그대로였다. revalidatePath('/') 호출 후 즉시 반영 확인.

3. Supabase RLS 미설정 시 insert 실패

Row Level Security가 기본 활성화라 정책 없이 insert하면 403이 반환된다. create policy "Insert all" on messages for insert with check (true) 추가로 해결.


마무리

Next.js App Router + Supabase 조합의 핵심은 이거다.

  • Server Component: API 없이 DB 직접 조회 → SSR HTML
  • Server Action: 'use server' 하나로 서버 전용 함수 분리
  • revalidatePath: 쓰기 후 캐시 무효화로 즉시 반영
  • useTransition: 비동기 작업 중 UI 상태 간단하게 처리

백엔드 서버 따로 안 띄우고 풀스택이 된다. Supabase가 DB + API를 대신하고, Server Action이 비즈니스 로직을 담당한다.


기술 스택: Next.js 15 (App Router), TypeScript, Supabase, TailwindCSS, Server Action
소스 코드: GitHub
데모: Vercel

반응형