Supabase RLS + SECURITY DEFINER로 멀티테넌트 권한 제어를 DB 레벨에서 처리하는 법
Supabase로 SaaS를 만들다 보면 어느 순간 RLS 정책만으로는 감당이 안 되는 벽에 부딪힙니다. "이 유저가 저 조직의 멤버인지 확인하면서 동시에 리소스별 역할도 따져야 하는데…" 하며 정책 표현식이 점점 엮이다 보면, 쿼리 성능이 눈에 띄게 떨어지고 결국 일부 권한 로직을 애플리케이션 레이어로 올려버리는 타협을 하게 됩니다. 저도 처음엔 그렇게 했었고, 심지어 그게 맞는 방향인 줄 알았습니다. 조직-멤버십-역할 체인 조건이 붙은 쿼리에서 SECURITY DEFINER 함수로 전환하고 나서야, 수천 행 기준으로 쿼리 실행 횟수가 행 단위 반복에서 단 한 번으로 줄어드는 걸 직접 확인하고 생각이 바뀌었습니다.
PostgreSQL의 SECURITY DEFINER 함수를 RLS 정책과 조합하면, 복잡한 권한 판단 로직을 DB 레벨에서 캡슐화하면서 보안과 성능을 동시에 잡을 수 있습니다. 이 글에서는 개념부터 멀티테넌트 SaaS 실전 패턴, 그리고 실수하기 쉬운 보안 함정까지 훑어봅니다. Supabase를 실무에서 사용 중이고 RLS를 한 번이라도 설정해본 분이라면 바로 이어지는 내용이 익숙하게 와닿을 겁니다.
핵심 개념
RLS: 행 단위 접근 제어가 막히는 지점
RLS는 PostgreSQL이 제공하는 행(row) 단위 접근 제어 메커니즘입니다. 테이블에 정책을 정의해두면 SELECT·INSERT·UPDATE·DELETE 요청이 들어올 때마다 PostgreSQL이 자동으로 해당 조건을 WHERE 절처럼 적용해줍니다. Supabase는 이를 기본 보안 레이어로 채택하고 있어서, auth.uid()나 auth.jwt() 헬퍼 함수로 현재 인증 사용자 정보를 SQL 안에서 바로 참조할 수 있습니다.
단순한 조건은 이걸로 충분합니다.
-- 내 글만 볼 수 있는 단순 RLS 정책
CREATE POLICY "own_posts_only"
ON public.posts
FOR SELECT
USING (user_id = auth.uid());문제는 "이 프로젝트가 속한 조직의 활성 멤버인 사람만 볼 수 있다"처럼 조건이 복잡해질 때입니다. org_members 테이블을 조인해야 하는데, 그 테이블에도 RLS가 걸려 있으면 권한 충돌이 발생하거나 성능이 크게 떨어집니다. 행마다 서브쿼리를 반복 실행하는 구조가 되어버리거든요.
SECURITY DEFINER 함수: 권한 판단을 캡슐화하는 블랙박스
SECURITY DEFINER는 PostgreSQL 함수 속성 중 하나입니다. 기본(SECURITY INVOKER)은 호출한 사용자의 권한으로 실행되지만, SECURITY DEFINER 함수는 함수를 정의한 소유자의 권한으로 실행됩니다.
핵심 포인트:
SECURITY DEFINER함수가 RLS를 우회하는 것은 함수 소유자가 테이블 소유자이거나bypassrls권한을 보유한 경우에 한합니다. 소유자가 일반 역할이라면 RLS는 여전히 적용됩니다. Supabase의 기본 설정에서는 함수 소유자가postgres(테이블 소유자)이기 때문에 내부 조회 시 RLS가 우회되는 것입니다.
이 특성을 RLS 정책과 조합하면 강력한 패턴이 나옵니다. 정책 자체는 모든 사용자에게 적용되지만, 권한 판단을 위한 내부 조회는 함수 소유자 권한으로 RLS 없이 수행됩니다. 덕분에 org_members 같은 내부 테이블을 안전하게 조회할 수 있습니다.
-- private 스키마에 역할 확인 헬퍼 함수 생성
CREATE OR REPLACE FUNCTION private.has_role(role_name text)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = ''
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role = role_name
);
$$;
-- RLS 정책에서 함수 호출
CREATE POLICY "admin_only"
ON public.sensitive_data
FOR ALL
USING ((SELECT private.has_role('admin')));(SELECT private.has_role('admin')) 형태로 감싸는 게 포인트입니다. 함수를 그냥 호출하면 PostgreSQL 옵티마이저가 행마다 반복 실행할 수 있는데, SELECT로 감싸면 옵티마이저가 이를 initPlan(쿼리 실행 계획상 서브쿼리 선계산)으로 처리해서 쿼리당 딱 한 번만 평가합니다. 수천 행이 있는 테이블이라면 성능 차이가 확연합니다.
한 가지 주의할 점: 함수에 STABLE 속성을 붙이면 동일 트랜잭션 내에서 같은 인수로 호출한 결과를 캐싱해 재사용합니다. 성능에는 좋지만, 트랜잭션 도중 멤버십이 변경되는 경우 오래된 권한 정보가 캐싱된 채로 적용될 수 있습니다. 멤버십이 실시간으로 자주 바뀌는 시나리오라면 VOLATILE로 설정하는 것도 고려해볼 수 있습니다.
최근 변경 사항: 2025년 Supabase에서 달라진 것들
패턴을 본격적으로 적용하기 전에, 올해 Supabase가 바꾼 몇 가지를 짚고 넘어가겠습니다. 직접 영향이 있어서요.
첫째, Postgres Event Triggers가 도입되어 이제는 대시보드, CLI, 마이그레이션 어떤 경로로 테이블을 생성해도 RLS가 자동으로 활성화됩니다. 이전에는 "CLI로 만든 테이블은 RLS가 꺼진 상태"라는 함정이 있었는데, 이제는 빠뜨릴 구멍이 없어졌습니다.
둘째, Custom Access Token Auth Hook이 강화됐습니다. JWT 발급 전 Hook으로 조직 멤버십, 역할, 테넌트 ID 같은 정보를 토큰에 사용자 정의 클레임으로 삽입할 수 있고, 이를 RLS에서 auth.jwt()->'app_metadata'->>'role' 형태로 직접 참조할 수 있습니다. 솔직히 이 기능이 생각보다 많은 경우를 커버해줘서, 저는 단순한 글로벌 역할 체크는 전부 여기로 이전했습니다. SECURITY DEFINER 함수 없이 JWT 클레임만으로 처리 가능한 케이스가 꽤 됩니다.
셋째, 관리형 PostgreSQL 환경에서 SECURITY DEFINER 함수의 실행 컨텍스트 격리 실패로 권한 상승이 가능했던 취약점 사례가 보고되면서, 이 사용 패턴 전반에 대한 보안 검토가 업계에서 강화되는 추세입니다. 자체 Supabase 인스턴스나 일반 PostgreSQL 환경에서는 직접적인 영향은 없지만, 주기적인 보안 감사와 pg_audit 활용을 권장합니다.
실전 적용
조직 멤버십 체크: org_members RLS 충돌 없이 조회하기
SaaS에서 가장 자주 나오는 패턴입니다. "이 프로젝트는 해당 조직의 활성 멤버만 볼 수 있다"는 조건인데, org_members 테이블에도 RLS가 걸려 있어서 직접 조인하면 권한 충돌이 발생합니다.
-- 000_init_private_schema.sql
-- private 스키마 생성 (api.exposed_schemas 목록에서 제외되어 외부 직접 호출 차단)
CREATE SCHEMA IF NOT EXISTS private;
-- 1단계: 조직 멤버십 확인 함수
CREATE OR REPLACE FUNCTION private.is_org_member(org_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = ''
AS $$
SELECT EXISTS (
SELECT 1 FROM public.org_members
WHERE organization_id = org_id
AND user_id = auth.uid()
AND status = 'active'
);
$$;
-- authenticated 역할에 실행 권한 명시적 부여
GRANT EXECUTE ON FUNCTION private.is_org_member(uuid) TO authenticated;
-- 2단계: projects 테이블에 RLS 적용
CREATE POLICY "org_member_access"
ON public.projects
FOR SELECT
USING ((SELECT private.is_org_member(organization_id)));private 스키마는 Supabase의 supabase/config.toml에서 api.exposed_schemas에 포함되지 않으면 PostgREST API에 노출되지 않습니다. 기본적으로 public만 노출되므로, private 스키마에 함수를 두기만 해도 외부 RPC 직접 호출을 막을 수 있습니다.
| 코드 요소 | 역할 |
|---|---|
private 스키마 |
PostgREST API에서 제외되어 외부 직접 호출 차단 |
STABLE |
동일 트랜잭션 내 같은 인수 호출 시 결과 캐싱 (멤버십 실시간 변경 시 주의) |
SET search_path = '' |
search_path 인젝션 공격 방지 |
(SELECT 함수()) 래핑 |
initPlan(서브쿼리 선계산)으로 쿼리당 1회 실행 보장 |
GRANT EXECUTE |
필요한 역할에만 실행 권한 명시적 부여 |
리소스별 역할 분리: admin > editor > viewer 계층 처리하기
역할이 계층 구조를 가지고, 작업별로 다른 역할을 요구하는 경우입니다.
-- 역할 수준 반환 함수 (파라미터명 p_resource_id로 컬럼명과 구분)
CREATE OR REPLACE FUNCTION private.get_user_role(p_resource_id uuid)
RETURNS text
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = ''
AS $$
SELECT role FROM public.resource_permissions
WHERE resource_id = p_resource_id
AND user_id = auth.uid()
LIMIT 1;
$$;
-- 읽기는 멤버 전체, 쓰기는 admin/editor만
CREATE POLICY "read_any_member"
ON public.documents
FOR SELECT
USING ((SELECT private.get_user_role(id)) IS NOT NULL);
CREATE POLICY "write_requires_editor"
ON public.documents
FOR UPDATE
USING ((SELECT private.get_user_role(id)) IN ('admin', 'editor'));파라미터명을 p_resource_id처럼 접두어로 구분하는 건 사소해 보이지만, WHERE resource_id = $1에서 $1이 컬럼인지 인수인지 헷갈리는 상황을 예방해줍니다. 처음 보는 사람이 읽기에도 한결 편해지고요.
JWT Custom Claims + RLS: 단순한 역할 체크는 여기서
Custom Access Token Hook으로 JWT에 역할 클레임을 미리 삽입했다면, SECURITY DEFINER 함수 없이 더 단순하게 처리할 수 있습니다.
-- JWT app_metadata에 role 클레임이 있는 경우
CREATE POLICY "moderator_access"
ON public.reported_content
FOR ALL
USING (
(auth.jwt()->'app_metadata'->>'role') = 'moderator'
);역할이 단순하고 JWT 클레임으로 표현 가능하다면 이 방식이 훨씬 가볍습니다. 반면 역할이 리소스별로 다르거나 멤버십 체인처럼 복잡한 조건이 필요하다면 SECURITY DEFINER 함수 쪽이 낫습니다. 실무에서는 글로벌 역할은 JWT 클레임으로, 리소스별 세밀한 권한은 함수로 처리하는 하이브리드 RBAC+ABAC 구조를 많이 사용합니다.
감사 로그 무결성 보호: 삽입만 허용하고 수정·삭제 완전 차단하기
감사 로그는 한 번 기록되면 수정·삭제가 되면 안 됩니다. SECURITY DEFINER 함수를 통해서만 쓰기가 가능하도록 구성하면 이 불변성을 DB 레벨에서 보장할 수 있습니다.
-- authenticated 역할에서 직접 INSERT 권한 회수 (이 줄이 없으면 패턴이 완성되지 않음)
REVOKE INSERT ON public.audit_logs FROM authenticated;
-- SECURITY DEFINER 함수를 통해서만 삽입 가능
CREATE OR REPLACE FUNCTION private.log_action(action text, resource_id uuid)
RETURNS void
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
INSERT INTO public.audit_logs (user_id, action, resource_id, created_at)
VALUES (auth.uid(), action, resource_id, now());
$$;
GRANT EXECUTE ON FUNCTION private.log_action(text, uuid) TO authenticated;REVOKE INSERT ... FROM authenticated;가 핵심입니다. 이걸 빠뜨리면 "함수를 통해서만 INSERT 가능하다"는 전제가 DB 레벨에서 강제되지 않습니다. RLS 정책만으로는 SECURITY DEFINER 함수 외부 경로를 완전히 차단하지 못하기 때문에, 직접 권한 회수까지 함께 묶어야 패턴이 완성됩니다.
장단점 분석
솔직히 이 패턴을 처음 도입했을 때, 아래 단점 표의 2번 항목에 정확히 빠졌습니다. SET search_path = ''의 의미를 잘 몰라서 그냥 생략했었는데, 코드 리뷰에서 지적받고 나서야 왜 필수인지 제대로 이해했습니다. 강력한 패턴인 만큼 잘못 쓰면 오히려 보안 구멍이 됩니다.
장점
| 항목 | 내용 |
|---|---|
| 성능 최적화 | RLS 패널티 없이 내부 테이블 조회. 복잡한 조인을 포함해도 빠름 |
| initPlan 캐싱 | (SELECT 함수()) 래핑으로 쿼리당 1회만 평가 — 행 수에 무관 |
| 로직 캡슐화 | 권한 판단 로직을 중앙화해 여러 테이블·정책에서 재사용 가능 |
| 테스트 용이성 | pgTAP으로 함수 단위 독립 테스트 작성 가능 |
| 복잡한 비즈니스 규칙 | 계층적 권한, 멤버십 체인, 시간 기반 접근 등 단순 컬럼 비교 불가 케이스 처리 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 스키마 노출 위험 | public 스키마에 생성하면 PostgREST RPC로 외부 직접 호출 가능 |
반드시 private 스키마에 생성, api.exposed_schemas에서 제외 |
| search_path 인젝션 | 설정 누락 시 악성 객체로 함수 동작 탈취 가능 | SET search_path = '' 항상 명시 |
| STABLE 캐싱 함정 | 트랜잭션 중 멤버십 변경 시 오래된 권한 정보가 캐싱될 수 있음 | 실시간 변경이 잦다면 VOLATILE 검토 |
| 뷰의 기본 SECURITY DEFINER | PostgreSQL 뷰는 기본적으로 RLS를 우회 | PostgreSQL 15+에서 security_invoker = true 명시 |
| 테이블 소유자 RLS 우회 | postgres 역할은 기본적으로 RLS 무시 |
중요 테이블에 FORCE ROW LEVEL SECURITY 적용 |
FORCE ROW LEVEL SECURITY란? 기본적으로 테이블 소유자는 RLS를 우회합니다.ALTER TABLE t FORCE ROW LEVEL SECURITY;를 적용하면 소유자도 예외 없이 정책을 따르게 됩니다. 마이그레이션 스크립트가 의도치 않게 모든 데이터에 접근하는 것을 막을 수 있습니다.
실무에서 가장 흔한 실수
-
SECURITY DEFINER함수를public스키마에 생성하는 것. 처음에 "private 스키마가 뭔데?"라는 상태에서 그냥public에 다 올리는 경우가 많습니다. Supabase는public스키마의 함수를 PostgREST RPC로 외부에 노출하기 때문에, 인증된 사용자라면 누구나supabase.rpc('is_org_member', { org_id: '...' })처럼 직접 호출해서 역할 체크를 우회할 수 있습니다.CREATE SCHEMA private;로 분리하고,supabase/config.toml의api.exposed_schemas에private이 포함되지 않도록 확인하는 것이 첫 번째 체크포인트입니다. -
SET search_path = ''를 빠뜨리는 것. 저도 처음엔 이 옵션의 의미를 잘 몰라서 생략했는데, 이 설정이 없으면 공격자가 같은 이름의 악성 함수나 테이블을 신뢰할 수 없는 스키마에 배치해SECURITY DEFINER함수가 의도치 않은 객체를 실행하도록 유도할 수 있습니다.supabase test db를 실행하면 lint 경고로 이 누락을 잡아줍니다. -
뷰에서 RLS가 우회되는 걸 모르는 것. PostgreSQL에서 뷰는 기본적으로 SECURITY DEFINER로 동작해서 뷰를 통한 접근은 RLS를 타지 않습니다. 이걸 모르고 뷰를 API 엔드포인트처럼 쓰면 의도치 않게 전체 데이터가 노출될 수 있습니다. PostgreSQL 15 이상을 쓰고 있다면
CREATE VIEW my_view WITH (security_invoker = true) AS ...로 명시하는 것을 권장합니다.supabase test db명령의 lint 0010이 이 문제를 자동 감지해줍니다.
마치며
SECURITY DEFINER 함수와 RLS를 조합하면 복잡한 비즈니스 권한 로직을 애플리케이션 레이어가 아닌 DB 레벨에서 안전하고 효율적으로 구현할 수 있습니다. 다만 강력한 만큼 스키마 격리와 search_path 설정은 선택이 아닌 필수입니다.
지금 바로 시작해볼 수 있는 4단계:
-
먼저 현재 상태를 파악합니다. RLS가 활성화되지 않은 테이블부터 전수 조회하는 것이 출발점입니다. 이후
supabase test db명령을 실행하면 보안 경고(lint 0004, 0010 등)를 자동으로 확인할 수 있습니다. -
SECURITY DEFINER뷰와public스키마 노출 함수를 점검합니다. lint 0010으로 잡힌 뷰는security_invoker = true로 전환하고,public스키마에 있는 권한 관련 함수는private스키마로 이전하는 것을 권장합니다. -
private스키마를 생성하고 권한 헬퍼 함수를 옮깁니다.CREATE SCHEMA private;로 스키마를 만들고, 현재 복잡한 RLS 정책 표현식을private.check_something()형태의 함수로 추출해볼 수 있습니다.SECURITY DEFINER와SET search_path = ''를 반드시 함께 붙여주세요. -
JWT Custom Claims 활용 가능 여부를 검토합니다. 역할이 비교적 단순하다면 Custom Access Token Auth Hook으로 JWT에 역할 클레임을 삽입하고, RLS에서
auth.jwt()->'app_metadata'->>'role'로 직접 참조하는 방식이 훨씬 가볍습니다. 복잡한 리소스별 권한은SECURITY DEFINER함수로, 글로벌 역할 체크는 JWT 클레임으로 나눠 처리하는 하이브리드 구조를 고려해볼 수 있습니다.
참고 자료
공식 문서
- Row Level Security | Supabase Docs
- RLS Performance and Best Practices | Supabase Troubleshooting
- Do I need to expose "security definer" Functions in RLS Policies? | Supabase
- Custom Claims & Role-based Access Control (RBAC) | Supabase Docs
- Performance and Security Advisors | Supabase Docs
- PostgreSQL Documentation: Row Security Policies
심화 읽기
- Supabase Security Retro: 2025 | Supabase Blog
- Supabase RLS Best Practices: Production Patterns for Secure Multi-Tenant Apps | makerkit.dev
- Enforcing RLS in Supabase: A Deep Dive into Multi-Tenant Architecture | DEV Community
- Optimizing Postgres Row Level Security (RLS) for Performance | Scott Pierce
- Supabase RLS using Functions - Security Definers | Entroblog
보안 참고