콘텐츠로 이동

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;
$$;
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장).

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

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;

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

  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()를 쓴다
  9. update 정책의 with check가 느슨함 — 소유권 이전(작성자 변경)이 가능해진다
  • 멀티테넌트는 모든 테이블에 org_id + 헬퍼 함수 기반 정책
  • 정책이 길어지면 security definer 헬퍼로 추출한다 — 재사용되고 빨라진다
  • 웹훅은 멱등하게. 유니크 제약을 멱등성 장치로 쓸 수 있다
  • Storage 정책과 DB 정책이 같은 헬퍼를 공유하면 규칙이 어긋나지 않는다
  • RAG에서 RLS가 그대로 걸리는 것이 Supabase의 큰 이점이다
  • 안티패턴 목록은 코드 리뷰 체크리스트로 쓴다