콘텐츠로 이동
Study NoteSupabase

16. 실전 패턴과 안티패턴

정책이 길어지면 헬퍼 함수로 추출한다. 재사용되고, 빨라지고, 어긋나지 않는다

create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
created_at timestamptz not null default now()
);
create table public.org_members (
org_id uuid not null references public.organizations (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role text not null default 'member' check (role in ('owner','admin','member')),
primary key (org_id, user_id)
);
create index org_members_user_idx on public.org_members (user_id);
-- 모든 업무 테이블은 org_id를 갖는다 (테넌트 키)
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations (id) on delete cascade,
name text not null
);
create index projects_org_idx on public.projects (org_id);

정책이 참조하는 org_members에도 RLS가 걸리므로, 헬퍼로 한 번 감싼다.

create schema if not exists private;
-- 내가 속한 조직 목록
create or replace function private.my_orgs()
returns setof uuid
language sql stable security definer set search_path = ''
as $$
select org_id from public.org_members where user_id = auth.uid();
$$;
-- 특정 조직에서의 내 역할
create or replace function private.my_role(target_org uuid)
returns text
language sql stable security definer set search_path = ''
as $$
select role from public.org_members
where user_id = auth.uid() and org_id = target_org;
$$;
-- 정책을 평가하는 역할만 스키마와 함수에 접근시킨다
revoke all on function private.my_orgs() from public;
revoke all on function private.my_role(uuid) from public;
grant usage on schema private to authenticated;
grant execute on function private.my_orgs() to authenticated;
grant execute on function private.my_role(uuid) to authenticated;
alter table public.projects enable row level security;
create policy "조직 멤버는 조회"
on public.projects for select to authenticated
using ( org_id in (select private.my_orgs()) );
create policy "admin 이상만 생성"
on public.projects for insert to authenticated
with check ( private.my_role(org_id) in ('owner','admin') );
create policy "admin 이상만 수정/삭제"
on public.projects for all to authenticated
using ( private.my_role(org_id) in ('owner','admin') )
with check ( private.my_role(org_id) in ('owner','admin') );

모든 테넌트 테이블이 이 두 함수만 참조하게 만드는 것이 핵심이다. 멤버십 규칙이 바뀌어도 고칠 곳이 두 군데뿐이다.

create table public.invitations (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations (id) on delete cascade,
email text not null,
role text not null default 'member',
token text not null unique default encode(gen_random_bytes(24), 'hex'),
expires_at timestamptz not null default now() + interval '7 days',
accepted_at timestamptz,
unique (org_id, email)
);
  1. admin이 초대 생성 → 트리거가 Edge Function 호출 → 초대 메일 발송

  2. 수신자가 링크 클릭 → 로그인 또는 가입

  3. 가입 후 RPC 호출(accept_invitation(token)) → org_members에 추가

RPC로 처리하는 이유: 토큰 검증 + 만료 확인 + 멤버 추가 + 초대 소진을 하나의 트랜잭션으로 처리해야 하기 때문이다.

create table public.messages (
id bigint generated always as identity primary key,
room_id uuid not null references public.rooms (id) on delete cascade,
user_id uuid not null default auth.uid() references auth.users (id),
body text not null check (char_length(body) between 1 and 4000),
created_at timestamptz not null default now()
);
create index messages_room_created_idx on public.messages (room_id, created_at desc);
alter table public.messages enable row level security;
create policy "방 참여자만 조회" on public.messages for select to authenticated
using ( room_id in (
select room_id from public.room_members where user_id = (select auth.uid())
) );
create policy "방 참여자만 전송" on public.messages for insert to authenticated
with check ( room_id in (
select room_id from public.room_members where user_id = (select auth.uid())
) );
// 초기 로딩은 Server Component에서
const { data: initial } = await supabase
.from('messages')
.select('id, body, created_at, profiles ( username )')
.eq('room_id', roomId)
.order('created_at', { ascending: false })
.limit(50)
'use client'
// 구독 + 타이핑 표시(Broadcast) + 접속자(Presence)를 한 채널에서
const channel = supabase.channel(`room:${roomId}`, { config: { private: true } })
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages',
filter: `room_id=eq.${roomId}` },
({ new: m }) => append(m))
.on('broadcast', { event: 'typing' }, ({ payload }) => showTyping(payload.userId))
.on('presence', { event: 'sync' }, () => setOnline(channel.presenceState()))
.subscribe(async s => { if (s === 'SUBSCRIBED') await channel.track({ at: Date.now() }) })

