Supabase Row Level Security 정책 설계 — 테이블별 데이터 노출을 막는 접근 제어 설정
Supabase로 프로젝트를 처음 시작하면 설정이 정말 빠르게 되는 게 신기할 정도인데, 그 편리함이 오히려 함정이 되는 지점이 있습니다. 바로 Row Level Security(RLS) 입니다. 저도 처음엔 대시보드에서 테이블 만들고 클라이언트에서 쿼리 날려보니 데이터가 잘 오길래 "됐다!" 싶었는데, 나중에 알고 보니 RLS가 꺼져 있어서 아무나 전체 데이터를 조회할 수 있는 상태였습니다. 식은땀이 쭈르르 흘렀죠.
이게 나만의 경험이 아닙니다. 2025년 1월, Lovable로 빌드된 170개 이상의 앱에서 RLS 미설정으로 DB 전체가 노출되는 사고가 실제로 있었습니다. 사용자 개인정보와 내부 비즈니스 데이터가 공개 API로 누구에게나 읽힌 상황이었고, 해당 서비스들은 긴급 조치를 취해야 했습니다. Supabase가 2025년 보안 회고에서 "더 안전한 기본값"을 최우선 과제로 내세운 이유도 여기에 있습니다.
이 글이 다른 RLS 문서와 다른 점은, 개념 설명에서 끝내지 않고 멀티 테넌트 격리·RBAC·공개 읽기처럼 실무에서 실제로 마주치는 시나리오별 정책 코드와, 그 코드를 그냥 복붙하면 생기는 함정까지 함께 짚는다는 점입니다. Supabase를 처음 접하는 분이든, 이미 쓰고 있지만 내 정책이 제대로 된 건지 불안한 분이든 모두에게 도움이 될 내용입니다.
핵심 개념
RLS가 하는 일: 데이터베이스가 직접 보안을 강제한다
RLS는 PostgreSQL의 기능으로, 테이블의 행(row) 단위로 접근 제어를 적용합니다. Supabase가 이를 기본 보안 모델로 채택한 이유가 있는데, 클라이언트(브라우저·모바일)에서 데이터베이스에 직접 쿼리를 보내는 구조이기 때문입니다. 애플리케이션 코드를 우회하더라도 데이터베이스 자체가 보안을 강제하므로, 별도 서버 미들웨어 없이도 안전한 설계가 가능합니다.
동작 원리는 단순합니다. RLS가 활성화된 테이블에 쿼리가 들어오면 PostgreSQL은 정의된 정책(Policy)을 암묵적인 WHERE 절로 변환해 실행합니다. 개발자가 작성한 정책이 모든 쿼리에 자동으로 붙는 필터라고 이해하면 나머지는 자연스럽게 따라옵니다.
핵심 동작 규칙: RLS를 활성화하고 정책을 하나도 만들지 않으면, 모든 쿼리가 빈 결과를 반환합니다. 에러가 나는 게 아니라 데이터가 없는 것처럼 보이기 때문에 디버깅이 까다롭습니다. 반대로 RLS를 비활성화하면 테이블의 모든 행이 API를 통해 공개됩니다.
USING과 WITH CHECK: 두 표현식의 역할 구분
정책을 구성하는 두 가지 표현식을 이해하면 나머지는 자연스럽게 따라옵니다.
| 표현식 | 적용 대상 | 역할 |
|---|---|---|
USING |
SELECT, UPDATE, DELETE | 이미 존재하는 행에 대한 접근 제어 |
WITH CHECK |
INSERT, UPDATE | 새로 쓰거나 수정하는 행의 유효성 검사 |
한 가지 놓치기 쉬운 동작 규칙이 있습니다. FOR ALL 정책에 WITH CHECK를 함께 지정해도 DELETE에는 WITH CHECK가 적용되지 않습니다. 삭제 대상을 걸러내는 건 USING이 단독으로 담당합니다.
-- 기본 소유권 정책: 자신의 데이터만 접근 가능
CREATE POLICY "users_own_data"
ON todos
FOR ALL
TO authenticated
USING (auth.uid() = user_id) -- 읽기/수정/삭제: 본인 행만
WITH CHECK (auth.uid() = user_id); -- 쓰기/수정: 본인 소유로만 저장UPDATE 정책이 단독으로 동작하지 않는 이유
PostgreSQL은 UPDATE를 실행하기 전에 SELECT로 해당 행을 먼저 확인하는 구조입니다. 그래서 SELECT 정책이 없으면 UPDATE도 동작하지 않습니다. 두 정책을 항상 함께 생성하는 것이 안전합니다. 이 이유를 몰랐을 땐 "분명히 UPDATE 정책을 만들었는데 왜 안 되지?"라며 한참 삽질했던 기억이 납니다.
JWT 클레임: app_metadata vs user_metadata
RBAC(역할 기반 접근 제어)를 구현할 때 Supabase의 JWT 구조를 이해하는 것이 중요합니다. JWT에는 두 가지 메타데이터 필드가 있는데, 역할·권한 판단에 어떤 필드를 써야 하는지가 핵심입니다.
| 필드 | 수정 주체 | 보안 용도 적합 여부 |
|---|---|---|
user_metadata |
사용자 본인이 클라이언트에서 직접 수정 가능 | 권한 판단에 사용하면 취약 |
app_metadata |
서버 측(서비스 역할)에서만 수정 가능 | 역할·권한 저장에 적합 |
역할·권한 정보는 반드시 app_metadata에 저장하고, Supabase Admin API를 통해서만 수정하도록 설계하는 것이 안전합니다.
실전 적용
예시 1: 개인 데이터 소유권 — 자신의 데이터만 읽고, 쓰고, 수정하기
가장 기본적이면서도 많이 쓰이는 패턴입니다. profiles 테이블처럼 사용자 개인 정보를 담는 경우에 적합합니다.
-- RLS 활성화 (마이그레이션 파일에 반드시 포함)
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- 자신의 프로필만 조회
CREATE POLICY "read_own_profile"
ON profiles FOR SELECT
TO authenticated
USING (auth.uid() = id);
-- 자신의 프로필만 생성 (첫 가입 시)
CREATE POLICY "insert_own_profile"
ON profiles FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = id);
-- 자신의 프로필만 수정
CREATE POLICY "update_own_profile"
ON profiles FOR UPDATE
TO authenticated
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);| 구성 요소 | 의미 |
|---|---|
auth.uid() |
현재 JWT에서 추출한 로그인 사용자의 UUID |
id 컬럼 타입 |
uuid여야 auth.uid()와 타입이 일치하여 비교 가능 |
USING (auth.uid() = id) |
행의 id가 본인인 경우만 접근 허용 |
WITH CHECK (auth.uid() = id) |
INSERT·UPDATE 후에도 행의 소유자가 반드시 본인이어야 함 |
저도 처음엔 SELECT 정책만 만들고 "왜 수정이 안 되지?"라고 한참 헤맸는데, INSERT와 UPDATE 정책은 별도로 만들어야 한다는 점을 처음에 놓치기 쉽습니다.
예시 2: 멀티 테넌트 격리 — SaaS 조직별 데이터 분리
실무에서 SaaS를 만들다 보면 꼭 맞닥뜨리는 케이스입니다. 같은 테이블을 여러 조직이 공유하되, 각 조직은 자신의 데이터만 볼 수 있어야 합니다.
ALTER TABLE tenant_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation"
ON tenant_data FOR ALL
TO authenticated
USING (
tenant_id = (
SELECT tenant_id FROM users WHERE users.id = auth.uid()
)
)
WITH CHECK (
tenant_id = (
SELECT tenant_id FROM users WHERE users.id = auth.uid()
)
);성능 주의: 위처럼 서브쿼리를 포함한 정책은 행마다 서브쿼리가 재평가될 수 있어, 대용량 테이블에서 성능 문제가 생길 수 있습니다.
tenant_id와users.id컬럼에 인덱스를 추가하는 것이 첫 번째 대응입니다. 더 안전한 방법은 권한 로직을SECURITY DEFINER함수로 캡슐화하는 것입니다.
-- SECURITY DEFINER 함수로 테넌트 조회를 캡슐화
CREATE OR REPLACE FUNCTION get_user_tenant_id()
RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT tenant_id FROM users WHERE id = auth.uid();
$$;
-- 함수를 활용한 정책 (읽기 쉽고 재사용 가능)
CREATE POLICY "tenant_isolation_v2"
ON tenant_data FOR ALL
TO authenticated
USING (tenant_id = get_user_tenant_id())
WITH CHECK (tenant_id = get_user_tenant_id());이 방식은 정책을 읽기 쉽게 만들면서, 동일한 조회 로직을 여러 테이블 정책에서 재사용할 수 있다는 장점도 있습니다.
예시 3: RBAC — 역할 기반 접근 제어
관리자만 특정 작업을 할 수 있게 하고 싶을 때 쓰는 패턴입니다. 역할 정보를 어디서 가져오느냐에 따라 두 가지 방식이 있습니다.
방식 A: 역할 테이블 참조
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 관리자만 게시글 수정 가능
CREATE POLICY "admin_update_posts"
ON posts FOR UPDATE
TO authenticated
USING (
auth.uid() IN (
SELECT user_id FROM user_roles WHERE role = 'admin'
)
);방식 B: JWT app_metadata 클레임 활용 (빠른 조회가 필요할 때)
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
-- JWT의 app_metadata에서 역할 확인
CREATE POLICY "editor_can_write"
ON articles FOR INSERT
TO authenticated
WITH CHECK (
(auth.jwt() -> 'app_metadata' ->> 'role') = 'editor'
);| 방식 | 장점 | 단점 |
|---|---|---|
| 역할 테이블 참조 | 역할 변경이 즉시 반영됨 | 매 쿼리마다 서브쿼리 발생 |
JWT app_metadata |
추가 쿼리 없이 빠름 | 역할 변경 후 토큰 재발급 필요 |
예시 4: 공개 읽기 + 인증 사용자만 쓰기
블로그 게시글처럼 누구나 읽을 수 있지만, 작성은 로그인한 사용자만 가능한 패턴입니다. anon 역할(비로그인 사용자)에 SELECT 정책을 부여하는 것이 핵심입니다.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- anon 포함 누구나 읽기 가능
CREATE POLICY "public_read"
ON posts FOR SELECT
USING (true);
-- 로그인한 사용자만 자신의 이름으로 작성
CREATE POLICY "auth_insert"
ON posts FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = author_id);정책 테스트: 클라이언트 SDK로 직접 확인하기
대시보드의 SQL Editor는 RLS를 우회합니다. 정책이 의도대로 동작하는지 확인하려면 반드시 supabase-js로 로그인·비로그인 시나리오를 각각 실행해봐야 합니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// 1. 비로그인 상태로 읽기 시도 (anon 정책 검증)
const { data: anonData, error: anonError } = await supabase
.from('posts')
.select('*');
console.log('anon read:', anonData, anonError);
// 2. 로그인 후 본인 데이터만 읽히는지 확인
await supabase.auth.signInWithPassword({ email, password });
const { data: ownData } = await supabase
.from('profiles')
.select('*');
console.log('authenticated read:', ownData); // 본인 행만 반환되어야 함
// 3. 다른 사용자 ID로 INSERT 시도 (차단되어야 함)
const { error: insertError } = await supabase
.from('profiles')
.insert({ id: 'other-user-id', name: 'test' });
console.log('blocked insert error:', insertError); // 에러가 나야 정상장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 데이터베이스 레벨 보안 | 애플리케이션 코드를 우회해도 보안이 유지됨 |
| 일관성 | 웹·앱·CLI 등 모든 클라이언트에 동일한 규칙이 자동 적용 |
| Realtime 연동 | 구독(subscription)도 RLS 정책을 그대로 존중 |
| 감사 용이성 | 보안 규칙이 SQL로 명시적으로 기록되어 이력 추적 가능 |
단점 및 주의사항
먼저 service_role 키에 대해 짚어두면, 이 키는 RLS를 완전히 우회하는 슈퍼유저 권한입니다. 결제 완료 처리나 권한 상승처럼 일반 사용자 권한으로는 처리할 수 없는 관리자 전용 작업에 쓰이는데, 반드시 서버 사이드 코드(Edge Function 등)에서만 사용하고 클라이언트에는 절대 노출되어선 안 됩니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 인덱스 미적용 | 정책 컬럼 미인덱싱 시 풀 스캔 발생 | 정책에서 참조하는 모든 컬럼에 인덱스 추가 |
| UPDATE 단독 작동 안 함 | SELECT 정책 없으면 UPDATE도 동작하지 않음 (PostgreSQL이 UPDATE 전 SELECT로 행을 먼저 확인하는 구조) | SELECT 정책을 반드시 함께 생성 |
| SQL Editor RLS 우회 | Dashboard SQL Editor는 RLS를 우회함 | 반드시 클라이언트 SDK로 정책 테스트 |
| 관리자 작업 분리 | 권한 상승 작업을 RLS만으로 처리하면 위험 | Edge Function + service_role 키 조합, 함수 내부에서 호출자 권한 검증 |
보안 설정의 누락이나 과도하게 허용적인 정책을 자동으로 잡아주는 도구도 있습니다. Supabase Dashboard의 Security Advisor가 바로 이것으로, 오픈소스 PostgreSQL 보안 린터인 Splinter를 엔진으로 잘못된 설정과 민감 컬럼 노출 등을 자동 스캔합니다. 프로젝트에 주기적으로 실행해두면 실수를 미리 잡을 수 있어 실무에서 꽤 유용합니다.
실무에서 가장 흔한 실수
-
RLS 활성화 누락: 테이블 생성 후
ALTER TABLE ... ENABLE ROW LEVEL SECURITY;를 빠뜨리면 전체 데이터가 공개됩니다. 2025년부터 Supabase는 Dashboard에 경고 레이블을 표시하고 이메일 알림도 발송하지만, 마이그레이션 파일에 명시적으로 넣어두는 습관이 훨씬 안전합니다. -
정책 없이 RLS만 활성화: 모든 쿼리가 빈 결과를 반환하는데 에러 메시지가 없어서 "데이터가 사라진 것"처럼 보입니다. RLS 활성화와 함께 최소한 하나의 정책을 같이 만들어두는 편이 좋습니다.
-
USING (true)남용: "일단 열어두자"는 의도로 사용하면 인증된 모든 사용자가 전체 행에 접근 가능해집니다. 공개 읽기가 필요한 테이블에 한해서,SELECT에만 사용하는 것이 안전합니다. -
user_metadata클레임으로 권한 판단: 사용자가 클라이언트에서 직접 수정할 수 있는 필드라 역할·권한 판단에 쓰면 취약합니다. 역할 정보는 반드시app_metadata에 저장하고 Supabase Admin API를 통해서만 수정하도록 설계하는 것이 안전합니다.
마치며
RLS는 쿼리에 WHERE 절을 자동으로 붙이는 것입니다. 이 한 문장만 기억해도 정책을 설계할 때 훨씬 직관적으로 접근할 수 있습니다.
솔직히 처음엔 USING과 WITH CHECK의 차이도 헷갈리고, 정책을 만들었는데 왜 데이터가 안 오는지 한참 삽질한 경험이 있습니다. 하지만 원리를 이해하고 나면 SQL 작성하듯 자연스럽게 접근할 수 있고, 보안 설정이 코드가 아닌 DB 레벨에 있어서 클라이언트가 뭘 하든 안심이 된다는 게 RLS의 진짜 장점입니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 프로젝트의 RLS 상태를 점검해볼 수 있습니다 — 저라면 여기서부터 시작하겠습니다. Supabase Dashboard > Table Editor에서 경고 레이블이 표시된 테이블이 있는지 확인하거나, Security Advisor 탭에서 Splinter 기반 자동 스캔 결과를 살펴보시면 됩니다.
-
마이그레이션 파일에 RLS 설정을 명시적으로 추가하는 것을 권장합니다 —
supabase db diff명령으로 현재 정책 상태를 파일로 추출한 뒤,ENABLE ROW LEVEL SECURITY와 정책 생성 SQL이 마이그레이션에 포함되어 있는지 확인해보시면 됩니다. -
정책 테스트는 반드시 클라이언트 SDK로 진행하는 것이 중요합니다 — 앞서 소개한
supabase-js스니펫처럼, 로그인 상태와 비로그인 상태 각각에서 데이터가 의도대로 보이거나 차단되는지 직접 확인해보시면 됩니다.
참고 자료
- Row Level Security | Supabase 공식 문서
- RLS Performance and Best Practices | Supabase Troubleshooting
- Supabase Security Retro: 2025
- Security & Performance Advisor | Supabase Features
- Performance and Security Advisors | Supabase Docs
- Custom Claims & RBAC | Supabase Docs
- AI Prompt: Database RLS Policies | Supabase Docs