Supabase Database 와 RLS — 한 줄 정책으로 권한을 잠근다
Postgres 위 행 단위 보안(RLS)으로 권한을 데이터 옆에 둔다. 스키마 설계부터 자주 쓰는 정책 패턴, 디버깅 팁까지 정리합니다.
들어가며
1편에서 한 가지 단서를 남겼다. Supabase 는 테이블을 만든 그 순간부터 REST·GraphQL 엔드포인트를 자동으로 노출한다 — 그 말은 곧 권한 설계를 하지 않으면 그 엔드포인트는 누구나 쓸 수 있다 는 뜻이다.
전형적인 백엔드라면 권한은 보통 컨트롤러나 서비스 레이어에 둔다. Supabase 의 모델은 다르다. 권한을 데이터 옆에 둔다 — Postgres 의 표준 기능인 RLS (Row Level Security) 를 활용해, "이 행을 누가 읽고 쓸 수 있는가" 를 테이블 정책 으로 선언한다.
먼저 한 벌 스키마를 만들자
권한을 이야기하려면 데이터가 있어야 하니, 작은 블로그 스키마를 한 벌 만들어 두자.
-- 사용자 프로필 (auth.users 와 1:1 으로 매핑)create table profiles ( id uuid primary key references auth.users(id) on delete cascade, username text unique not null, bio text, created_at timestamptz not null default now());-- 글create table posts ( id bigserial primary key, author_id uuid not null references profiles(id) on delete cascade, title text not null, content text, published boolean not null default false, created_at timestamptz not null default now());-- 댓글create table comments ( id bigserial primary key, post_id bigint not null references posts(id) on delete cascade, author_id uuid not null references profiles(id) on delete cascade, body text not null, created_at timestamptz not null default now());핵심 한 가지: auth.users 는 Supabase Auth 가 관리하는 내장 테이블 이다. 직접 INSERT 하지 않고, 인증을 통해서만 행이 생성된다. 우리 도메인 테이블(profiles)은 그 id 를 외래키로 따라간다.
RLS 가 무엇이고, 왜 default off 가 위험한가
RLS 는 Postgres 의 기능이다 — Supabase 의 독자 기능이 아니다. 테이블에 RLS 를 켜면, 그 테이블에 대한 모든 SELECT/INSERT/UPDATE/DELETE 가 정책(policy) 함수를 통과해야 한다.
Supabase 가 새 테이블을 만들 때 기본값은 RLS off 이다. 즉, 방금 만든 posts 테이블은 익명 키로도 누구나 읽고 쓸 수 있는 상태 다.
세 테이블 모두에 RLS 를 켜자.
alter table profiles enable row level security;alter table posts enable row level security;alter table comments enable row level security;여기서 끝이 아니다. RLS 만 켜고 정책을 안 만들면 어떤 행도 보이지 않는 상태가 된다 (정책이 없으니 모두 거부). 켰다면 정책도 같이 정의해야 한다.
첫 정책 — 공개 글은 누구나, 수정은 본인만
가장 흔한 패턴. posts 에 적용해 보자.
-- 1) 발행된 글은 누구나 읽을 수 있다create policy "published posts are readable by anyone"on posts for selectusing ( published = true );-- 2) 본인이 쓴 글은 발행 여부와 무관하게 본인이 읽을 수 있다create policy "users can read their own posts"on posts for selectusing ( auth.uid() = author_id );-- 3) 글 작성은 로그인한 사용자만, 본인 author_id 로만create policy "authenticated users can insert their own posts"on posts for insertwith check ( auth.uid() = author_id );-- 4) 글 수정·삭제는 본인 글만create policy "users can update their own posts"on posts for updateusing ( auth.uid() = author_id );create policy "users can delete their own posts"on posts for deleteusing ( auth.uid() = author_id );읽어보면 자연스럽다. 정책은 어떤 행을 통과시킬지 를 SQL 식으로 선언하는 것뿐이다.
using 과 with check 는 헷갈리는 지점이라 한 번 정리:
using— 기존 행 에 대한 가시성·접근 가능성. SELECT/UPDATE/DELETE 시 평가.with check— 새 행/변경 후 행 에 대한 검증. INSERT/UPDATE 시 평가.
auth.uid() · auth.jwt() — Supabase 가 주입하는 컨텍스트
정책에서 auth.uid() 를 그냥 호출하면 현재 요청자의 사용자 ID 가 나온다. 어떻게?
Supabase 는 클라이언트가 보낸 Authorization: Bearer <jwt> 헤더를 PostgREST 가 받아 — 그 JWT 의 payload 를 Postgres 의 request.jwt.claims 세션 변수에 채워준다. auth.uid() 는 거기서 sub 를 뽑는 헬퍼 함수다.
-- 거칠게 풀어보면 이렇게 동작한다create or replace function auth.uid() returns uuid as $$ select coalesce( nullif(current_setting('request.jwt.claims', true), '')::json ->> 'sub', '' )::uuid;$$ language sql stable;JWT 안의 다른 클레임(role, email, custom claim) 도 auth.jwt() 로 꺼낼 수 있다. 권한 모델을 확장할 때 자주 쓰인다.
-- 운영자 클레임이 붙은 사용자만 발행 여부와 상관없이 모두 읽기create policy "admins can read all posts"on posts for selectusing ( (auth.jwt() ->> 'user_role') = 'admin' );자주 쓰는 패턴
처음 한두 테이블이 익숙해지면, 다음 패턴들이 거의 모든 경우를 덮는다.
1. 공개 읽기 + 소유자 쓰기
가장 흔하다. 위에서 본 posts 가 정확히 이 형태.
2. 조직(팀) 단위 가시성
다중 테넌트(multi-tenant) 앱의 표준. memberships(user_id, org_id) 같은 매핑 테이블을 두고, 조회 정책을 그 매핑으로 join.
-- 조직에 속한 멤버만 그 조직의 문서를 읽을 수 있다create policy "org members can read documents"on documents for selectusing ( exists ( select 1 from memberships where memberships.user_id = auth.uid() and memberships.org_id = documents.org_id ));exists 서브쿼리는 인덱스만 잘 잡혀 있으면 RLS 의 기본 도구 가 된다.
3. 역할 기반 (admin / editor / viewer)
JWT 클레임에 역할이 들어 있다면 auth.jwt() ->> 'user_role' 로 분기. 역할 자체를 DB 에 두고 싶다면 role 컬럼을 profiles 에 두고 함수로 감싸는 패턴이 흔하다.
create or replace function is_admin() returns boolean as $$ select exists ( select 1 from profiles where id = auth.uid() and role = 'admin' );$$ language sql stable security definer;create policy "admins bypass restrictions"on posts for all using ( is_admin() );4. 공개·비공개 토글
posts.published 처럼 한 컬럼이 가시성을 결정. 위 첫 정책 에서 본 그대로.
Service Role 은 RLS 를 우회 — 절대 클라이언트에 두지 말 것
Supabase 가 발급하는 두 종류의 키:
anon public key— 클라이언트(브라우저·앱)에서 안전하게 노출 가능. RLS 적용.service role key— 서버 전용 키. RLS 를 우회한다. 즉, 모든 행을 보고 모든 행을 쓸 수 있다.
Cron 작업·관리자 백오피스·웹훅 같은 시스템 동작 이 필요할 때만 service role 을 써야 하는 자리다.
정책 디버깅 팁
RLS 는 강력하지만 디버깅이 까다롭다. 정책을 통과 못한 행은 그냥 안 보일 뿐 에러를 안 던지기 때문에, "왜 빈 결과가 나오지?" 가 흔한 시나리오다.
체크 순서:
- 테이블에 RLS 가 켜져 있는가 — 대시보드의 빨간 배지로 확인.
- 그 테이블에 정책이 하나라도 있는가 — 없으면 모두 거부 상태.
- 여러 정책이 있다면 OR 결합이라는 점을 기억 — 한 정책만 통과해도 행이 보인다. 정책 의도를 좁게 적었는데 다른 정책이 넓다면 거기서 통과될 수 있다.
auth.uid()가 실제로 무엇인가 — 익명 호출이면null이라 비교가 다 거짓. 로그인 토큰을 SDK 에 제대로 주입했는지 확인.- SQL Editor 에서 직접 시뮬레이션 —
set role authenticated; set request.jwt.claims to '{"sub": "..."}';후 쿼리.
-- 사용자 X 의 시점에서 posts 가 어떻게 보이는지 확인begin;set local role authenticated;set local request.jwt.claims to '{"sub": "<user-uuid>"}';select id, title, published from posts;rollback;정리
RLS 는 처음 보면 어색하지만, 익숙해지면 권한을 데이터 옆에 두는 모델 이 코드베이스의 진실의 위치 를 줄여준다. 비즈니스 로직 어디에서도 권한 체크가 누락될 수 없게 되는 셈.
기억할 것 셋:
- 테이블을 만들면 바로 RLS 를 켠다 — 그리고 정책을 같이 정의한다.
using/with check의미를 분리해서 본다.service role key는 클라이언트에 절대 두지 않는다.