세 기능을 한 채널에 얹는 것이 요령이다. 연결이 하나면 관리도 하나다. 구독자가 수천 명 규모가 되면 postgres_changes 대신 트리거 + realtime.broadcast_changes()로 전환한다 (9장).

결제 흐름 — Vercel Route Handler가 Stripe Checkout Session을 만들고, 결제 완료 웹훅을 Edge Function이 서명 검증·멱등 확인 후 Postgres에 반영한다

왜 결제 시작은 Vercel, 웹훅은 Supabase인가? 결제 시작은 사용자 세션과 프론트 흐름에 붙어 있다. 웹훅은 프론트 배포와 무관하게 항상 살아 있어야 하고, 목적이 DB 반영이다.

처리 완료 표식과 구독 상태 변경은 같은 DB 트랜잭션에 있어야 한다. 표식을 먼저 커밋하고 실제 처리가 실패하면 재시도가 중복으로 오인되어 영원히 건너뛰기 때문이다.

create table private.processed_webhook_events (
id text primary key,
event_type text not null,
processed_at timestamptz not null default now()
);
create or replace function public.apply_stripe_event(
event_id text,
event_type text,
event_payload jsonb
)
returns boolean
language plpgsql
security definer set search_path = ''
as $$
begin
insert into private.processed_webhook_events (id, event_type)
values (event_id, event_type)
on conflict (id) do nothing;
if not found then
return false; -- 이미 처리됨
end if;
-- 여기서 subscriptions 등 도메인 테이블을 갱신한다.
-- 예외가 나면 위 insert도 함께 롤백되어 Stripe 재시도가 다시 처리할 수 있다.
return true;
end;
$$;
revoke all on function public.apply_stripe_event(text, text, jsonb) from public;
grant execute on function public.apply_stripe_event(text, text, jsonb) to service_role;
// supabase/functions/stripe-webhook/index.ts (verify_jwt = false)
import { withSupabase } from 'npm:@supabase/server'
import Stripe from 'npm:stripe@17'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
export default {
fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
const sig = req.headers.get('stripe-signature')
const raw = await req.text()
let event
try {
event = await stripe.webhooks.constructEventAsync(
raw, sig!, Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
const { error } = await ctx.supabaseAdmin.rpc('apply_stripe_event', {
event_id: event.id,
event_type: event.type,
event_payload: event,
})
if (error) return new Response('retry', { status: 500 })
return new Response('ok', { status: 200 })
}),
}
create table public.documents (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations (id) on delete cascade,
title text not null,
storage_path text not null unique,
size_bytes bigint,
created_by uuid not null default auth.uid(),
created_at timestamptz not null default now()
);
create policy "조직 문서 조회" on public.documents for select to authenticated
using ( org_id in (select private.my_orgs()) );
-- Storage 정책도 같은 조직 규칙을 따르게
create policy "조직 폴더 접근" on storage.objects for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] in (select private.my_orgs()::text)
);

경로 규칙: documents/<org_id>/<document_id>.<ext>

DB 정책과 Storage 정책이 같은 헬퍼 함수를 공유하면 규칙이 어긋나지 않는다. 멤버십 로직을 두 벌 유지하는 순간, 언젠가 한쪽만 고치게 된다.

RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM(Large Language Model, 대규모 언어 모델)이 모르는 내 데이터로 답하게 하는 표준 패턴이다. 모델을 다시 학습시키는 대신, 질문과 관련된 문서를 검색해서 프롬프트에 함께 넣어 준다. 그래서 파이프라인이 둘로 나뉜다 — 문서를 미리 벡터로 색인해 두는 쪽과, 질문이 올 때 유사 문서를 찾아 LLM에 전달하는 쪽.

RAG 구조 — 비동기 색인은 문서를 청크로 나눠 임베딩을 저장하고, 동기 질의는 match_documents RPC로 그 벡터를 찾아 LLM에 넘긴다
  • RLS가 그대로 적용된다 — 사용자가 접근 가능한 문서에서만 검색된다. 별도 필터링 코드가 없다는 게 전용 벡터 DB 대비 가장 큰 이점이다
  • 색인은 Edge Function + pgmq로 비동기 처리 (문서가 많으면 시간이 걸린다)
  • 답변 스트리밍은 Vercel(프레임워크 스트리밍 지원)이 유리하다
