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 ?? []} />;
}

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.