Supabase RLS 실수 5가지 — 직접 뚫어보고 막았다
Supabase RLS에서 자주 나오는 실수 5가지를 직접 재현하고 데이터가 새는지 확인했다. auth.uid() 누락, policy 순서 문제, anon 권한 방치까지 — 실제로 뚫린 패턴과 막은 방법을 코드와 함께 정리했다.
On this page (13)
2026년 4월 · GoCodeLab
Supabase RLS 자주 실수하는 5가지 패턴
— 직접 뚫어보고 막은 법
Supabase 프로젝트에서 RLS를 켰는데 데이터가 샜다. anon API 키 하나로 다른 유저의 notes 테이블 전체가 읽혔다. 오류 메시지도 없었다. 설정을 반만 한 게 문제였다. 어디가 빠졌는지 찾는 데 두 시간이 걸렸다.
RLS(Row Level Security)는 PostgreSQL의 행 단위 보안 기능이다. 쉽게 말하면 테이블의 각 행에 자물쇠를 다는 것과 같다. Supabase가 기본으로 제공하지만, 설정이 반만 되면 조용히 뚫린다. 이 글은 직접 공격 시나리오를 재현하면서 찾아낸 5가지 실수 패턴이다.
다섯 가지만 막으면 대부분의 데이터 유출은 막을 수 있다. 코드 한 줄, 정책 하나 차이다. 각 패턴이 어떻게 뚫리는지 재현하고, 막는 방법까지 같이 정리했다.
- 실수 1 — RLS 자체를 안 켰다 → 테이블 전체 공개. ALTER TABLE ... ENABLE ROW LEVEL SECURITY 필수
- 실수 2 — RLS만 켜고 정책 없음 → 0건 반환 (조용히 실패). 정책 최소 1개 추가 필수
- 실수 3 — auth.uid() 직접 호출 → 행마다 함수 재실행, 성능 저하. (SELECT auth.uid()) 패턴으로 교체
- 실수 4 — UPDATE WITH CHECK 누락 → user_id 변조 가능. USING + WITH CHECK 반드시 함께
- 실수 5 — INSERT 역할 미지정 → anon도 쓸 수 있다. TO authenticated 명시 필수
- 보너스 — service_role 키 클라이언트 노출 → RLS 완전 우회. 서버 전용 환경변수로 격리
RLS란 무엇인가 — 왜 실수가 생기는가
RLS는 PostgreSQL의 행 단위 접근 제어 기능이다. GRANT로 테이블 전체의 읽기·쓰기를 제어하는 것과 달리, RLS는 각 행에 조건을 걸어 특정 행만 볼 수 있게 한다. "본인 데이터만 본다"는 규칙이 서버단에서 DB 수준으로 강제된다. 앱 코드가 아무리 허술해도 DB 레이어에서 막히는 구조다.
Supabase Auth는 로그인한 사용자의 UUID를 JWT 토큰에 담아 전달한다. RLS 정책 안에서 auth.uid()를 호출하면 이 UUID를 가져온다. 테이블의 user_id 컬럼과 비교해서 본인 행만 접근이 되도록 제한하는 원리다. 이 연결이 끊기면 어디서든 데이터가 샌다.
Supabase에는 세 가지 역할이 있다. anon은 로그인 없이 접근하는 익명 사용자다. authenticated는 Supabase Auth로 로그인한 사용자다. service_role은 RLS를 우회하는 관리자 키다. 정책에 역할을 명시하지 않으면 세 역할 모두에 적용된다. 이걸 모르면 anon 사용자가 정책의 적용 대상이 되어 예상치 못한 허용이 생긴다.
왜 실수가 자주 생기는가. RLS 설정은 단계가 분리되어 있어서다. 먼저 테이블에 RLS를 활성화하고, 그 다음 정책을 만들고, 정책 안에서 역할과 조건을 각각 지정해야 한다. 하나라도 빠지면 조용히 뚫리거나 조용히 막힌다. 오류 메시지가 없어서 더 찾기 어렵다.
- 앱 코드 버그와 무관하게 DB 레이어에서 접근을 차단한다
- SQL Injection이 성공해도 해당 유저 데이터만 노출된다
- 서버리스·Edge 환경에서 별도 권한 로직 없이 보안을 유지한다
- 정책이 코드가 아니라 DB에 있어서 언어·프레임워크 교체에도 유지된다
- Supabase Dashboard에서 테이블별 정책을 한눈에 관리할 수 있다
- service_role 키가 클라이언트에 노출되면 RLS 전체가 무효다
- 정책 조건이 잘못 작성되면 RLS가 켜져 있어도 데이터가 샐 수 있다
- 복잡한 비즈니스 로직(팀 단위 권한, 계층적 접근 등)은 정책이 길어지고 관리가 어려워진다
- Storage 버킷은 별도로 Storage RLS를 설정해야 한다. DB RLS와 별개다
- 새 테이블을 추가할 때마다 RLS 설정을 빠뜨리지 않는 프로세스가 별도로 필요하다
실수 1 — RLS 자체를 안 켰다
Supabase에서 테이블을 새로 만들면 RLS는 기본으로 꺼져 있다. CREATE TABLE만 하면 anon API 키로 전체 조회가 된다. Dashboard Table Editor에 "RLS disabled" 경고가 뜨지만 무시하는 경우가 많다. 이 상태로 배포하면 누구든 테이블을 읽을 수 있다.
직접 테스트해봤다. anon 키로 curl을 날렸더니 전체 데이터가 그대로 왔다. 인증 없이 content 필드까지 전부 공개된 상태였다. RLS 한 줄이 없어서 생긴 일이다. 개발 중에는 편하다는 이유로 RLS를 끄고 작업하는 경우가 있는데, 배포 전에 반드시 켜야 한다.
RLS가 꺼진 테이블을 찾는 쿼리를 만들어뒀다. pg_tables에서 직접 조회하는 방식이다. 프로젝트가 커지면 테이블이 늘어나고 놓치는 경우가 생긴다. CI/CD 파이프라인에 이 쿼리를 추가해두면 배포 전에 자동으로 잡을 수 있다.
새 테이블 추가 시 마이그레이션 파일에 ENABLE 줄을 함께 넣는 것이 가장 안전하다. CREATE TABLE 다음 줄에 바로 ALTER TABLE ... ENABLE ROW LEVEL SECURITY를 쓰면 빠뜨릴 일이 없다. 습관이 되면 자동으로 따라온다.
CREATE TABLE notes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID,
content TEXT
);
-- 수정: 테이블 생성 직후 반드시 실행
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- RLS가 꺼진 테이블 전체 조회 (결과가 0행이어야 안전)
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;
실수 2 — RLS만 켜고 정책을 빠뜨렸다
RLS를 켰는데 데이터가 하나도 안 보이는 상황이 있다. 오류는 없다. 빈 배열 []만 온다. 처음에는 데이터 버그인지, 보안 설정 문제인지 구분이 안 됐다. 잘못된 쿼리를 한참 들여다보다가 정책이 없다는 걸 뒤늦게 발견했다.
RLS를 켜고 정책을 하나도 안 만들면 PostgreSQL은 기본으로 모든 접근을 차단한다. 이걸 암묵적 거부(implicit deny)라고 한다. 오류 메시지 없이 0건을 반환하기 때문에 버그처럼 보인다. 최소 하나의 허용 정책이 있어야 데이터가 보인다.
정책 타입도 알아둬야 한다. 기본 정책(PERMISSIVE)은 같은 연산에 여러 개일 때 OR로 합산된다. 하나만 통과해도 접근이 허용된다. RESTRICTIVE 정책은 AND로 처리되어 반드시 통과해야 접근이 된다. 대부분의 경우 PERMISSIVE 정책을 쓰고, 추가 제한이 필요할 때만 RESTRICTIVE를 사용한다.
Dashboard Authentication → Policies 탭에서 현재 정책 목록을 확인할 수 있다. 테이블에 정책이 하나도 없으면 노란 경고 아이콘으로 표시된다. 정책을 추가한 후에도 데이터가 안 보이면 SELECT 정책의 USING 조건이 잘못된 경우가 많다. 조건 자체를 먼저 확인한다.
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- 정책 없음 → 전체 차단, 오류 없이 [] 반환
-- 정상: RLS + 정책 함께
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own notes"
ON notes FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- 현재 테이블에 등록된 정책 목록 확인
SELECT policyname, cmd, roles, qual
FROM pg_policies
WHERE tablename = 'notes';
실수 3 — auth.uid()를 직접 호출했다
정책을 만들 때 USING 절에 auth.uid()를 직접 넣는 경우가 많다. 작동은 한다. 하지만 성능 함정이 있다. 이 방식은 테이블의 각 행마다 auth.uid() 함수를 한 번씩 호출한다.
rows가 1만 개라면 auth.uid()가 1만 번 실행된다. (SELECT auth.uid())로 서브쿼리를 감싸면 쿼리당 한 번만 실행된다. Supabase 공식 문서에서도 이 패턴을 권장한다. 테이블이 클수록 차이가 벌어진다.
EXPLAIN ANALYZE로 두 방식을 비교해봤다. 5만 건 테이블에서 직접 호출 방식은 Seq Scan과 함께 함수가 5만 번 실행됐다. (SELECT auth.uid()) 방식은 함수 실행이 1번이고 인덱스 스캔이 가능했다. 쿼리 시간이 4배 이상 차이났다. user_id 컬럼에 인덱스가 있을 때 이 차이가 커진다.
기존 정책을 교체하는 방법은 간단하다. DROP POLICY로 기존 정책을 삭제하고 CREATE POLICY로 새로 만들면 된다. ALTER POLICY로 USING 조건만 수정할 수도 있다. 어떤 방식이든 현재 실행 중인 세션에 즉시 적용된다. 앞으로 새로 작성하는 정책은 (SELECT auth.uid()) 패턴을 기본으로 쓴다.
CREATE POLICY "slow policy"
ON notes FOR SELECT TO authenticated
USING (auth.uid() = user_id);
-- 빠른 방식: 쿼리당 한 번만 호출 (권장)
CREATE POLICY "fast policy"
ON notes FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- 기존 정책 교체
DROP POLICY IF EXISTS "slow policy" ON notes;
CREATE POLICY "fast policy"
ON notes FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- user_id 인덱스 추가 (없으면 반드시 추가)
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
실수 4 — UPDATE WITH CHECK를 빠뜨렸다
UPDATE 정책에는 USING과 WITH CHECK 두 가지가 있다. USING은 수정할 행을 선택할 때 적용된다. WITH CHECK는 수정 후 결과가 조건을 만족하는지 검증한다. WITH CHECK를 빠뜨리면 행 소유권을 다른 유저로 이전할 수 있다.
내 노트의 user_id를 다른 유저 UUID로 UPDATE하는 상황이다. USING만 있으면 수정 전 행은 내 것이니 조건을 통과한다. 수정 후 결과 검증이 없으니 그대로 저장된다. 내 노트가 다른 유저 소유로 바뀐다.
이 공격은 실제로 가능하다. JavaScript 클라이언트에서 .update({ user_id: '다른유저-uuid' })를 보내면 된다. USING만 있는 정책에서는 이 요청이 성공한다. 이후 공격 대상이 자신의 데이터를 SELECT하면 변조된 노트가 포함되어 온다. 데이터 정합성이 깨진다.
UPDATE 정책에는 USING과 WITH CHECK를 항상 같이 써야 한다. 두 조건이 같은 표현이더라도 반드시 둘 다 명시한다. USING만 있으면 수정 전 소유권 확인은 하지만, 수정 후 결과를 보장하지 못한다. 이건 PostgreSQL 문서에도 명시된 동작이다.
CREATE POLICY "update own notes (취약)"
ON notes FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- 정상: USING + WITH CHECK 모두 명시
CREATE POLICY "update own notes"
ON notes FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
-- JavaScript에서 공격 시뮬레이션 (취약한 정책일 때)
// const { error } = await supabase
// .from('notes')
// .update({ user_id: '다른유저-uuid' })
// .eq('id', '내노트-uuid')
// 취약 정책이면 이 요청이 성공한다
실수 5 — INSERT 정책에 역할을 명시하지 않았다
INSERT 정책에 TO 절을 쓰지 않으면 PostgreSQL은 기본으로 모든 역할에 정책을 적용한다. anon 사용자도 포함이다. WITH CHECK 조건이 느슨하면 로그인 없이 데이터를 쓸 수 있게 된다.
특히 WITH CHECK (true)처럼 완전 허용 조건을 쓰면서 역할을 명시하지 않으면 누구나 INSERT가 된다. 스팸 데이터가 쌓이고 테이블이 빠르게 커진다. TO authenticated를 명시하는 것만으로 막을 수 있다.
WITH CHECK (auth.uid() = user_id) 조건도 완전히 안전하지 않을 수 있다. anon 역할에서 auth.uid()는 NULL을 반환한다. NULL = UUID 비교는 FALSE라서 막히는 것처럼 보인다. 하지만 user_id 컬럼에 NOT NULL 제약이 없고 클라이언트가 user_id를 NULL로 보내면 NULL = NULL 비교가 발생한다. DB 버전이나 설정에 따라 통과할 수 있다.
조건의 우연한 통과에 의존하지 않는 것이 원칙이다. TO authenticated를 명시하면 anon 역할의 요청은 정책 평가 자체를 거치지 않고 차단된다. 조건 내용과 무관하게 완전히 막힌다. 역할 명시는 의도를 드러내고 예상치 못한 버그를 방지한다.
CREATE POLICY "insert notes (취약)"
ON notes FOR INSERT
WITH CHECK (true);
-- 불완전: NULL 비교 우연 통과 위험
CREATE POLICY "insert notes (불완전)"
ON notes FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- anon에서 auth.uid()가 NULL → NULL = NULL 비교 위험
-- 정상: TO authenticated + 소유권 검증
CREATE POLICY "insert own notes"
ON notes FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
보너스 — service_role 키를 클라이언트에 넣었다
service_role 키는 RLS를 우회한다. 이 키로 요청하면 모든 행에 접근된다. 브라우저나 모바일 앱에 이 키를 넣으면 RLS 설정 전체가 무의미해진다. DevTools를 열면 누구든 키를 뽑아서 전체 데이터에 접근할 수 있다.
서버 환경 변수로만 관리해야 한다. Next.js 기준으로 NEXT_PUBLIC_ 접두사 없이 서버 사이드 변수로 관리한다. 클라이언트에는 anon 키만 줘야 한다. 이게 지켜지지 않으면 RLS는 장식이 된다. NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY 같은 이름이 코드에 있으면 즉시 수정해야 한다.
키가 이미 노출됐다면 Supabase Dashboard에서 즉시 로테이션해야 한다. Project Settings → API → API Keys에서 새 키를 발급할 수 있다. 기존 키는 발급 즉시 만료된다. 노출된 키는 어떤 이유로도 계속 쓰면 안 된다. GitHub에 올라간 키도 마찬가지다. git history에 남아있으면 즉시 로테이션이다.
Next.js에서 Supabase 클라이언트를 두 개로 분리하는 패턴이 권장된다. 브라우저용(anon 키)과 서버용(service_role 키)을 각각 만들어서 사용한다. createBrowserClient와 createServerClient가 그 역할을 한다. 클라이언트 컴포넌트에서는 service_role 키 자체에 접근할 수 없는 구조가 된다.
- anon 키 → 브라우저, 앱 클라이언트. RLS가 적용된다. 공개해도 괜찮다.
- service_role 키 → 서버 전용 (Edge Functions, API Routes). RLS를 우회한다. 절대 공개하면 안 된다.
- Next.js 환경변수:
NEXT_PUBLIC_SUPABASE_ANON_KEY(공개) /SUPABASE_SERVICE_ROLE_KEY(비공개) - 클라이언트에 service_role 키가 있으면 RLS는 무효다. 즉시 키를 로테이션한다.
실전 시나리오 1 — 멀티유저 노트 앱에서 RLS 전체 설정해봤다
노트 앱에서 전체 CRUD를 RLS로 보호하는 설정을 처음부터 만들었다. 테이블 구조를 먼저 정하고, 연산별로 정책을 순서대로 만들었다. SELECT, INSERT, UPDATE, DELETE 네 가지 전부 필요했다. 한 테이블에 정책 4개가 들어간 구조다.
설정 순서가 중요하다. 테이블 생성 → RLS 활성화 → 정책 순서로 실행한다. 정책 이름에 의도를 명확히 적는 것이 나중에 디버깅할 때 도움이 된다. "notes: select own"처럼 테이블명 + 연산 + 대상을 적으면 6개월 후에 봐도 바로 알 수 있다.
DELETE 정책을 빠뜨리면 다른 유저의 노트를 삭제할 수 있다. DELETE도 USING 조건이 필요하다. 많이 놓치는 부분이다. CRUD를 전부 커버해야 완전한 보호가 된다. SELECT와 DELETE는 USING만, INSERT는 WITH CHECK만, UPDATE는 둘 다 필요하다.
설정 후 세 가지 시나리오로 테스트했다. 내 UUID로 내 노트가 보이는가. 다른 UUID로 내 노트가 안 보이는가. anon 키로 아무것도 안 보이는가. 세 가지 모두 예상대로 동작했다. 이 세 가지 테스트가 통과하면 기본 보안은 된 것이다.
CREATE TABLE notes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "notes: select own"
ON notes FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: insert own"
ON notes FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: update own"
ON notes FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: delete own"
ON notes FOR DELETE TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- RLS 성능에 직접 영향하는 인덱스
CREATE INDEX notes_user_id_idx ON notes(user_id);
RLS 정책에서 user_id를 조건으로 쓰면 PostgreSQL이 해당 컬럼으로 필터링한다. 인덱스가 없으면 매 요청마다 Seq Scan이 발생한다. 테이블이 100건이면 차이가 없지만 10만 건이 넘으면 즉시 체감된다. CREATE INDEX notes_user_id_idx ON notes(user_id)를 정책 설정과 함께 실행한다.
실전 시나리오 2 — 공개 게시물과 비공개 데이터를 한 테이블에 넣었을 때
블로그 글처럼 공개/비공개가 혼재하는 데이터를 한 테이블에 넣는 경우가 있다. is_public 컬럼 하나로 처리하는 구조다. 공개글은 누구든 읽을 수 있고, 비공개글은 작성자만 읽을 수 있어야 한다. PERMISSIVE 정책 두 개가 OR로 합산되는 원리를 활용한다.
PERMISSIVE 정책(기본값)은 같은 연산에 여러 개일 때 OR로 합산된다. 공개글 SELECT 정책과 본인글 SELECT 정책 두 개를 만들면 된다. anon 사용자는 공개글 정책만 통과한다. authenticated 사용자는 공개글 정책 또는 본인글 정책 중 하나를 통과한다. 다른 유저의 비공개글은 두 정책 모두 통과하지 못해서 0건이 반환된다.
INSERT와 UPDATE 정책은 별도로 설정해야 한다. 공개/비공개 읽기 정책과 쓰기 정책이 분리된 구조다. is_public 값을 바꾸는 UPDATE도 본인만 가능해야 한다. WITH CHECK 조건에 user_id만 체크하면 is_public을 포함한 모든 필드 수정이 본인에게만 허용된다.
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_public BOOLEAN DEFAULT false
);
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 정책 1: 공개글은 누구나 조회 (anon 포함)
CREATE POLICY "posts: select public"
ON posts FOR SELECT TO anon, authenticated
USING (is_public = true);
-- 정책 2: 본인 글은 공개/비공개 무관하게 조회
CREATE POLICY "posts: select own"
ON posts FOR SELECT TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- 두 정책은 OR로 합산됨
-- anon: 공개글만 보임
-- 본인: 본인의 공개글 + 비공개글 모두 보임
-- 다른 유저: 해당 유저의 공개글만 보임
CREATE POLICY "posts: insert own"
ON posts FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "posts: update own"
ON posts FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "posts: delete own"
ON posts FOR DELETE TO authenticated
USING ((SELECT auth.uid()) = user_id);
RLS 정책 체크리스트
5가지 실수를 한 표로 정리했다. 새 테이블을 만들 때마다 확인 용도로 쓰면 된다.
| 실수 패턴 | 증상 | 위험도 | 수정 |
|---|---|---|---|
| RLS 비활성화 | 전체 데이터 공개 | 치명적 | ENABLE ROW LEVEL SECURITY |
| 정책 없음 | 0건 반환 (오류 없음) | 높음 | SELECT 정책 최소 1개 추가 |
| auth.uid() 직접 호출 | 느린 쿼리 | 중간 | (SELECT auth.uid()) 패턴으로 교체 |
| UPDATE WITH CHECK 누락 | user_id 변조 가능 | 높음 | WITH CHECK 추가 |
| 역할 미지정 INSERT | anon 쓰기 가능 | 높음 | TO authenticated 명시 |
| service_role 클라이언트 노출 | RLS 완전 우회 | 치명적 | 서버 전용 환경변수로 이동 |
연산별로 필요한 절도 따로 정리했다. INSERT와 UPDATE는 WITH CHECK가 필요하다. SELECT와 DELETE는 USING만 있으면 된다.
| 연산 | USING | WITH CHECK | 역할 (TO) |
|---|---|---|---|
| SELECT | 필요 | 불필요 | authenticated (공개글은 anon도) |
| INSERT | 불필요 | 필요 | authenticated |
| UPDATE | 필요 | 필요 (자주 누락) | authenticated |
| DELETE | 필요 | 불필요 | authenticated |
Supabase의 세 역할이 어떻게 다른지도 한 표에 정리했다. anon, authenticated, service_role이 각각 어디에 쓰이고 어떤 접근 수준을 가지는지 비교한 것이다.
| 역할 | 사용 환경 | RLS 적용 | auth.uid() | 공개 가능 여부 |
|---|---|---|---|---|
| anon | 브라우저, 앱 클라이언트 | 적용 | NULL | 공개 가능 |
| authenticated | 로그인한 사용자 | 적용 | 사용자 UUID | JWT로 인증됨 |
| service_role | 서버 전용 (Edge Functions 등) | 우회 | N/A | 절대 공개 불가 |
기존 프로젝트에 RLS를 뒤늦게 추가할 때는 순서가 중요하다. RLS를 켜기 전에 정책을 먼저 만들거나, 트랜잭션으로 묶어서 같이 실행한다. 정책 없이 RLS만 켜면 잠깐이라도 서비스가 0건 상태가 된다. BEGIN/COMMIT으로 묶어서 처리하면 원자적으로 적용된다.
BEGIN;
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "notes: select own" ON notes
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: insert own" ON notes
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: update own" ON notes
FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "notes: delete own" ON notes
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
COMMIT;
-- RLS 켜짐과 정책 추가가 원자적으로 처리됨
RLS 제대로 검증하는 법
정책을 만든 다음에는 실제로 테스트해봐야 한다. Supabase SQL Editor에서 역할과 JWT claim을 직접 설정하면 된다. anon으로도, 다른 유저 UUID로도 테스트할 수 있다. 이 방법이 실제 API를 타지 않아도 DB 레벨에서 정책 동작을 확인할 수 있는 가장 빠른 방법이다.
두 가지를 반드시 확인한다. 내 UUID로 내 데이터가 보이는가. 다른 UUID로 내 데이터가 안 보이는가. 두 번째가 더 중요하다. 많은 경우 자기 데이터는 잘 보이는데, 타인 데이터 차단이 빠져있는 경우가 있다.
-- 1. 내 UUID로 authenticated 역할 테스트
SET LOCAL role TO 'authenticated';
SET LOCAL "request.jwt.claims" TO '{"sub": "my-user-uuid-here"}';
SELECT * FROM notes;
-- 내 노트만 보여야 정상
-- 2. 다른 UUID로 접근 (0건이어야 정상)
SET LOCAL "request.jwt.claims" TO '{"sub": "other-user-uuid"}';
SELECT * FROM notes;
-- 0건이어야 정상
-- 3. anon 역할 테스트
SET LOCAL role TO 'anon';
SELECT * FROM notes;
-- 0건이어야 정상
curl로 anon 키를 직접 날리는 것도 필수다. SQL Editor가 아니라 실제 API 엔드포인트를 때려야 실제 상황을 알 수 있다. SQL Editor와 결과가 다르면 미들웨어나 Supabase 프록시 레이어 문제일 수 있다.
curl 'https://<project-ref>.supabase.co/rest/v1/notes?select=*' \
-H 'apikey: <anon-key>' \
-H 'Authorization: Bearer <anon-key>'
# 결과가 [] 이어야 정상
# 데이터가 보이면 RLS 설정 오류
JavaScript 클라이언트로도 테스트할 수 있다. supabase.auth.signInWithPassword로 테스트 계정을 로그인하고, 다른 계정의 UUID가 있는 행을 SELECT한다. 결과가 빈 배열이면 정상이다. 이 방법은 실제 앱 코드와 동일한 경로로 테스트하는 장점이 있다.
Table Editor → 테이블 목록에서 RLS enabled/disabled 상태 확인. Authentication → Policies에서 정책 목록 확인. 정책이 없는 테이블에는 노란 경고 표시가 나온다. 각 정책 옆 "Review" 버튼을 누르면 USING/WITH CHECK 조건을 바로 볼 수 있다.
자주 묻는 질문
RLS를 켜면 API 속도가 느려지나?
정책 작성 방식에 따라 다르다. auth.uid()를 직접 쓰면 행마다 함수가 실행돼 느려진다. (SELECT auth.uid())로 바꾸면 쿼리당 한 번만 실행된다. user_id 컬럼에 인덱스가 걸려 있으면 성능 차이는 무시할 수 있는 수준이다. 복잡한 정책은 EXPLAIN ANALYZE로 실행 계획을 확인하는 것이 좋다.
service_role 키는 언제 써도 되나?
서버 사이드에서만 쓴다. Supabase Edge Functions, Next.js API Route, 백엔드 서버가 대상이다. 클라이언트에 노출되지 않는 환경에서만 사용한다. 브라우저나 모바일 앱에는 절대 넣으면 안 된다. 노출됐다면 Supabase Dashboard Project Settings → API에서 즉시 키를 로테이션한다.
정책이 제대로 적용됐는지 확인하는 방법은?
SQL Editor에서 SET LOCAL role TO 'anon'으로 역할을 바꾸고 SELECT를 실행한다. 0건이 오면 정상이다. curl로 anon API 키를 직접 써서 엔드포인트를 때리는 것도 병행한다. Dashboard Authentication → Policies에서 정책 목록도 확인한다. 정책 이름이 보이지 않으면 추가가 안 된 것이다.
anon 역할과 authenticated 역할의 차이는 무엇인가?
anon은 로그인하지 않은 사용자 역할이다. authenticated는 Supabase Auth로 로그인한 사용자 역할이다. anon API 키로 요청하면 anon 역할로 처리된다. JWT 토큰이 있으면 authenticated 역할로 처리된다. 두 역할에 각각 정책을 따로 설정할 수 있어서 공개/비공개 데이터를 세밀하게 제어할 수 있다.
테이블마다 RLS를 전부 설정해야 하나?
그렇다. Supabase에서 새 테이블은 기본으로 RLS가 꺼져 있다. public 스키마의 모든 테이블에 RLS를 켜야 한다. Table Editor에서 테이블별 RLS 상태를 한눈에 확인할 수 있다. 마이그레이션 파일에 ENABLE 줄을 함께 넣는 것이 빠뜨리지 않는 방법이다.
기존 프로젝트에 RLS를 뒤늦게 켜면 데이터가 사라지나?
데이터가 사라지지는 않는다. 다만 정책 없이 RLS만 켜면 API를 통한 조회가 전부 0건으로 반환된다. RLS를 켜기 전에 반드시 정책을 먼저 만들거나, 트랜잭션으로 묶어서 같이 실행해야 한다. BEGIN; ENABLE ROW LEVEL SECURITY; CREATE POLICY; COMMIT; 순서로 묶으면 중간 상태 없이 원자적으로 적용된다.
정책을 여러 개 만들면 어떻게 적용되나?
같은 연산에 PERMISSIVE 정책이 여러 개면 OR로 합산된다. 하나만 통과해도 접근이 허용된다. RESTRICTIVE 정책은 AND로 처리되어 반드시 통과해야 접근이 된다. 공개/비공개 혼합 데이터는 PERMISSIVE 정책 두 개를 OR로 활용하면 된다. 정책 개수가 많아지면 EXPLAIN ANALYZE로 실행 계획을 확인한다.
UPDATE 정책에서 USING과 WITH CHECK는 무엇이 다른가?
USING은 수정할 행을 선택할 때 적용되는 조건이다. 수정 전 행이 조건을 만족해야 UPDATE가 가능하다. WITH CHECK는 수정 후 결과가 조건을 만족하는지 검증한다. WITH CHECK를 빠뜨리면 수정 전에는 본인 행이었더라도 수정 후 user_id를 다른 사람으로 바꿀 수 있다. UPDATE 정책에는 두 가지를 반드시 함께 써야 한다.
마무리
RLS는 반만 설정하면 뚫린다. 켜는 것도 중요하고, 정책을 제대로 쓰는 것도 중요하다. 5가지 실수 중 가장 자주 보이는 건 RLS 비활성화와 정책 누락이다. 이 두 가지만 잡아도 대부분의 데이터 유출은 막을 수 있다.
새 테이블을 만들 때마다 위 체크리스트를 한 번씩 돌리면 된다. 완벽하게 짤 필요는 없다. SELECT 정책 하나에 WITH CHECK 달린 INSERT 정책 하나만 있어도 기본은 지킨다. DELETE 정책과 UPDATE WITH CHECK는 그 다음 단계로 추가하면 된다.
service_role 키 관리는 코드 리뷰 체크리스트에 포함시키는 것이 좋다. NEXT_PUBLIC_ 접두사가 붙은 환경 변수 중에 service_role 키가 있으면 즉시 옮겨야 한다. RLS 설정은 한 번으로 끝나지 않는다. 새 기능이 추가될 때마다 관련 테이블에 정책이 있는지 함께 검토하는 것이 습관이 되면 된다.
이 글은 2026년 4월 기준 정보다. Supabase 업데이트에 따라 일부 내용이 달라질 수 있다.