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;$$;정책 적용
섹션 제목: “정책 적용”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장).
결제 연동
섹션 제목: “결제 연동”sequenceDiagram
participant B as 브라우저
participant V as Vercel Route Handler
participant ST as Stripe
participant EF as Supabase Edge Function
participant DB as Postgres
B->>V: 결제 시작
V->>V: getClaims() 로 사용자 확인
V->>ST: Checkout Session 생성 (secret key)
ST-->>V: 결제 URL
V-->>B: 리다이렉트
B->>ST: 결제 완료
ST->>EF: 웹훅 (checkout.session.completed)
EF->>EF: 서명 검증 + 멱등 확인
EF->>DB: subscriptions 업데이트 (secret key)
왜 결제 시작은 Vercel, 웹훅은 Supabase인가? 결제 시작은 사용자 세션과 프론트 흐름에 붙어 있다. 웹훅은 프론트 배포와 무관하게 항상 살아 있어야 하고, 목적이 DB 반영이다.
// supabase/functions/stripe-webhook/index.ts (verify_jwt = false)Deno.serve(async (req) => { 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 }) }
// 멱등성: 이미 처리한 이벤트면 조용히 200 const { error: dup } = await admin .from('processed_webhook_events') .insert({ id: event.id, type: event.type }) if (dup?.code === '23505') return new Response('ok (duplicate)', { status: 200 })
// ... 실제 처리 ... 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)”flowchart LR
subgraph IDX["색인 — 비동기"]
direction TB
D["문서 업로드"] --> T["Edge Function<br/>청크 분할"]
T --> E1["임베딩 생성<br/>LLM API"]
E1 --> V1[("documents<br/>+ embedding")]
end
subgraph QRY["질의 — 동기"]
direction TB
Q["사용자 질문"] --> E2["임베딩 생성"]
E2 --> M["match_documents RPC<br/>RLS 적용"]
M --> L["LLM 에 컨텍스트 전달"]
L --> A["답변 스트리밍"]
end
M --> V1
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class V1,M key
class D,T,E1,Q,E2,L,A mute
- 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()를 쓴다 update정책의with check가 느슨함 — 소유권 이전(작성자 변경)이 가능해진다
- 인덱스 없는 외래 키 / RLS 비교 컬럼 — 가장 흔한 성능 문제
auth.uid()를(select ...)로 안 감쌈 — 행마다 재평가된다- 루프 안 쿼리 (N+1) — 중첩 select 또는
.in()으로 select('*')남용 — 대역폭 비용- 깊은 오프셋 페이지네이션 — 커서로 전환
- 대시보드에서 프로덕션 스키마 직접 수정 — 환경이 갈라진다
- 여러 supabase-js 호출로 원자성 기대 — RPC로 묶는다
- Prisma 직접 연결에 RLS 기대 — 적용되지 않는다
- 서버리스에서 direct connection(
5432) — 커넥션 고갈 - 함수 리전과 DB 리전 불일치 — 모든 쿼리에 지연 세금
- Realtime 채널 정리 누락 — 구독 누적
- 사용자별 데이터를 페이지 캐시에 — 데이터 유출
16장 요약
섹션 제목: “16장 요약”- 멀티테넌트는 모든 테이블에
org_id+ 헬퍼 함수 기반 정책 - 정책이 길어지면
security definer헬퍼로 추출한다 — 재사용되고 빨라진다 - 웹훅은 멱등하게. 유니크 제약을 멱등성 장치로 쓸 수 있다
- Storage 정책과 DB 정책이 같은 헬퍼를 공유하면 규칙이 어긋나지 않는다
- RAG에서 RLS가 그대로 걸리는 것이 Supabase의 큰 이점이다
- 안티패턴 목록은 코드 리뷰 체크리스트로 쓴다