Supabase RLS 정책을 pgTAP으로 자동 테스트하기 — 역할별 접근 시나리오를 SQL로 코드화하는 법
RLS 정책을 처음 작성하고 나서 "잘 되겠지" 하고 프로덕션에 올렸다가 식은땀 흘린 경험, 저만 있는 건 아닐 겁니다. 대시보드에서 쿼리를 날려봤더니 잘 차단되는 것 같아서 안심했는데, 나중에 알고 보니 SQL 에디터가 슈퍼유저로 실행되어 RLS를 통째로 무시하고 있었던 거죠. 그때의 당혹감이 아직도 생생합니다.
보고된 바에 따르면, AI 코딩 도구로 빌드된 앱 수백 개에서 RLS가 아예 비활성화된 채 데이터베이스가 노출되는 사고가 이어지고 있습니다. AI가 정책을 작성해줬지만 그게 실제로 작동하는지 아무도 검증하지 않았던 겁니다. 이런 사고를 계기로 "RLS 정책은 자동화된 테스트로 검증한다"는 방식이 점점 더 권장되고 있습니다. 이 글에서는 pgTAP을 활용해 역할별 접근 시나리오를 SQL 레벨 단위 테스트로 코드화하는 패턴을 다룹니다.
이 글을 따라하려면 Supabase 프로젝트와 Supabase CLI(v1.11.4 이상), 그리고 SQL 기초 지식이 필요합니다.
핵심 개념
RLS가 묵시적으로 실패하는 이유
Row Level Security는 PostgreSQL이 각 행에 대해 정책 표현식을 평가해서 true를 반환하는 행만 결과에 포함시키는 방식으로 동작합니다. 여기서 중요한 점은 SELECT·UPDATE·DELETE는 정책을 통과하지 못한 행을 에러 없이 그냥 무시한다는 겁니다. INSERT만 위반 시 42501 insufficient_privilege 에러를 던지고, 나머지 세 가지는 조용히 빈 결과만 돌아옵니다.
묵시적 실패(silent failure): 보안 정책 위반인데도 에러가 발생하지 않고 결과가 빈 값으로 반환되는 동작. 개발자 입장에서 "통과했나, 차단됐나"를 쿼리 결과만으로 구분하기 어렵습니다.
이 특성 때문에 수동 테스트는 구멍이 생기기 쉽습니다. "조회가 안 되네, 잘 차단됐다"는 판단이 사실은 쿼리 자체가 잘못됐거나 RLS가 꺼져 있었을 수도 있거든요. 이게 바로 제가 처음에 SQL 에디터로 RLS를 검증했다가 빠진 함정이기도 합니다.
pgTAP이 이 문제를 해결하는 방식
pgTAP은 PostgreSQL 전용 TAP(Test Anything Protocol) 기반 테스트 프레임워크입니다. SQL 스크립트 안에서 plan(), assertion 함수들, finish()로 테스트를 선언하고, 전체를 트랜잭션으로 감싸서 ROLLBACK으로 끝냅니다. 테스트가 끝나면 데이터베이스 상태는 원래대로 돌아옵니다.
TAP(Test Anything Protocol): 테스트 결과를 표준화된 텍스트 형식으로 출력하는 프로토콜입니다.
ok 1 - 소유자는 조회 가능같은 형식으로 출력되며, CI 도구가 이를 파싱해서 통과/실패를 판단합니다.
RLS 검증의 핵심은 역할 전환과 JWT 클레임 주입입니다. 테스트 세션에서 SET LOCAL role과 SET LOCAL request.jwt.claims를 설정하면 PostgreSQL이 특정 사용자 컨텍스트를 흉내 낼 수 있습니다. Supabase의 auth.uid()와 auth.role() 함수는 이 PostgreSQL 세션 설정값을 읽어서 JWT 클레임을 반환합니다. 즉, 테스트에서 request.jwt.claims를 원하는 값으로 주입하면 그 값이 그대로 auth.uid()와 auth.role()에 반영되는 겁니다.
JWT의 sub 클레임은 사용자 고유 ID로, Supabase에서는 auth.uid()가 이 값을 반환합니다.
-- 역할 전환 원리 (헬퍼 없이 직접 작성하면 이렇게 됩니다)
SET LOCAL role TO 'authenticated';
SET LOCAL request.jwt.claims TO '{"sub": "user-uuid-here", "role": "authenticated"}';
-- 이제 이 세션은 해당 JWT를 가진 사용자처럼 동작합니다
-- auth.uid()는 "user-uuid-here"를, auth.role()은 "authenticated"를 반환합니다
SELECT * FROM posts; -- RLS 정책이 적용됩니다supabase-test-helpers 패키지는 이 과정을 tests.authenticate_as('username') 한 줄로 추상화해줍니다.
세 가지 기본 역할과 주의사항
Supabase의 RLS 정책에서 자주 쓰이는 역할입니다.
| 역할 | 설명 | 대표 시나리오 |
|---|---|---|
anon |
비인증 사용자 | 로그인 없이 공개 데이터 접근 |
authenticated |
JWT를 가진 로그인 사용자 | 본인 데이터 CRUD |
service_role |
RLS 우회 관리자 권한 | 서버 사이드 어드민 작업 |
한 가지 중요한 함정이 있습니다. service_role은 RLS를 완전히 우회하기 때문에, 테스트에서 실수로 service_role로 역할을 설정하면 RLS 정책을 전혀 검증하지 않는 테스트가 됩니다. "테스트가 다 통과되는데 왜 프로덕션에서 데이터가 새지?" 하는 상황이 바로 이 경우입니다.
환경 설정
예시 코드를 따라하려면 다음 설정이 먼저 필요합니다.
1. pgTAP 익스텐션 활성화
supabase db extension enable pgtap2. supabase-test-helpers 설치
database.dev는 PostgreSQL 전용 패키지 매니저로, npm처럼 커뮤니티 SQL 패키지를 설치하고 관리할 수 있습니다. supabase-test-helpers는 Supabase RLS 테스트에 특화된 헬퍼 함수 모음으로, 여기서 설치할 수 있습니다.
-- supabase/tests/000-setup.sql
-- dbdev(database.dev 클라이언트)가 설치되어 있어야 합니다
select dbdev.install('basejump/supabase_test_helpers');
-- 헬퍼 함수가 제대로 로드됐는지 확인
select has_function(
'tests',
'authenticate_as',
ARRAY['text'],
'supabase_test_helpers가 정상 설치되었다'
);000-setup.sql이 알파벳순으로 가장 먼저 실행되므로, 공통 픽스처 설정이나 헬퍼 설치 확인 코드를 이 파일에 모아두는 것이 일반적인 관행입니다.
실전 적용
예시 1: 게시물 소유자만 읽기·수정 허용하는 RLS 검증
가장 흔한 패턴부터 시작해볼게요. posts 테이블에 "본인 게시물만 조회·수정 가능"한 정책이 걸려 있을 때, 소유자·타인·비인증 사용자 세 역할을 한꺼번에 검증합니다.
먼저 테스트 대상 테이블의 스키마입니다.
-- 마이그레이션 파일 예시 (테스트 전제 조건)
create table posts (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
title text not null
);
alter table posts enable row level security;
-- 소유자만 자신의 게시물을 조회·수정·삭제할 수 있다
create policy "소유자 접근" on posts
for all
using (auth.uid() = user_id);이 정책을 검증하는 pgTAP 테스트는 이렇습니다.
-- supabase/tests/002-posts-rls.sql
BEGIN;
SELECT plan(4);
-- 테스트용 사용자 두 명 생성
-- tests.create_supabase_user()는 auth.users에 테스트 사용자를 삽입하고
-- UUID를 자동 생성해 반환합니다. ROLLBACK 시 함께 삭제됩니다.
SELECT tests.create_supabase_user('owner');
SELECT tests.create_supabase_user('stranger');
-- owner로 인증 후 게시물 삽입
SELECT tests.authenticate_as('owner');
INSERT INTO posts (title, user_id)
VALUES ('비밀 게시글', tests.get_supabase_uid('owner'));
-- 1. 소유자는 자신의 게시물을 볼 수 있다
SELECT results_eq(
$$ SELECT title FROM posts $$,
$$ VALUES ('비밀 게시글'::text) $$,
'소유자는 자신의 게시물을 조회할 수 있다'
);
-- 2. 타인은 볼 수 없다
SELECT tests.authenticate_as('stranger');
SELECT is_empty(
$$ SELECT title FROM posts $$,
'다른 사용자는 타인의 게시물을 조회할 수 없다'
);
-- 3. 비인증 사용자는 볼 수 없다
SELECT tests.clear_authentication();
SELECT is_empty(
$$ SELECT title FROM posts $$,
'anon 사용자는 게시물을 조회할 수 없다'
);
-- 4. 타인이 수정 시도 — RETURNING *와 is_empty 조합으로 묵시적 차단 확인
SELECT tests.authenticate_as('stranger');
SELECT is_empty(
$$ UPDATE posts SET title = '해킹' RETURNING * $$,
'타인은 게시물을 수정할 수 없다'
);
SELECT * FROM finish();
ROLLBACK;코드에서 눈여겨볼 부분을 정리하면 이렇습니다.
| 패턴 | 이유 |
|---|---|
BEGIN / ROLLBACK으로 감싸기 |
테스트 데이터가 남지 않도록 상태 보존 |
SELECT plan(4) |
실행할 assertion 수를 미리 선언, 누락 감지 |
UPDATE ... RETURNING * + is_empty |
묵시적 차단 검증 — 에러가 없으므로 결과로 확인 |
tests.clear_authentication() |
anon 역할로 명시적 전환 |
예시 2: INSERT 차단 검증 — throws_ok 사용
앞에서 SELECT와 UPDATE 차단을 봤으니, 이번엔 INSERT가 왜 다르게 처리되는지 보겠습니다. UPDATE와 달리 INSERT는 RLS 위반 시 에러를 던지므로, 이 경우엔 is_empty 대신 throws_ok를 씁니다.
-- 다른 사용자의 user_id로 INSERT 시도
SELECT tests.authenticate_as('stranger');
SELECT throws_ok(
$$ INSERT INTO posts (title, user_id)
VALUES ('위조 게시글', tests.get_supabase_uid('owner')) $$,
'42501',
NULL,
'타인 명의 INSERT는 RLS가 차단해야 한다'
);throws_ok의 두 번째 인자는 PostgreSQL 에러 코드입니다. 42501이 insufficient_privilege에 해당합니다. 솔직히 저도 처음엔 INSERT 차단도 is_empty로 쓰다가 테스트가 에러로 터져서 당황했던 기억이 있습니다. INSERT와 SELECT/UPDATE/DELETE의 차단 방식이 다르다는 걸 실수로 배웠죠.
예시 3: 테스트 파일 구성과 CI 통합
테스트 파일은 알파벳순으로 실행되므로, 번호 접두사로 순서를 제어하는 관행이 자리잡혀 있습니다.
supabase/tests/
000-setup.sql ← 헬퍼 설치 확인, 공통 픽스처
001-auth-rls.sql ← 인증 관련 테이블 RLS 테스트
002-posts-rls.sql ← 게시글 RLS 테스트
003-comments-rls.sql ← 댓글 RLS 테스트GitHub Actions 통합은 이 정도면 충분합니다.
# .github/workflows/test.yml
name: Database Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Start Supabase local stack
run: supabase start
- name: Apply migrations
run: supabase db reset
- name: Run pgTAP tests
run: supabase test dbsupabase db reset이 마이그레이션을 적용하는 단계입니다. 이 단계 없이 바로 supabase test db를 실행하면 테이블이 없는 상태에서 테스트가 돌아 전부 실패하게 됩니다. CI를 처음 구성할 때 이 단계를 빠트려서 한참 헤맸던 기억이 있으니, 꼭 챙겨두시면 좋습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 자동화된 회귀 방지 | 정책 변경 시 기존 역할 시나리오가 깨지는 것을 즉시 감지합니다 |
| DB 상태 보존 | 각 테스트 파일이 ROLLBACK으로 마무리되어 잔여 데이터가 없습니다 |
| 세밀한 시나리오 | 특정 UUID를 가진 사용자가 특정 행을 보는지 SQL 레벨에서 정밀하게 검증됩니다 |
| CI 통합 용이 | supabase test db 단일 명령으로 전체 테스트를 실행할 수 있습니다 |
| 명세 문서화 효과 | 테스트 파일 자체가 "누가 무엇을 볼 수 있는가"의 살아있는 명세서가 됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 슈퍼유저 우회 | 테이블 소유자·슈퍼유저는 RLS를 무시합니다 | 테스트에서 반드시 SET ROLE을 명시하거나 헬퍼 사용 |
| service_role 함정 | service_role로 역할 설정 시 RLS 검증이 무의미해집니다 |
테스트 역할은 authenticated 또는 anon만 사용 |
| JWT 클레임 설정 복잡성 | auth.jwt()->'app_metadata' 같은 중첩 클레임은 JSON 직접 주입이 필요합니다 |
복잡한 클레임용 래퍼 함수를 000-setup.sql에 준비 |
| 묵시적 실패 함정 | UPDATE·DELETE 차단은 에러가 없습니다 |
RETURNING *와 is_empty 조합 필수 |
| 성능 검증 한계 | RLS 정책이 인덱스를 제대로 타는지는 검증하지 않습니다 | 별도 EXPLAIN ANALYZE로 확인 필요 |
실무에서 가장 흔한 실수
-
SQL 에디터로 RLS를 검증하는 것 — Supabase 대시보드의 SQL 에디터는
postgres슈퍼유저로 실행되기 때문에 RLS 정책을 완전히 우회합니다. "에디터에서 잘 차단되던데"라는 말은 사실 "차단을 못 봤는데"와 같습니다. 이게 바로 제가 처음에 빠졌던 함정이기도 하고, 도입부에서 말한 식은땀의 원인이었습니다. -
UPDATE·DELETE차단을 에러로 확인하려는 것 —INSERT와 달리UPDATE/DELETE/SELECT차단은 에러를 던지지 않습니다.throws_ok를 쓰면 테스트가 오히려 실패합니다.is_empty에RETURNING *를 붙여서 결과로 확인하면 됩니다. -
plan()수와 실제 assertion 수를 맞추지 않는 것 —plan(3)을 선언하고 assertion을 2개만 쓰면 pgTAP이 "테스트가 부족하다"고 실패 처리합니다. 테스트를 추가하거나 삭제할 때마다plan()숫자도 함께 갱신해주어야 합니다.
마치며
RLS 정책은 선언만으로는 부족하고, 역할별 시나리오를 코드로 명세화한 테스트가 함께 있을 때 비로소 신뢰할 수 있습니다. 처음에는 "SQL로 테스트를 작성한다"는 게 낯설게 느껴질 수 있는데, 한 번 손에 익으면 오히려 가장 직관적인 보안 검증 방법이라는 게 느껴집니다.
지금 바로 시작해볼 수 있는 3단계:
- 헬퍼 설치부터 —
supabase db extension enable pgtap으로 pgTAP을 활성화하고,supabase/tests/000-setup.sql을 만들어 database.dev(PostgreSQL 전용 패키지 매니저)에서basejump/supabase_test_helpers를 설치해두시면tests.authenticate_as()같은 편의 함수를 바로 쓸 수 있습니다. - 가장 중요한 테이블 하나부터 — 전체 테이블을 한꺼번에 다루려 하면 부담이 큽니다. 개인 데이터가 담긴 테이블 하나를 골라 소유자·타인·anon 세 역할만 검증하는
001-테이블명-rls.sql을 작성해보시면 패턴이 금방 손에 익습니다. - CI에 붙이기 —
supabase db reset과supabase test db를 GitHub Actions 워크플로에 순서대로 추가해두시면 이후 RLS 정책을 수정할 때마다 자동으로 검증이 돌아갑니다. 한 번 설정해두면 "혹시 깨진 거 없을까" 하는 불안을 PR마다 해소해줍니다.
참고 자료
- Testing Overview | Supabase
- Advanced pgTAP Testing | Supabase
- pgTAP Extension | Supabase
- Row Level Security | Supabase
- RLS Performance and Best Practices | Supabase
- Automated testing with GitHub Actions | Supabase
- supabase-test-helpers | GitHub
- A Guide to testing on Supabase using pgTAP | Basejump Blog
- Testing RLS Policies in PostgreSQL with pgTAP | Blair Jordan (Medium)
- basejump/supabase_test_helpers | database.dev
- pgTAP 공식 사이트
- Postgres RLS Limitations and Alternatives | Bytebase