16. 실전 패턴과 안티패턴
정책이 길어지면 헬퍼 함수로 추출한다. 재사용되고, 빨라지고, 어긋나지 않는다
멀티테넌트 SaaS
섹션 제목: “멀티테넌트 SaaS”스키마
섹션 제목: “스키마”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);RLS 헬퍼 함수
섹션 제목: “RLS 헬퍼 함수”정책이 참조하는 org_members에도 RLS가 걸리므로, 헬퍼로 한 번 감싼다.
create schema if not exists private;
-- 내가 속한 조직 목록create or replace function private.my_orgs()returns setof uuidlanguage 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 textlanguage 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));-
admin이 초대 생성 → 트리거가 Edge Function 호출 → 초대 메일 발송
-
수신자가 링크 클릭 → 로그인 또는 가입
-
가입 후 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, 웹훅은 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 booleanlanguage plpgsqlsecurity 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 정책이 같은 헬퍼 함수를 공유하면 규칙이 어긋나지 않는다. 멤버십 로직을 두 벌 유지하는 순간, 언젠가 한쪽만 고치게 된다.
AI 챗봇 (RAG)
섹션 제목: “AI 챗봇 (RAG)”RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM(Large Language Model, 대규모 언어 모델)이 모르는 내 데이터로 답하게 하는 표준 패턴이다. 모델을 다시 학습시키는 대신, 질문과 관련된 문서를 검색해서 프롬프트에 함께 넣어 준다. 그래서 파이프라인이 둘로 나뉜다 — 문서를 미리 벡터로 색인해 두는 쪽과, 질문이 올 때 유사 문서를 찾아 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;구독과 저장을 동시에. 접속 중이면 실시간으로 받고, 접속하지 않았으면 다음에 조회한다. 읽지 않은 알림만 색인하는 부분 인덱스가 배지 카운트 쿼리를 빠르게 만든다.
안티패턴 총정리
섹션 제목: “안티패턴 총정리”- RLS 미적용 테이블 — 인터넷에 공개된 것과 같다
- secret key를 클라이언트에 — 프로젝트 전체가 열린다
user_metadata로 권한 판단 — 사용자가 직접 수정 가능하다- 서버에서
getSession()의 user를 신뢰 — 쿠키는 위조 가능하다 security definer함수에search_path미설정 — 스키마 하이재킹- 뷰로 RLS 우회 —
security_invoker = on누락 verify_jwt = false함수에 자체 검증 없음 — 공개 엔드포인트다- 소유자 컬럼 위조 —
default auth.uid()는 편의일 뿐이다.with check로 강제한다 update정책의with check가 느슨함 — 소유권 이전(작성자 변경)이 가능해진다
- 인덱스 없는 외래 키 / RLS 비교 컬럼 — 가장 흔한 성능 문제
auth.uid()를(select ...)로 안 감쌈 — 행마다 재평가된다- 루프 안 쿼리 (N+1) — 중첩 select 또는
.in()으로 select('*')남용 — 대역폭 비용- 깊은 오프셋 페이지네이션 — 커서로 전환
- 대시보드에서 프로덕션 스키마 직접 수정 — 환경이 갈라진다
- 여러 supabase-js 호출로 원자성 기대 — RPC로 묶는다
- Prisma 직접 연결에 RLS 기대 — 적용되지 않는다
- 서버리스에서 Direct host(
db.<ref>.supabase.co:5432) 연결 — 커넥션 고갈 - 함수 리전과 DB 리전 불일치 — 모든 쿼리에 지연 세금
- Realtime 채널 정리 누락 — 구독 누적
- 사용자별 데이터를 페이지 캐시에 — 데이터 유출
16장 요약
섹션 제목: “16장 요약”- 멀티테넌트는 모든 테이블에
org_id+ 헬퍼 함수 기반 정책 - 정책이 길어지면
security definer헬퍼로 추출한다 — 재사용되고 빨라진다 - 웹훅은 멱등하게. 유니크 제약을 멱등성 장치로 쓸 수 있다
- Storage 정책과 DB 정책이 같은 헬퍼를 공유하면 규칙이 어긋나지 않는다
- RAG에서 RLS가 그대로 걸리는 것이 Supabase의 큰 이점이다
- 안티패턴 목록은 코드 리뷰 체크리스트로 쓴다
참고 자료
섹션 제목: “참고 자료”- RLS용
security definer함수 — 비공개 스키마와 권한 최소화 - Edge Function 웹훅 인증 —
auth: 'none'과 제공자 서명 검증 - Stripe 웹훅 처리 권장 사항 — 중복 이벤트와 비동기 재시도