Next.js 14 App Router에서 Supabase RLS 정책 무한 재귀 오류 디버깅하기

배포 직전에 콘솔에 infinite recursion detected in policy for relation 오류가 떴다면? 멘붕이죠. Next.js 14 App Router와 Supabase RLS가 얽힌 이 오류는 2026년 현재도 풀스택 개발자들이 가장 자주 마주치는 장애 중 하나예요. Supabase GitHub Discussions 기준으로 2025년 한 해만 해당 키워드로 올라온 이슈가 340건을 넘겼거든요. 단순 설정 실수로 보이지만, App Router의 서버 컴포넌트 캐싱 구조와 얽히면 재현 자체가 힘들 정도로 복잡해져요.
이 글에서는 원인 구조부터 실제 디버깅 순서, 패턴별 해결책까지 순서대로 짚어볼게요.
핵심 요약
- Supabase RLS 무한 재귀는 주로 정책 내에서 동일 테이블을 다시 참조할 때 발생하며,
organization_members같은 멤버십 테이블에서 가장 빈번하다.- Next.js 14 App Router의 서버 컴포넌트 캐싱(
unstable_cache,fetch캐시)은 RLS 오류를 숨기거나 지연 노출시키는 2차 원인이 된다.- Supabase 공식 문서는 정책 내
auth.uid()를 쓰되 동일 테이블 서브쿼리는 Security Definer 함수로 우회하는 방식을 권장한다.service_role키를 서버 사이드에서만 쓰고 클라이언트에 절대 노출하지 않는 것이, 재귀 회피 이상으로 중요한 보안 원칙이다.- 로컬에서 재현이 안 된다면 Next.js 14의 스태틱 캐시가 오염된 쿼리 결과를 반환하고 있을 가능성을 먼저 확인해야 한다.
무한 재귀, 왜 생기는 걸까요?
RLS(Row Level Security)는 쿼리를 실행하는 사용자가 어떤 행에 접근할 수 있는지를 DB 레벨에서 필터링하는 기능이에요. Supabase가 PostgreSQL 기반이라 이 기능을 그대로 쓰죠.
문제는 정책 정의 안에서 같은 테이블을 다시 조회할 때 터져요. 대표적인 시나리오가 이거예요.
-- organization_members 테이블에 RLS 정책 추가
CREATE POLICY "members can see their org"
ON organization_members
FOR SELECT
USING (
user_id = auth.uid()
OR
org_id IN (
SELECT org_id FROM organization_members WHERE user_id = auth.uid()
)
);
이 정책은 organization_members를 조회하는 시점에 RLS를 적용하려는데, 그 평가 내부에서 또 organization_members를 조회해요. PostgreSQL은 이 루프를 감지하고 ERROR: infinite recursion detected in policy for relation "organization_members"를 내뱉죠.
얼핏 보면 당연한 실수 같지만, 팀 규모가 커지거나 복잡한 권한 모델을 구현할수록 자연스럽게 이 패턴으로 흘러가요. 그리고 실전 디버깅에서 가장 헷갈리는 건, 이 오류가 개발 환경에서는 안 나타나다가 프로덕션에서만 터지는 경우예요.
App Router 캐싱 때문이에요.
App Router 캐시가 오류를 어떻게 감추나요?
Next.js 14의 fetch 캐시와 unstable_cache는 서버 컴포넌트에서 동일한 쿼리 결과를 재사용해요. Supabase 공식 문서에 따르면, RLS 정책을 변경한 뒤 캐시를 비우지 않으면 이전 정책 기준으로 필터링된 데이터가 계속 반환돼요. 새 정책이 무한 재귀를 유발하더라도, 캐시 히트 구간에서는 오류가 뜨지 않고 그냥 지나쳐버리는 거예요.
실제 흐름은 이래요.
- 로컬에서 정책 수정 →
npm run dev재시작 → 정상 작동 (캐시 없음) - 프로덕션 배포 → 기존 캐시 살아있음 → 일부 요청은 캐시 반환
- 캐시 만료 후 첫 실제 DB 조회 → 재귀 오류 → 500
이 타이밍 문제를 모르면 원인 찾는 데 몇 시간을 날릴 수 있어요.
캐시 오염 확인 방법
Route Handler에 force-dynamic을 붙이고 테스트해보세요.
// app/api/test-rls/route.ts
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
export const dynamic = 'force-dynamic' // 캐시 완전 해제
export async function GET() {
const supabase = createServerComponentClient({ cookies })
const { data, error } = await supabase.from('organization_members').select('*')
return Response.json({ data, error })
}
force-dynamic 상태에서도 같은 오류가 나면, 캐시가 아니라 정책 자체가 문제예요.
패턴별 해결 방법 비교
무한 재귀를 피하는 방법은 크게 세 가지예요. 상황마다 맞는 선택이 달라요.
| 방법 | 원리 | 구현 복잡도 | 보안 수준 | 적합한 케이스 |
|---|---|---|---|---|
| Security Definer 함수 | 정책 내 서브쿼리를 별도 함수로 분리, RLS 평가 스킵 | 중간 | 높음 | 복잡한 멤버십/권한 모델 |
| 정책 조건 단순화 | 재귀를 유발하는 서브쿼리 제거 | 낮음 | 높음 | 단순한 user_id 기반 접근 |
| service_role 우회 | 서버 사이드에서 RLS 자체를 건너뜀 | 낮음 | 낮음 (주의 필요) | 관리자 전용 서버 작업 |
Security Definer 함수 방식이 가장 권장돼요. SECURITY DEFINER로 생성된 함수는 함수 소유자 권한으로 실행되고, 그 안에서는 RLS가 적용되지 않아요. 정책이 이 함수를 호출하면 재귀 루프를 끊을 수 있죠.
-- 먼저 함수 생성
CREATE OR REPLACE FUNCTION get_my_org_ids()
RETURNS SETOF uuid
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT org_id FROM organization_members WHERE user_id = auth.uid();
$$;
-- 정책에서 서브쿼리 대신 함수 호출
CREATE POLICY "members can see their org"
ON organization_members
FOR SELECT
USING (
user_id = auth.uid()
OR org_id IN (SELECT get_my_org_ids())
);
service_role 키 우회는 보안 리스크가 있어요. 클라이언트 사이드 코드에 절대 노출하면 안 되고, server-only 패키지와 함께 서버 컴포넌트 또는 Route Handler 안에서만 써야 해요.
실전 디버깅 순서: 이 흐름대로 따라가세요
시나리오 1 — 로컬에서 재현이 안 될 때
force-dynamic으로 캐시를 끊고, supabase.auth.getSession() 결과가 서버와 클라이언트에서 같은지 확인하세요. Next.js 미들웨어에서 세션을 갱신하지 않으면 서버 컴포넌트가 만료된 토큰으로 Supabase에 요청을 보내요. 이 경우 RLS 평가 자체가 anon 권한으로 진행되어 전혀 다른 정책 경로를 타기도 해요.
시나리오 2 — 특정 테이블에서만 오류가 날 때
Supabase 대시보드 → SQL Editor에서 직접 EXPLAIN 해보세요.
EXPLAIN SELECT * FROM organization_members WHERE user_id = auth.uid();
실행 계획에서 동일 테이블 재참조가 보이면 Security Definer 함수로 분리하면 돼요.
시나리오 3 — 배포 후 간헐적으로 발생할 때
revalidatePath 또는 revalidateTag를 정책 변경 배포 시점에 강제 호출해주세요. RLS 변경은 DB 스키마 변경이라서 Next.js가 자동으로 캐시를 무효화하지 않아요. 실전에서 가장 놓치기 쉬운 포인트예요.
정리: 지금 당장 확인할 것들
- RLS 정책에서 자기 자신 테이블을 서브쿼리로 참조하면 PostgreSQL이 무한 루프를 감지해요
- Next.js 14 App Router 캐시가 오류를 덮어버릴 수 있어서,
force-dynamic으로 캐시부터 끊고 테스트해야 해요 - 해결책 중 가장 안전한 건 Security Definer 함수로 서브쿼리를 분리하는 방법이에요
service_role키는 서버 사이드 전용이고, 클라이언트에 노출되면 RLS 전체가 무의미해져요
참고로, Supabase 2026년 초 로드맵에 “Policy Inspector” 기능이 언급됐어요. 대시보드에서 RLS 정책을 시각적으로 분석하는 도구가 생길 가능성이 높아요. 그 전까지는 SQL Editor에서 직접 pg_policies 뷰를 쿼리해서 정책 조건을 눈으로 확인하는 게 가장 빠른 방법이에요.
마지막으로 하나만요. 지금 프로젝트의 RLS 정책을 직접 SQL로 꺼내서 읽어본 적 있으세요? 자기도 모르게 재귀 구조를 만들어뒀을 수 있어요. SELECT * FROM pg_policies WHERE tablename = 'your_table'; 딱 한 줄이면 확인할 수 있어요.
참고자료
- [트러블슈팅] Supabase Auth를 활용한 다양한 환경에서의 세션 유지
- 새로운 개발환경 조합 Next.js + Supaba… - Inflearn | Community Q&A
- Supabase Docs | Troubleshooting | Next.js 13/14 stale data when changing RLS or table data.
Photo by Balázs Kétyi on Unsplash


