Supabase RLS 정책 적용 후 쿼리 속도 10배 느려짐: 원인 분석과 인덱스 해결법

쿼리 하나가 200ms에서 2초로 늘어났어요. RLS 정책 하나 추가했을 뿐인데요.
착각이 아니에요. Supabase RLS(Row Level Security) 정책을 적용한 뒤 쿼리 속도가 10배 느려지는 현상은 수많은 개발자들이 실제로 겪는 문제예요. 프로덕션에서 갑자기 이 상황을 마주치면 당황하기 십상이죠. 그런데 원인을 알면 해결은 생각보다 간단해요. 인덱스 설계가 핵심이에요.
핵심 요약
- Supabase RLS 정책은 모든 쿼리에 WHERE 조건을 자동 삽입하는 방식으로 작동해요. 해당 컬럼에 인덱스가 없으면 풀 테이블 스캔이 발생해 쿼리 속도가 10배 이상 느려질 수 있어요.
auth.uid()를 RLS 조건으로 쓸 때user_id컬럼에 B-tree 인덱스가 없으면 PostgreSQL은 매 요청마다 전체 테이블을 순회해요.EXPLAIN ANALYZE명령어로 쿼리 실행 계획을 확인하면 Seq Scan(순차 탐색) vs. Index Scan(인덱스 탐색) 여부를 바로 파악할 수 있어요.- RLS 정책의 조건 컬럼에 복합 인덱스(Composite Index)를 적용하면 대부분의 경우 쿼리 속도가 원래 수준으로 돌아오거나 그 이상으로 개선돼요.
RLS가 정확히 뭘 하는 건가요?
Supabase는 PostgreSQL의 RLS 기능을 기반으로 데이터 접근 제어를 구현해요. 쉽게 말하면, 사용자가 자신의 데이터만 볼 수 있도록 데이터베이스 레벨에서 자동으로 필터를 거는 거예요.
예를 들어 이런 정책을 만들었다고 해볼게요.
CREATE POLICY "users_own_data" ON messages
FOR SELECT
USING (user_id = auth.uid());
이 정책이 활성화되는 순간, 누군가 SELECT * FROM messages를 실행하면 PostgreSQL 내부에서 자동으로 이렇게 변환돼요.
SELECT * FROM messages WHERE user_id = auth.uid();
사용자는 WHERE 조건 없이 쿼리를 날렸지만, 데이터베이스는 모르게 조건을 붙이는 거예요. Supabase 공식 문서에 따르면 이 정책 평가는 쿼리 최적화 단계에서 자동으로 일어나요.
여기서 문제가 시작돼요. user_id 컬럼에 인덱스가 없으면 PostgreSQL은 해당 값을 찾기 위해 테이블 전체를 읽어야 해요. 행이 1만 개면 1만 번 비교, 100만 개면 100만 번 비교예요. 이게 바로 Seq Scan, 풀 테이블 스캔이에요.
RLS 없이는 이 문제가 드러나지 않아요. RLS 없이 쿼리를 날리면 애플리케이션 레벨에서 이미 특정 user_id를 명시하는 경우가 많고, 그 경우엔 개발자가 인덱스를 챙기거든요. 반면 RLS는 정책이 자동으로 조건을 추가하니까, “이 컬럼에도 인덱스가 필요하다"는 걸 놓치기 쉬워요.
10배 느려지는 원인, 세 가지로 정리
1. auth.uid() 호출 비용과 Seq Scan의 조합
auth.uid()는 현재 인증된 사용자의 UUID를 반환하는 함수예요. 이 함수 자체의 오버헤드는 미미해요. 진짜 문제는 이 값으로 필터링할 때 인덱스가 없다는 점이에요.
Lobehub의 Supabase 트러블슈팅 가이드에서도 이 패턴을 대표적인 성능 저하 원인으로 꼽아요. 특히 messages, posts, orders처럼 데이터가 빠르게 쌓이는 테이블에서 치명적이에요. 테이블이 작을 땐 괜찮아 보이다가, 수십만 행이 넘어가는 순간 갑자기 속도가 곤두박질쳐요.
2. 정책 조건이 복잡할수록 실행 비용 증가
RLS 정책을 여러 개 겹치거나, OR 조건으로 연결하면 상황이 더 나빠져요.
-- 이 정책은 생각보다 비싸요
CREATE POLICY "team_access" ON documents
FOR SELECT
USING (
owner_id = auth.uid()
OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = auth.uid()
)
);
여기서 서브쿼리(team_members 테이블 조회)가 매 행 평가마다 실행될 수 있어요. team_members.user_id에 인덱스가 없으면, 행 100만 개짜리 테이블에서는 서브쿼리도 100만 번 돌아가는 셈이에요.
3. JOIN과 RLS의 만남
RLS는 참조되는 모든 테이블에 각각 적용돼요. messages 테이블과 attachments 테이블을 JOIN할 때 두 테이블에 모두 RLS가 걸려 있다면, 각각의 정책이 독립적으로 평가돼요. 인덱스가 없는 상태라면 스캔이 두 배로 늘어나는 셈이에요.
인덱스로 해결하기: 방법별 비교
핵심 해결책 비교
| 상황 | 적용할 인덱스 | 예상 개선 효과 | 주의사항 |
|---|---|---|---|
단순 user_id = auth.uid() | B-tree 단일 인덱스 | 5~15배 개선 | 쓰기 속도 소폭 저하 |
user_id + 날짜 범위 필터 | 복합 인덱스 (user_id, created_at) | 10~20배 개선 | 컬럼 순서가 중요함 |
| 텍스트 검색 포함 RLS | GIN 인덱스 | 검색에 한해 3~8배 개선 | 일반 필터에는 비효율 |
| 멀티 테넌트 구조 | 복합 인덱스 (tenant_id, user_id) | 15배 이상 개선 가능 | 설계 단계 고려 필수 |
B-tree 단일 인덱스 (가장 빠른 해결책)
-- RLS 정책이 user_id를 쓴다면, 이 한 줄로 많은 걸 해결해요
CREATE INDEX idx_messages_user_id ON messages(user_id);
EXPLAIN ANALYZE로 확인하면 Seq Scan 대신 Index Scan이 뜨는 걸 볼 수 있어요. 이것만으로도 대부분의 케이스에서 10배 이상 차이가 나요.
복합 인덱스 (더 정밀한 해결책)
RLS 정책 조건에 추가 필터가 자주 붙는다면 복합 인덱스가 더 효과적이에요.
-- "내 메시지 중 최근 것" 같은 쿼리에 최적
CREATE INDEX idx_messages_user_created ON messages(user_id, created_at DESC);
복합 인덱스에서 컬럼 순서는 왼쪽부터 적용돼요. user_id가 먼저 와야 RLS 필터가 인덱스를 타고, 그다음 created_at으로 정렬까지 인덱스 안에서 해결돼요. 순서를 바꾸면 RLS 필터에서 인덱스를 못 써요.
실전 진단: EXPLAIN ANALYZE 쓰는 법
EXPLAIN ANALYZE
SELECT * FROM messages
WHERE created_at > NOW() - INTERVAL '7 days';
결과에서 봐야 할 것:
Seq Scan→ 인덱스 없음, 개선 필요Index Scan→ 인덱스 타고 있음, 양호Bitmap Heap Scan→ 중간 상태, 데이터 규모에 따라 괜찮을 수도 있음cost=뒤 숫자가 크면 클수록 비싼 쿼리
한 가지 주의할 점이 있어요. RLS가 적용된 상태에서는 Supabase 대시보드의 SQL Editor에서 실행해야 실제 RLS 조건이 반영된 실행 계획을 볼 수 있어요. service_role로 실행하면 RLS를 우회하기 때문에 실제 성능과 달라 보여요. 이걸 모르고 “인덱스 달았는데 왜 똑같지?” 하는 경우가 꽤 많아요.
지금 당장 해야 할 것들
즉시 확인 (이번 주):
EXPLAIN ANALYZE로 RLS가 걸린 주요 테이블의 쿼리 실행 계획 확인- RLS 정책 WHERE 조건에 쓰이는 컬럼에 인덱스 존재 여부 점검
- Supabase 대시보드 → Database → Indexes 탭에서 현재 인덱스 목록 확인
중기 대응 (1~2개월):
- 멀티 테넌트 구조라면
tenant_id + user_id복합 인덱스 설계를 스키마 단계에서 반영 - OR 조건이 들어간 복잡한 RLS 정책은 서브쿼리 대신 별도 테이블이나 함수로 분리 검토
- Supabase의
pg_stat_statements확장으로 느린 쿼리 주기적으로 모니터링
참고로, Supabase 로드맵에는 성능 어드바이저 기능이 예정돼 있어요. 인덱스 부재를 자동으로 감지해주는 방향으로 발전할 거예요. 그래도 지금은 개발자가 직접 챙겨야 해요.
마무리: 설계 단계에서 막는 게 최선이에요
- RLS 정책의 조건 컬럼 = 반드시 인덱스가 있어야 하는 컬럼
auth.uid()쓰면user_id인덱스는 필수- 복잡한 OR 조건 RLS는 서브쿼리 없이 설계할 방법을 먼저 찾기
EXPLAIN ANALYZE는 RLS 디버깅의 시작이자 끝
원인이 명확하면 해결도 명확해요. 데이터베이스가 느려졌을 때 제일 먼저 의심해야 할 건 네트워크도 서버도 아니에요. 쿼리 실행 계획이에요.
지금 프로젝트에 RLS를 쓰고 있다면, 오늘 EXPLAIN ANALYZE 한 번만 돌려보세요. 결과가 어떻게 나왔나요?
참고자료
Photo by Microsoft Copilot on Unsplash