create table public.audit_logs (
id bigint generated always as identity primary key,
table_name text not null,
record_id text not null,
action text not null, -- INSERT | UPDATE | DELETE
actor_id uuid,
old_data jsonb,
new_data jsonb,
created_at timestamptz not null default now()
);
create index audit_logs_record_idx
on public.audit_logs (table_name, record_id, created_at desc);
create or replace function public.audit_trigger()
returns trigger language plpgsql security definer set search_path = ''
as $$
begin
insert into public.audit_logs
(table_name, record_id, action, actor_id, old_data, new_data)
values (
tg_table_name,
coalesce(new.id, old.id)::text,
tg_op,
auth.uid(),
case when tg_op in ('UPDATE','DELETE') then to_jsonb(old) end,
case when tg_op in ('INSERT','UPDATE') then to_jsonb(new) end
);
return coalesce(new, old);
end;
$$;
create trigger projects_audit
after insert or update or delete on public.projects
for each row execute function public.audit_trigger();

jsonb로 통째로 남기면 어떤 테이블에도 같은 트리거를 재사용할 수 있다. 감사 로그는 빠르게 커지므로 보관 기간 정책과 정리 배치를 함께 만든다.

alter table public.projects add column deleted_at timestamptz;
create index projects_alive_idx on public.projects (org_id) where deleted_at is null;
-- restrictive 정책으로 예외 없이 숨긴다
create policy "삭제된 항목 숨김"
on public.projects as restrictive for select to authenticated
using ( deleted_at is null );
-- 정기 정리 배치를 반드시 만든다
select cron.schedule('purge-deleted', '0 4 * * 0',
$$ delete from public.projects where deleted_at < now() - interval '30 days' $$);

as restrictive라 다른 어떤 정책과도 AND로 결합된다 → 새 정책을 추가해도 누락되지 않는다. 관리자가 봐야 한다면 별도 뷰나 security definer 함수로 제공한다.

create table public.notifications (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id) on delete cascade,
type text not null,
payload jsonb not null default '{}',
read_at timestamptz,
created_at timestamptz not null default now()
);
create index notifications_unread_idx
on public.notifications (user_id, created_at desc) where read_at is null;
alter table public.notifications enable row level security;
create policy "본인 알림만" on public.notifications for all to authenticated
using ( (select auth.uid()) = user_id ) with check ( (select auth.uid()) = user_id );
alter publication supabase_realtime add table public.notifications;

구독과 저장을 동시에. 접속 중이면 실시간으로 받고, 접속하지 않았으면 다음에 조회한다. 읽지 않은 알림만 색인하는 부분 인덱스가 배지 카운트 쿼리를 빠르게 만든다.

  1. RLS 미적용 테이블 — 인터넷에 공개된 것과 같다
  2. secret key를 클라이언트에 — 프로젝트 전체가 열린다
  3. user_metadata로 권한 판단 — 사용자가 직접 수정 가능하다
  4. 서버에서 getSession()의 user를 신뢰 — 쿠키는 위조 가능하다
  5. security definer 함수에 search_path 미설정 — 스키마 하이재킹
  6. 뷰로 RLS 우회 — security_invoker = on 누락
  7. verify_jwt = false 함수에 자체 검증 없음 — 공개 엔드포인트다
  8. 소유자 컬럼 위조 — default auth.uid()는 편의일 뿐이다. with check로 강제한다
  9. update 정책의 with check가 느슨함 — 소유권 이전(작성자 변경)이 가능해진다
  • 멀티테넌트는 모든 테이블에 org_id + 헬퍼 함수 기반 정책
  • 정책이 길어지면 security definer 헬퍼로 추출한다 — 재사용되고 빨라진다
  • 웹훅은 멱등하게. 유니크 제약을 멱등성 장치로 쓸 수 있다
  • Storage 정책과 DB 정책이 같은 헬퍼를 공유하면 규칙이 어긋나지 않는다
  • RAG에서 RLS가 그대로 걸리는 것이 Supabase의 큰 이점이다
  • 안티패턴 목록은 코드 리뷰 체크리스트로 쓴다