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
'튜토리얼 > 웹' 카테고리의 다른 글
| [HTML/CSS/JS] Canvas API로 그림판 만들기 (0) | 2026.03.01 |
|---|---|
| [HTML/CSS/JS] 로컬스토리지 메모장 앱 만들기 (0) | 2026.02.28 |
| [HTML/CSS/JS] 다크모드 토글 포트폴리오 페이지 만들기 (0) | 2026.02.28 |
| [React] 실시간 환율 계산기 만들기 (API 연동) (0) | 2026.02.27 |
| [HTML/CSS/JS] 드래그앤드롭 Todo 앱 만들기 (0) | 2026.02.26 |