당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
개발 서버에서 데이터가 안 보인다. 네트워크 탭을 열어도 200 OK. 콘솔에도 에러는 찍히지 않는다. 그런데 빈 배열만 줄기차게 돌아온다. PostgreSQL의 Row Level Security를 켠 상태에서 가장 흔히 겪는 초기 증상이다. RLS는 실패할 때 소리를 내지 않는다. 그냥 결과 집합에서 행을 조용히 제거한다. 당신이 기대한 37건 중 30건이 권한 검사를 통과하지 못했더라도 Postgres는 "30 rows filtered" 같은 경고를 남기지 않는다. 그냥 7건만 반환하거나, 전부 걸러졌으면 빈 배열이다. 여기서부터 추리가 시작된다.
SQL 에디터는 거짓말쟁이다
Supabase 대시보드의 SQL 에디터에서 SELECT * FROM posts를 실행하면 모든 행이 보인다. 아직 RLS를 안 켰구나 싶어 테이블 옆에 있는 토글을 올린다. 다시 쿼리를 돌렸더니 여전히 전부 보인다. 이쯤에서 "RLS가 제대로 작동하네, 내 정책이 허용하는 거겠지"라고 착각하기 쉽다. 사실은 전혀 다르다.
Supabase SQL 에디터는 기본적으로 service_role 키를 사용해 연결한다. 이 키는 RLS를 완전히 우회한다. 당신이 아무리 정교한 USING 절을 짜놨어도 service_role 세션에서는 Postgres가 ALTER TABLE ... FORCE ROW LEVEL SECURITY를 걸지 않은 한 RLS 검사를 건너뛴다. 다시 말해, SQL 에디터에서 성공한 쿼리가 앱에서도 성공한다는 보장은 제로에 가깝다. 앱은 보통 anon 키나 인증된 사용자의 JWT로 요청을 보내고, 이때부터 실제 정책이 적용된다. 대시보드와 앱은 같은 테이블을 보고 있지만 서로 다른 권한으로 바라보는 셈이다.
혼동이 생기는 또 다른 경로는 Supabase 클라이언트를 초기화할 때다. createClient(supabaseUrl, supabaseKey)에 service_role 키를 넣어두면 서버 컴포넌트, API 라우트, 서버 액션 할 것 없이 모든 쿼리가 RLS를 우회한다. 로컬에서 잘 돌아가던 게 프로덕션에서만 빈 배열을 뱉는다면, 환경변수에 실수로 service_role 키가 들어가 있지 않은지 먼저 점검하는 편이 빠르다. getSecret()이나 환경변수 인젝션 로직에 콘솔 한 줄 찍어서 키 앞자리(eyJh인지 eyJz인지)를 확인해볼 일이다.
auth.uid()가 null인 덫
RLS 정책에서 가장 흔히 쓰는 표현식은 auth.uid() = user_id다. "현재 인증된 사용자의 ID가 이 행의 user_id와 같을 때만 보여준다"는 의도다. 그런데 auth.uid()는 인증되지 않은 요청에서는 null을 반환한다. null = user_id는 null이 아니라 false도 아닌, SQL 특유의 삼치 논리에 따라 unknown이 되고, 이 행은 결과에서 제외된다. 아무 에러도 없다.
여기서 간과하기 쉬운 건 클라이언트에서 로그인이 끝났어도 서버에서는 인증이 안 돼 있을 수 있다는 점이다. Supabase의 세션은 쿠키로 관리되며, Next.js의 서버 컴포넌트에서 createServerClient를 쓸 때는 매 요청마다 쿠키를 읽어 세션을 복원하는 로직이 들어가야 한다. 이게 빠지면 getUser()는 null을 반환하는데, RLS 쪽으로 전달되는 JWT 클레임도 당연히 비어 있다. 결과적으로 auth.uid()가 모든 행에 걸쳐 null로 평가되고 모든 행이 필터링된다.
실제로 무슨 일이 일어나고 있는지 확인하려면 Supabase 대시보드의 Authentication > Users에서 JWT를 발급받아 직접 curl로 쳐보는 게 가장 싸다. 혹은 RLS 정책에 잠시 USING (true)를 넣어보고 데이터가 보이는지 확인한 다음 원래 정책으로 되돌리는 식으로 범위를 좁혀나간다. 문제를 정책 자체로 오인하게 하는 또 다른 요소는 로컬과 스테이징에서 멀쩡했던 정책이 특정 테이블에서만 실패하는 패턴이다. 이런 경우 대부분 public 스키마가 아니라 커스텀 스키마에 테이블을 만들었기 때문이다. Supabase는 public 스키마에 대해서만 auth 역할에 USAGE 권한을 부여한다. 다른 스키마에 만든 테이블에는 RLS 정책이 있어도 auth 역할이 스키마 자체에 접근할 수 없어서 Permission Denied가 뜨는데, 이 에러는 클라이언트 라이브러리를 거치면 또 빈 배열로 마스킹된다.
WITH CHECK를 깜빡했을 때의 정숙
RLS는 SELECT뿐 아니라 INSERT, UPDATE, DELETE에도 적용된다. 그런데 많은 사람이 USING만 정의하고 WITH CHECK는 생략한다. USING은 "보이는 행"을 결정하고, WITH CHECK는 "쓰일 수 있는 행"을 결정한다. INSERT에는 USING이 의미 없다. 새로 생기는 행이니까 기존에 "보이던" 행이 없기 때문이다. INSERT 정책에 USING만 써두면 아무 조건도 걸리지 않은 것과 같아서 WITH CHECK 없이도 INSERT가 성공하는 것처럼 보일 수 있다. 실제로는 auth.uid()가 null인 상태에서도 INSERT가 되거나, 반대로 INSERT 자체가 정책 없음으로 처리되어 거부될 수 있다. UPDATE도 비슷하다. USING 절만 통과하고 WITH CHECK가 없으면 변경 후의 행이 정책을 위반해도 그냥 기록된다.
혼자 개발할 때는 모든 요청이 service_role로 날아가서 눈치채지 못한다. 팀원이 로그인한 상태에서 특정 버튼이 동작하지 않는다고 제보할 때까지 모른다. 정책을 점검할 땐 각 오퍼레이션별로 USING과 WITH CHECK가 모두 정의되어 있는지, 그리고 그 조건이 실제로 의도한 대로 동작하는지 확인해야 한다. 이때 유용한 Postgres 확장으로 row_security_active와 row_security_policies 정보를 노출하는 pg_row_security가 있지만, Supabase 관리형 환경에서는 대부분 직접 설치할 수 없으니 직접 진단 쿼리를 준비해두는 편이 낫다.
SELECT
schemaname,
tablename,
policyname,
cmd,
qual,
with_check
FROM pg_policies
WHERE tablename = 'posts';
이 쿼리 하나로 해당 테이블에 어떤 오퍼레이션에 어떤 조건이 걸려 있는지 한눈에 들어온다. qual은 USING 표현식, with_check는 WITH CHECK 표현식이며, null이면 그 조건이 정의되지 않았다는 뜻이다. 모든 INSERT 정책에 with_check가 null로 찍힌다면 당장 고쳐야 한다.
정책이 충돌할 때
테이블에 여러 정책을 걸어두면 Postgres는 OR 연산으로 평가한다. 한 정책이 "작성자만 볼 수 있다"이고 다른 정책이 "관리자는 모든 행을 볼 수 있다"이면, 둘 중 하나만 통과해도 행이 반환된다. 여기까지는 의도와 맞을 수 있다. 그런데 "나만 볼 수 있다" + "비공개 포스트는 아무도 못 본다"를 둘 다 걸어두면? 작성자임에도 비공개 포스트가 보이지 않기를 기대하겠지만, OR 연산 때문에 첫 번째 정책이 통과해버려서 열람된다. 의도한 건 AND 조건인데 Postgres의 RLS 정책 평가는 AND를 지원하지 않는다. 복합 조건은 하나의 정책 안에 USING 절로 다 밀어넣어야 한다.
극단적인 패턴 하나 더. FOR ALL 대신 FOR SELECT, FOR INSERT를 따로따로 만들다 보면 서로 다른 조건을 가진 정책들이 쌓이는데, 운영 6개월쯤 지나서 어느 것 하나를 수정할 때 다른 오퍼레이션에 미칠 영향을 예측할 수 없게 된다. 정책이 세 개만 넘어가도 누구도 자신 있게 건드리지 못하는 영역이 된다. FOR ALL 한두 개로 압축해두고 조건을 CASE WHEN이나 서브쿼리로 풀어내는 게 장기적으로 버티기 훨씬 낫다.
슈퍼유저 환상과 실전 점검
RLS는 테이블 소유자도 우회하지 않는다. 테이블을 만든 사람은 기본적으로 RLS 무시 권한을 갖는다. Supabase에서는 마이그레이션을 postgres 역할로 실행하거나 대시보드에서 직접 테이블을 만들었을 때 소유자가 postgres로 설정된다. 이 상태에서 anon, authenticated 역할에 대한 정책을 아무리 잘 짜놔도, 실수로 서버 코드에서 supabase-admin 클라이언트나 raw pg 연결로 쿼리하면 RLS는 꺼져 있는 것과 같다.
실전에서 써먹을 수 있는 점검 루틴을 하나 제안한다. 배포 전에 다음 네 가지를 순서대로 확인한다. 첫째, GRANT USAGE ON SCHEMA public TO anon, authenticated;가 마이그레이션에 포함되어 있는지. 의외로 로컬에서는 Supabase CLI가 알아서 해주지만 CI에서 초기화할 때 빠지는 경우가 있다. 둘째, pg_policies 뷰를 조회해서 모든 INSERT 정책에 WITH CHECK가 null이 아닌지 확인한다. 셋째, auth.uid()를 사용하는 모든 정책에 대해 인증되지 않은 요청일 때의 거동을 curl로 검증한다. 넷째, 로컬에서 환경변수 SUPABASE_SERVICE_ROLE_KEY 대신 NEXT_PUBLIC_SUPABASE_ANON_KEY로 클라이언트를 초기화한 뒤 실제 사용자처럼 로그인해보는 시나리오 테스트를 한 번 돌려본다. 이 네 가지만 통과해도 운영에서 RLS가 조용히 데이터를 숨기는 사태는 대부분 막을 수 있다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유
Next.js API Route를 Vercel Functions 크론 잡으로 운영할 때 외부 API 호출로 인한 타임아웃을 피하려면, 동시에 여러 건을 처리하려는 욕심을 버려야 한다. 최대 1건 처리, 멱등키 기반 재시도, 중복 실행 방지, 로그 기반 관측 전략을 소개한다.
이전 글
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
다음 글
데이터는 반드시 낡는다. 그리고 그 사실을 받아들일 때 진짜 설계가 시작된다.
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.