생산성

Supabase RLS 정책 설정 후 쿼리 응답 속도 저하 원인과 인덱스 해결법

Supabase RLS 정책 설정 후 쿼리 응답 속도 저하 원인과 인덱스 해결법

RLS를 켰더니 쿼리가 5배 느려졌어요. 보안을 강화했을 뿐인데 앱이 갑자기 버벅거리기 시작해요. Supabase를 쓰는 팀들이 가장 많이 겪는 퍼포먼스 병목 중 하나가 바로 이 문제예요. 원인은 생각보다 단순하지만, 모르면 며칠을 날릴 수 있어요.

Key Takeaways

  • RLS 정책을 활성화하면 PostgreSQL이 각 행마다 정책 표현식을 평가하며, 인덱스가 없을 경우 전체 테이블 스캔(Seq Scan)이 발생해 쿼리 속도가 수 배 느려진다.
  • auth.uid()user_id 같은 필터 컬럼에 인덱스가 없으면, 100만 행 테이블에서 단순 SELECT 하나가 수백 밀리초에서 수 초까지 늘어날 수 있다.
  • Supabase 공식 Database Advisors(Performance Advisor)는 RLS 비활성화 테이블과 인덱스 누락을 자동 감지해 경고를 띄운다.
  • EXPLAIN ANALYZE로 실행 계획을 확인하면 문제 지점을 정확히 잡을 수 있고, 적절한 인덱스 하나로 응답 시간을 10분의 1 이하로 줄인 사례가 다수 보고되고 있다.

RLS가 느려지는 이유, 맥락부터 짚어볼게요

Row Level Security(RLS)는 PostgreSQL이 제공하는 행 단위 접근 제어 기능이에요. Supabase는 이걸 기본 보안 레이어로 쓰고, 공개 테이블에 RLS가 꺼져 있으면 Database Advisors가 경고(0013_rls_disabled_in_public)를 띄울 정도로 중요하게 다뤄요.

그런데 RLS를 켜는 순간 PostgreSQL 내부에서 뭔가 달라져요. 쿼리를 실행할 때마다 해당 사용자가 각 행에 접근할 수 있는지를 정책 표현식으로 검사해요. 예를 들어 이런 정책이 있다고 해봐요.

CREATE POLICY "user can view own data"
ON posts
FOR SELECT
USING (user_id = auth.uid());

겉으로는 단순해 보이죠. 그런데 PostgreSQL 입장에선 posts 테이블을 훑으면서 모든 행에 대해 user_id = auth.uid()를 평가해야 해요. user_id 컬럼에 인덱스가 없으면? 테이블 전체를 처음부터 끝까지 읽어야 하는 Seq Scan이 발생해요.

Supabase가 RLS를 본격적으로 밀기 시작한 건 2023년 이후예요. 그 전엔 많은 팀이 service_role 키로 모든 걸 처리하거나, 아예 애플리케이션 레이어에서 필터링했거든요. 그러다 Supabase가 Auth와 RLS를 더 깊게 통합하면서 자연스럽게 RLS 사용이 늘었고, 덩달아 이 성능 문제도 수면 위로 올라왔어요. 2026년 현재 Supabase는 월간 활성 프로젝트 수가 100만 개를 넘어선 상황이고(Supabase 공식 블로그, 2025년 11월 기준), 그만큼 RLS 관련 퍼포먼스 이슈도 커뮤니티에서 가장 자주 올라오는 주제 중 하나예요.


원인 해부: 어디서 느려지는 건가요?

Seq Scan vs Index Scan: 실행 계획이 말해주는 것

느림의 핵심은 쿼리 실행 계획이에요. RLS 정책이 붙은 쿼리를 EXPLAIN ANALYZE로 뜯어보면 바로 보여요.

EXPLAIN ANALYZE
SELECT * FROM posts WHERE true;

결과에 Seq Scan on posts가 보이면 인덱스를 못 타고 있다는 신호예요. PostgreSQL은 RLS 정책의 USING 절을 쿼리의 WHERE 조건처럼 처리하는데, 그 조건에 쓰인 컬럼에 인덱스가 없으면 전체 스캔이에요.

10만 행 테이블에서 Seq Scan이 발생하면 보통 50200ms 정도 걸려요. 100만 행이면 500ms2초도 나와요. 반면 인덱스를 타면 같은 쿼리가 1~5ms로 줄어드는 경우가 일반적이에요. 거의 100배 차이죠.

auth.uid() 함수 호출 비용

두 번째 원인은 auth.uid() 자체예요. 이 함수는 매 쿼리 실행마다 현재 JWT에서 사용자 ID를 파싱해요. 보통 한 번 호출하는 비용은 작지만, RLS 정책이 여러 테이블에 걸쳐 있고 조인이 복잡할수록 호출 횟수가 늘어요. PostgreSQL 15 이후론 이 함수가 STABLE로 최적화되어 같은 트랜잭션 내에선 한 번만 평가되지만, 구버전 Supabase 인스턴스나 특정 설정에선 여전히 반복 호출이 발생해요.

정책 표현식의 복잡도

정책이 단순한 컬럼 비교가 아니라 서브쿼리를 포함할 때 문제가 심각해져요.

-- 이렇게 쓰면 위험해요
USING (
  user_id IN (
    SELECT user_id FROM team_members WHERE team_id = auth.uid()
  )
);

이런 정책은 쿼리마다 서브쿼리를 실행해요. team_members에 인덱스가 없으면 두 테이블을 동시에 풀스캔해요. 페이지 하나 로드하는 데 쿼리가 5개만 붙어도 서버가 버텨내기 힘들어요.

인덱스 전략 비교

