06 · Developer Guide · 레시피
Supabase 스키마 · 권한 정책Database Schema & RLS
정적 시안에서 동적 사이트로 가는 첫 관문이 데이터베이스 설계입니다.
PostgreSQL + 행 단위 보안(RLS)으로 회원·게시판·신청·일정 4개 영역을 한 스키마에 담습니다.
언제 쓰는가
- 관리자가 글을 직접 쓰는 게시판 (시안의 정적 배열을 DB로 옮길 때)
- 로그인한 회원만 보는 정보 (중보기도 응답·내 신청 내역)
- 신청 폼 제출물을 담당자가 처리·통계 내야 하는 경우
① 테이블 4개로 압축
profiles(회원) · posts(게시판 통합) · applications(신청 폼) · events(일정). 게시판은 종류만 다를 뿐 구조가 같으므로 board_kind enum 한 컬럼으로 통합합니다.
supabase/migrations/0001_init.sqlsql
-- supabase/migrations/0001_init.sql
-- 1. 회원 프로필 (auth.users 확장)
create table public.profiles (
id uuid primary key references auth.users on delete cascade,
nickname text not null,
phone text,
parish text, -- 1교구·2교구·3교구·…
role text not null default 'member' check (role in ('member','staff','admin')),
created_at timestamptz not null default now()
);
-- 2. 게시판 (news·bulletin·hamjul·qt 통합)
create type board_kind as enum ('news','bulletin','hamjul','qt','prayer','counsel');
create table public.posts (
id bigint generated always as identity primary key,
kind board_kind not null,
slug text not null,
title text not null,
body text, -- MDX 또는 HTML
cover_image text,
author_id uuid references public.profiles,
published_at timestamptz, -- null이면 비공개 / 작성 중
meta jsonb not null default '{}'::jsonb, -- 설교자·성구·호별 번호 등
created_at timestamptz not null default now(),
unique (kind, slug)
);
create index posts_kind_published_idx
on public.posts (kind, published_at desc nulls last);
-- 3. 신청 폼 제출물 (상담·새가족·중보기도)
create table public.applications (
id bigint generated always as identity primary key,
form_kind text not null, -- 'counsel' | 'new-family' | 'prayer'
data jsonb not null, -- 폼 필드 그대로 저장
status text not null default 'received'
check (status in ('received','in-progress','done','rejected')),
assignee_id uuid references public.profiles,
created_at timestamptz not null default now()
);
-- 4. 일정 (예배·행사)
create table public.events (
id bigint generated always as identity primary key,
title text not null,
description text,
location text,
starts_at timestamptz not null,
ends_at timestamptz not null,
rrule text, -- iCal RRULE (매주 일요일 등)
category text not null, -- '주일예배·새벽예배·구역모임·교육'
visibility text not null default 'public',
created_at timestamptz not null default now()
);② 행 단위 보안 정책 (RLS)
Supabase는 anon 키로 직접 DB에 접근하므로 RLS를 켜지 않으면 누구나 모든 행을 읽을 수 있습니다. RLS는 보안이 아니라 기본값입니다.
sql
-- 행 단위 보안 정책 (Row Level Security)
alter table public.profiles enable row level security;
alter table public.posts enable row level security;
alter table public.applications enable row level security;
alter table public.events enable row level security;
-- profiles: 본인만 자기 행 읽기·수정, 관리자는 전체
create policy "profiles_self_read" on public.profiles
for select using (auth.uid() = id);
create policy "profiles_admin_read" on public.profiles
for select using (
exists (select 1 from public.profiles p
where p.id = auth.uid() and p.role = 'admin')
);
create policy "profiles_self_update" on public.profiles
for update using (auth.uid() = id);
-- posts: 게시된 글은 누구나 읽기, 작성·수정은 staff/admin만
create policy "posts_public_read" on public.posts
for select using (published_at is not null);
create policy "posts_staff_write" on public.posts
for all using (
exists (select 1 from public.profiles p
where p.id = auth.uid() and p.role in ('staff','admin'))
);
-- applications: 본인 제출물만 읽기, 담당자·관리자는 전체
create policy "applications_self_read" on public.applications
for select using (
(data->>'submitter_id')::uuid = auth.uid()
or assignee_id = auth.uid()
or exists (select 1 from public.profiles p
where p.id = auth.uid() and p.role = 'admin')
);
create policy "applications_anyone_insert" on public.applications
for insert with check (true); -- 비로그인 사용자도 신청 가능
-- events: 공개 일정은 누구나 읽기, 작성·수정은 staff/admin
create policy "events_public_read" on public.events
for select using (visibility = 'public');
create policy "events_staff_write" on public.events
for all using (
exists (select 1 from public.profiles p
where p.id = auth.uid() and p.role in ('staff','admin'))
);③ 가입 트리거 — 프로필 자동 생성
sql
-- 새 사용자 가입 시 profiles 자동 생성
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, nickname)
values (
new.id,
coalesce(new.raw_user_meta_data->>'name', '성도')
);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();④ Next.js 서버 컴포넌트에서 가져오기
tsx
// 서버 컴포넌트에서 게시물 가져오기
import { createClient } from "@/lib/supabase/server";
export default async function NewsPage() {
const supabase = await createClient();
const { data: posts } = await supabase
.from("posts")
.select("id, slug, title, cover_image, published_at, meta")
.eq("kind", "news")
.not("published_at", "is", null)
.order("published_at", { ascending: false })
.limit(20);
return <NewsList posts={posts ?? []} />;
}