슈퍼베이스(Supabase) RLS(Row Level Security) 정책을 심층 정리한다. ENABLE 후 정책 부재, USING·WITH CHECK, auth.uid()와 JWT 클레임, 소유자·조직 멤버 패턴, service_role 우회, 테스트 순서, 인덱스·성능 함정을 SQL·표로 고정. 백엔드·PostgreSQL·보안·Next.js·API·개발자가 바로 쓰는 FAQ와 공식 문서 출처.
슈퍼베이스(Supabase)에서 테이블에 RLS(Row Level Security)를 켜지 않으면, 공개 anon 키만으로도 해당 테이블 행을 읽을 수 있는 상태가 된다. 대시보드 기본 권고가 “RLS enable”인 이유가 여기 있다. 정책 한 줄이 빠지면 앱 UI 권한과 무관하게 DB가 먼저 열린다.
설치 소개보다 정책 설계, auth.uid()와 JWT 클레임, 소유자·팀 공유 패턴, service_role 바이패스, 테스트·성능을 깊게 파 본다. 포스그레스(PostgreSQL) RLS 위에 슈퍼베이스 Auth가 얹힌 구조를 기준으로, 클라이언트 SDK와 서버 라우트가 각각 어떤 키를 써야 하는지도 표로 고정한다. 백엔드·프론트·보안을 한 사람이 맡는 팀 기준으로 쓴다.
RLS를 켜면 쿼리가 어떻게 바뀌나
RLS가 활성화된 테이블에서는 테이블 권한(GRANT)이 있어도 행 단위 정책이 없으면 SELECT·INSERT·UPDATE·DELETE가 빈 결과 또는 거절로 끝난다. 포스그레스가 쿼리 실행 전에 각 행에 대해 USING / WITH CHECK 식을 평가한다. 앱에서 “로그인 사용자만 목록 보이기”를 프론트에서 가려도, 네트워크 탭으로 API를 직접 때리면 그대로 노출된다. 그래서 권한의 최종 방어선은 UI가 아니라 DB 정책이다.
슈퍼베이스 클라이언트는 보통 anon 키 + 사용자 JWT를 붙인다. 요청이 포스트그레스로 들어가면 세션의 auth.uid()가 정책 식에 쓰인다. 반대로 서버에서 service_role 키를 쓰면 RLS를 우회한다. 이 차이는 아래 표로 먼저 고정해 둔다.
정책은 명령(command)별로 나뉜다. SELECT·UPDATE·DELETE는 대상 행을 거르는 USING, INSERT·UPDATE의 새 행 값은 WITH CHECK가 담당한다. “읽을 수 있는 행”과 “쓸 수 있는 값”이 다르면 버그가 난다. 예를 들어 본인 글만 읽게 해 두고, INSERT 때 user_id를 아무 값이나 넣게 두면 다른 사람 명의로 글을 심을 수 있다.
소유자 전용 notes 테이블 예시 (SQL)
-- RLS 켜기 (테이블마다 필수)
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
-- 본인 행만 읽기
CREATE POLICY notes_select_own
ON public.notes
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- 본인 명의로만 삽입
CREATE POLICY notes_insert_own
ON public.notes
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- 본인 행만 수정 (대상 행 + 변경 후 값 모두 본인)
CREATE POLICY notes_update_own
ON public.notes
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- 본인 행만 삭제
CREATE POLICY notes_delete_own
ON public.notes
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
TO authenticated는 슈퍼베이스가 만든 역할이다. 비로그인 공개 읽기가 필요하면 TO anon용 SELECT 정책을 따로 둔다. 정책을 하나도 안 만들고 RLS만 켜면 인증 사용자도 0건을 본다. “켜기만 하고 정책 추가를 잊은” 장애가 의외로 흔하다.
RLS는 테이블 GRANT 위에 얹히는 행 단위 필터다. 정책이 없으면 인증 사용자도 행을 못 본다.
auth.uid()와 JWT 클레임을 정책에 넣는 법
auth.uid()는 현재 JWT의 사용자 UUID를 돌려준다. 슈퍼베이스 Auth로 로그인하면 클라이언트가 요청마다 이 토큰을 붙이고, 포스그레스 세션에 claim이 실린다. 정책 식에서 가장 많이 쓰는 한 줄이 auth.uid() = user_id다.
팀·역할·조직 단위 권한은 컬럼 하나로는 부족하다. 흔한 패턴은 (1) memberships(user_id, org_id, role) 테이블을 두고 서브쿼리로 확인하거나, (2) JWT 커스텀 클레임에 org_id를 넣고 auth.jwt() ->> 'org_id'로 읽는다. 클레임 방식은 조회가 가볍지만 역할 변경 시 토큰 재발급이 필요하다. 멤버십 테이블 방식은 즉시 반영되지만 정책이 무거워질 수 있다.
조직 멤버만 프로젝트 읽기 (membership 서브쿼리)
CREATE POLICY projects_select_member
ON public.projects
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.memberships m
WHERE m.org_id = projects.org_id
AND m.user_id = auth.uid()
)
);
서브쿼리 정책은 memberships.org_id + user_id 복합 인덱스가 없으면 목록이 커질 때 느려진다. “권한 때문에 느리다”고 느끼면 먼저 EXPLAIN으로 정책 쪽 시퀀셜 스캔이 있는지 본다. 포스그레스 튜닝 기본기는 포스그레스 성능 튜닝 가이드와 같이 보면 된다.
주의: 정책 안에서 다른 테이블을 읽을 때 그 테이블에도 RLS가 켜져 있으면, 그 정책까지 연쇄로 평가된다. 헬퍼 테이블을 SECURITY DEFINER 함수로 감싸 우회하는 패턴도 문서에 나오지만, 함수 권한·search_path를 잘못 두면 더 큰 구멍이 난다. 가능하면 단순 비교·EXISTS + 인덱스로 끝내는 쪽이 운영 부담이 적다.
소유자 전용·공유·공개 읽기 패턴
실무에서 반복되는 패턴은 네 가지 정도다. 이름을 붙여 두면 팀 리뷰가 빨라진다.
패턴
조건 요약
적합 예시
소유자 전용
auth.uid() = user_id
개인 메모, 설정, 결제 수단 메타
조직 멤버
memberships EXISTS
팀 프로젝트, 공유 문서
역할 분리
role in ('admin','editor')
수정은 편집자, 삭제는 관리자
공개 읽기 + 소유자 쓰기
SELECT: true / 쓰기는 소유자
블로그 초안 아닌 게시글, 공개 프로필
공개 읽기 정책을 USING (true)로 두면 말 그대로 전 행이 열린다. 초안·비공개 플래그가 있으면 published = true를 같이 걸어야 한다. “목록 API만 공개하고 상세는 막기”는 프론트 라우팅만으로는 안 된다. 상세 테이블·컬럼에도 같은 조건이 있어야 한다.
UPDATE 정책에서 user_id를 다른 사용자로 바꾸는 것을 WITH CHECK로 막지 않으면 소유권 탈취가 된다. 소유자 전용 업데이트는 USING과 WITH CHECK 둘 다 본인 조건을 넣는 습관을 들이는 편이 안전하다.
자주 나는 구멍: Storage 버킷 정책과 테이블 정책을 따로 두고, 파일 경로에 user_id를 넣지 않은 채 “인증되면 전체 업로드 허용”으로 열어 두는 경우. DB 행은 막혀 있어도 오브젝트 URL이 추측 가능하면 파일이 샌다. 스토리지 정책도 테이블과 같은 소유자 규칙을 맞출 것.
service_role 우회를 언제·어떻게 쓸까
service_role 키는 RLS를 통과하지 않는다. 관리자 일괄 수정, 웹훅 수신 후 시스템 행 생성, 크론 집계처럼 “특정 사용자 세션이 아닌 작업”에 쓴다. 브라우저·모바일 앱·공개 레포에 넣으면 그 순간 전 테이블이 열린 것과 같다.
서버 전용 클라이언트는 환경변수에서만 읽고, 라우트 핸들러·백그라운드 잡에 한정한다. 사용자 요청을 대행할 때도 가능하면 사용자 JWT를 서버에 전달해 RLS가 적용된 클라이언트를 쓰는 쪽이 감사(audit)에 유리하다. 정말 우회가 필요할 때만 service_role을 쓰고, 그 경로의 입력 검증·로깅을 더 빡세게 건다.
넥스트 앱에서 서버 액션이 DB를 직접 치면 클라이언트가 보낸 user_id를 믿지 말고, 세션에서 꺼낸 id와 비교하거나 RLS 적용 클라이언트로 넣는다. 프론트에서 서버 상태를 다룰 때는 탠스택 쿼리 v5 심층 분석처럼 캐시 키에 사용자 범위를 넣어도, 최종 거절은 여전히 RLS가 한다.
두 명의 테스트 유저를 만들고, A의 행을 B 세션에서 SELECT 했을 때 0건인지 확인
A로 INSERT 할 때 다른 사람 user_id를 넣으면 거절되는지 확인 (WITH CHECK)
UPDATE로 소유자 컬럼을 바꾸려 할 때 거절되는지 확인
anon 키만으로 비공개 테이블이 0건인지 확인
service_role로는 관리 작업이 되는지 확인 (우회가 의도된 경로인지)
SQL 에디터에서 역할을 흉내 내려면 SET request.jwt.claim.sub = '...' 같은 슈퍼베이스 문서의 테스트 패턴을 쓰거나, 실제 클라이언트 두 개로 통합 테스트를 돌린다. 앱 로그만 보면 “빈 배열”과 “권한 오류”가 구분되지 않는 경우가 많다. 포스그레스는 RLS 때문에 걸러진 행을 조용히 빼기도 하므로, 정책 이름을 짧게 나누고 어떤 정책이 매치됐는지 대시보드·로그로 추적할 수 있게 둔다.
정책 목록 확인 (SQL)
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;
같은 테이블에 SELECT 정책이 여러 개면 기본(permissive) 정책은 OR로 합쳐진다. “이 정책으로 막았는데 다른 정책이 열어 준” 케이스가 나온다. 제한을 강화할 때는 기존 넓은 정책을 먼저 찾고, 필요하면 restrictive 정책을 검토한다. 문서 기준으로 restrictive는 AND 조건으로 더 좁힌다.
성능 함정: 정책 과다·함수·인덱스
RLS 자체는 “보안 기능”이지만, 식 안에 무거운 함수·다중 조인이 들어가면 목록 API 지연으로 이어진다. 체크 포인트는 네 가지다.
필터 컬럼 인덱스: user_id, org_id, membership 복합키
STABLE/IMMUTABLE 함수: 정책에서 호출하는 헬퍼가 매 행 비싸지 않은지
정책 개수: 명령별로 의도가 다른 정책을 과도하게 쪼개 OR 지옥이 되지 않는지
select *: 클라이언트에 불필요 컬럼을 주지 않기 (권한과 별개로 노출 면적)
대시보드에서 “RLS 켜짐” 배지만 보고 끝내면 안 된다. 스테이징에서 실제 목록 쿼리 EXPLAIN을 한 번 저장해 두고, 멤버십 테이블이 커진 뒤 같은 계획을 비교하는 습관이 있다. 연결 풀이 좁은 환경에서는 느린 정책이 풀 고갈을 앞당긴다. 풀 쪽은 앞서 링크한 PgBouncer 사례와 같이 본다.
정책 식의 컬럼에 인덱스가 없으면 권한 검사가 곧 풀 스캔이 된다.
앱에 붙일 때 체크 순서
새 테이블을 올릴 때 아래 순서를 고정하면 “나중에 정책 달기” 부채가 줄어든다.
테이블 생성 + user_id/org_id 등 권한 키 컬럼 확정
ENABLE ROW LEVEL SECURITY
SELECT·INSERT·UPDATE·DELETE 정책 4종(또는 필요한 명령만) 추가
인덱스 추가
anon / authenticated 두 세션으로 스모크 테스트
서버 전용 경로만 service_role
스토리지·리얼타임이 있으면 동일 소유 규칙 복제
마이그레이션 파일에 정책 SQL을 같이 커밋한다. 스키마만 올리고 정책은 대시보드 클릭으로만 두면 환경마다 권한이 어긋난다. CI에서 pg_policies 스냅샷을 비교하는 팀도 있다.
클라이언트에서 에러 메시지를 사용자에게 그대로 보여 주기보다, “권한 없음 / 찾을 수 없음”을 구분해 UX를 정한다. RLS로 0건이 된 경우와 네트워크 실패를 같은 토스트로 처리하면 디버깅이 어려워진다.
실무 팁: 프로덕션 전에 “RLS 미적용 테이블” 목록을 슈퍼베이스 어드바이저·SQL로 한 번 뽑는다. 새 테이블 추가 직후 깜빡한 경우가 대부분이다. 공개 마케팅 페이지만 있는 프로젝트가 아니라면 기본값은 enable + 최소 정책이다.