인덱스 전략적용 상황예시 쿼리 속도 (100만 행 기준)주의사항
인덱스 없음기본 상태800ms ~ 2,000msRLS 켜는 순간 즉시 느려짐
단일 컬럼 인덱스 (user_id)대부분의 기본 RLS 정책3ms ~ 15ms가장 빠른 개선, 첫 번째로 적용
복합 인덱스 (user_id, created_at)정렬 포함 쿼리2ms ~ 10ms컬럼 순서가 성능에 영향
부분 인덱스 (Partial Index)특정 조건의 데이터만 접근1ms ~ 5msWHERE is_deleted = false 같은 조건과 조합 시 효과적
함수 기반 인덱스auth.uid() 직접 비교2ms ~ 8msPostgreSQL 버전에 따라 지원 범위 다름

실제로 이렇게 고쳐요

가장 먼저: 인덱스 추가

RLS 정책의 USING 절에서 쓰는 컬럼에 인덱스를 붙여요. 이게 전부예요. 정말 단순해요.

-- user_id 기반 정책이라면
CREATE INDEX idx_posts_user_id ON posts(user_id);

-- 정렬까지 포함한다면
CREATE INDEX idx_posts_user_id_created ON posts(user_id, created_at DESC);

Supabase의 Database Advisors(performance_advisor)는 이런 인덱스 누락을 자동으로 감지해서 대시보드에 표시해줘요. Supabase 공식 문서(database-advisors)에 따르면, 어드바이저가 추천하는 인덱스를 적용했을 때 평균 쿼리 시간이 절반 이하로 줄어드는 경우가 보고돼요.

security_definer 함수로 복잡한 정책 분리

서브쿼리가 들어간 복잡한 정책은 함수로 빼는 게 좋아요.

CREATE OR REPLACE FUNCTION is_team_member(team_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT EXISTS (
    SELECT 1 FROM team_members
    WHERE team_members.team_id = $1
    AND team_members.user_id = auth.uid()
  );
$$;

-- 정책에선 이렇게 간단하게
CREATE POLICY "team member access"
ON posts FOR SELECT
USING (is_team_member(team_id));

STABLE 표시를 붙이면 같은 트랜잭션 내에서 PostgreSQL이 함수 결과를 캐싱해요. 반복 호출 비용을 줄일 수 있어요.

EXPLAIN ANALYZE를 습관으로

코드 리뷰할 때 RLS 정책을 추가하면 반드시 EXPLAIN ANALYZE를 같이 붙여서 실행 계획을 확인하는 걸 팀 컨벤션으로 잡는 게 좋아요. Seq Scan이 보이면 인덱스부터 의심해요.


실무 적용: 어떤 순서로 접근할까요?

첫 번째 시나리오: 기존 프로젝트에 RLS를 처음 켰을 때

이미 데이터가 쌓인 테이블에 RLS를 켜면 즉시 느려져요. 가장 먼저 Supabase 대시보드 → Database → Advisors에서 퍼포먼스 경고를 확인하세요. 어드바이저가 인덱스 추천을 자동으로 만들어줘요. 추천대로 인덱스를 만들고 EXPLAIN ANALYZE로 검증하면 돼요.

두 번째 시나리오: 신규 프로젝트에서 처음부터 RLS를 설계할 때

스키마를 짤 때부터 RLS 정책에서 쓸 컬럼을 정하고, 그 컬럼에 인덱스를 미리 붙여요. 나중에 고치는 것보다 훨씬 쉬워요. 특히 user_id, org_id, tenant_id 같은 멀티테넌시 키 컬럼은 인덱스를 달아야 해요.

세 번째 시나리오: 복잡한 조인과 RLS가 얽힐 때

여러 테이블을 조인하는 쿼리에 각 테이블마다 RLS가 붙으면 실행 계획이 폭발적으로 복잡해져요. 이땐 뷰(View)나 security_definer 함수로 정책을 격리하는 방법을 써요. 복잡도를 한 곳에서 관리하면 디버깅도 쉬워지고 성능도 잡을 수 있어요.

지켜봐야 할 신호:

  • Supabase 대시보드의 Slow Query 로그에 같은 쿼리가 반복 등장하면 RLS 정책을 먼저 의심해요.
  • 신규 기능 출시 후 갑자기 API 응답이 느려졌다면 그 기능에 추가된 정책 표현식을 뜯어봐요.

마무리: 보안과 속도는 트레이드오프가 아니에요

한 줄로 요약하면 이래요. 정책이 필터링하는 컬럼에 인덱스가 없으면 느려진다.

  • RLS는 PostgreSQL의 WHERE 조건처럼 작동하고, 인덱스 없는 컬럼은 Seq Scan을 유발해요.
  • EXPLAIN ANALYZE로 Seq Scan을 확인하고, 해당 컬럼에 인덱스를 추가하면 대부분 해결돼요.
  • 복잡한 서브쿼리 정책은 STABLE + SECURITY DEFINER 함수로 분리해요.
  • Supabase Database Advisors를 주기적으로 확인하면 문제가 생기기 전에 잡을 수 있어요.

앞으로 6~12개월 안에 Supabase는 RLS 정책 분석 툴을 더 강화할 가능성이 높아요. 이미 Performance Advisor가 자동 인덱스 추천을 제공하고 있고, 쿼리 단위 RLS 비용 측정 기능도 로드맵에 올라와 있거든요. 도구가 더 좋아지더라도, 인덱스 설계를 제대로 이해하는 개발자가 훨씬 빠르게 문제를 잡아낼 수 있어요.

지금 여러분 프로젝트의 EXPLAIN ANALYZE 결과에 Seq Scan이 몇 개나 있나요?

참고자료

  1. Performance and Security Advisors | Supabase Docs

Photo by Microsoft Copilot on Unsplash