앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
Supabase Row Level Security를 켜면 클라이언트에서는 데이터가 안 보이는데 Supabase SQL 에디터에서 같은 쿼리를 날리면 잘 보인다. 이 현상을 처음 마주한 개발자는 대개 RLS 정책 자체에 문제가 있다고 생각한다. 절반은 맞고 절반은 틀렸다.
문제의 근원은 Supabase가 노출하는 두 가지 API 키, 즉 anon key와 service_role key가 완전히 다른 권한을 갖고 있다는 점이다. anon key는 클라이언트 환경에서 사용된다. RLS가 활성화된 테이블을 anon key로 조회하면 PostgreSQL은 해당 사용자(정확히는 auth.uid()가 반환하는 UUID)에게 부여된 RLS 정책을 기준으로 행을 필터링한다. 반면 SQL 에디터는 service_role key를 사용한다. 이 키는 bypasses RLS 플래그를 기본적으로 갖고 있어 모든 정책을 우회한다. 따라서 "SQL 에디터에선 되는데 앱에선 안 된다"는 신호는 anon key가 맞고 SQL 에디터가 틀린 게 아니라, RLS 정책이 anon key 사용자에 대해 의도대로 동작하지 않고 있다는 뜻이다.
RLS 정책을 작성할 때 가장 많이 실수하는 지점은 auth.uid()가 NULL을 반환하는 상황을 간과하는 것이다. Supabase Auth를 통해 로그인한 사용자가 요청을 보내면 Supabase가 자동으로 JWT를 파싱해서 request.jwt.claims에 사용자 정보를 주입한다. auth.uid()는 이 값을 읽는다. 하지만 두 가지 경우에 auth.uid()는 NULL이 된다. 첫째, 사용자가 로그인하지 않은 상태(익명 사용자)다. 이때 RLS 정책이 auth.uid() = user_id 같은 조건을 사용하면 모든 행이 필터링되어 빈 결과가 반환된다. 둘째, 서버 측에서 supabaseAdmin(service_role) 클라이언트로 조회할 때다. 이 클라이언트는 auth.uid()를 제공하지 않으므로, 정책 안에서 auth.uid()를 직접 참조하면 예외가 발생한다. bypasses RLS가 설정되어 있어도 정책 평가 자체가 실패하면 쿼리가 실패한다.
-- 잘못된 예: 서비스 롤 클라이언트가 이 테이블을 조회하면 에러 CREATE POLICY "users can see own data" ON profiles FOR SELECT USING (auth.uid() = id); -- 개선: 서비스 롤을 고려한 정책 CREATE POLICY "users can see own data" ON profiles FOR SELECT USING (auth.role() = 'service_role' OR auth.uid() = id);
정책 충돌도 조용히 데이터를 삼키는 원인이다. PostgreSQL은 동일한 테이블에 여러 정책이 적용될 때 OR로 결합하지 않고, 명령어 타입(SELECT/INSERT/UPDATE/DELETE)별로 따로 평가한다. 같은 명령어에 대해 여러 정책이 있으면 AND로 결합한다. 즉 SELECT 정책 두 개가 각각 tenant_id = current_setting('app.tenant_id')와 is_public = true 조건을 갖고 있으면, 두 조건을 모두 만족하는 행만 반환된다. 의도가 "자신의 테넌트 데이터거나 공개 데이터거나"였다면 두 정책을 하나로 합쳐 USING (tenant_id = current_setting('app.tenant_id') OR is_public = true)로 만들어야 한다.
INSERT 정책의 WITH CHECK 누락도 흔한 실수다. FOR INSERT 정책을 만들 때 USING 절만 작성하면 PostgreSQL이 조용히 받아들이지만, 정책이 의도한 대로 작동하지 않는다. INSERT의 RLS 검사는 WITH CHECK로 수행된다. USING은 SELECT와 DELETE에만 적용된다. WITH CHECK를 생략하면 USING 절이 대체하지 않고, 사실상 정책이 없는 것과 같아져 모든 INSERT가 허용되거나(정책 자체가 NULL일 때), 모든 INSERT가 차단된다(다른 정책과 AND 결합될 때). 어느 쪽이든 개발자 의도와 다르다.
로컬 개발 환경에서 RLS를 테스트하는 방법도 자주 잘못 사용된다. Supabase local development는 supabase start로 실행하면 로컬 PostgreSQL 인스턴스를 띄우고, 여기서 anon key와 service_role key를 발급한다. 문제는 로컬 데이터베이스에 auth.users 테이블에 시드 데이터를 넣지 않고 RLS 정책만 작성하면, auth.uid()가 항상 NULL이라는 점이다. 클라이언트에서 로그인 플로우를 거쳐 JWT를 얻은 후에야 auth.uid()가 정상 값을 반환한다. 따라서 RLS 정책만 단독으로 테스트하려면 set_config를 통해 request.jwt.claims를 수동으로 설정하거나, Supabase Dashboard의 SQL 에디터에서 auth.uid()를 직접 설정할 수 없다는 걸 인지하고 있어야 한다.
실전에서 추천하는 점검 순서는 다음과 같다. 테스트할 쿼리를 supabase-js 클라이언트로 실행하기 전에, psql에서 RLS를 우회하지 않은 상태로 같은 쿼리를 날려본다. Supabase local DB에 직접 연결해서 set local request.jwt.claims to '{"sub": "<test-user-id>"}';를 실행한 후 SELECT * FROM target_table;을 해보면, 앱에서 보이는 결과와 동일한 결과가 나와야 한다. 여기서 차이가 발생하면 정책이 예상과 다르게 평가되고 있다는 증거다.
프로덕션에서 RLS 정책을 변경할 때는 반드시 트랜잭션 안에서 변경 전후를 비교해야 한다. BEGIN;으로 트랜잭션을 열고, 기존 정책을 드롭하거나 새 정책을 추가한 후, SELECT current_setting('role')로 현재 롤을 anon으로 설정하고 실제 조회를 해본다. 문제가 없으면 COMMIT, 문제가 있으면 ROLLBACK하면 된다. Supabase Dashboard의 SQL 에디터도 트랜잭션을 지원하므로, 이 방법을 쓰면 프로덕션에서 정책을 잘못 배포해서 모든 사용자에게 데이터가 노출되는 사고를 막을 수 있다.
RLS 정책에 auth.email() 또는 auth.jwt()를 사용할 때도 주의할 점이 있다. auth.jwt()->>'user_metadata' 같은 접근은 JWT 클레임 구조에 의존하는데, Supabase Auth의 JWT는 버전 업데이트에 따라 클레임 구조가 달라진 적이 있다. user_metadata가 최상위에 있던 시절도 있었고, app_metadata와 합쳐진 적도 있었다. JWT 구조에 강하게 결합된 정책은 Supabase Auth 업데이트 후 조용히 깨질 수 있다. raw_user_meta_data 같은 데이터베이스 컬럼을 직접 참조하는 편이 더 안정적이다.
정책이 복잡해질수록 pg_policy 카탈로그를 직접 조회해서 현재 적용된 정책 목록을 확인하는 습관을 들이는 게 좋다. SELECT * FROM pg_policies WHERE tablename = 'target_table';로 한 번에 모든 정책을 볼 수 있고, permissive와 restrictive 구분도 여기서 확인할 수 있다. Supabase는 기본적으로 permissive 정책을 사용하지만, restrictive 정책과 섞이면 예상치 못한 필터링이 발생할 수 있다. 이때도 AND 결합 규칙이 적용되므로, 정책이 많아질수록 하나의 테이블에 여러 RLS 정책을 두기보다 하나의 정책에 여러 조건을 OR로 묶는 쪽이 실수를 줄인다.
마지막으로, RLS가 SELECT 결과에는 잘 적용되지만 INSERT나 UPDATE에서 실패하는 경우를 의심해볼 만한 지점이 하나 더 있다. 바로 트리거다. RLS 정책이 행을 통과시켰더라도, 트리거 함수 안에서 수행되는 쿼리는 별도의 RLS 정책 평가를 받는다. 트리거 함수가 SECURITY DEFINER로 실행되는지, SECURITY INVOKER로 실행되는지에 따라 접근 권한이 달라진다. SECURITY DEFINER는 함수 소유자의 권한으로 실행되므로 RLS를 우회할 수 있고, SECURITY INVOKER는 호출자의 권한으로 실행되므로 RLS가 그대로 적용된다. 트리거 내부 쿼리가 갑자기 실패하기 시작한다면, 트리거 함수의 보안 속성을 먼저 확인하는 게 빠르다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유
Next.js API Route를 Vercel Functions 크론 잡으로 운영할 때 외부 API 호출로 인한 타임아웃을 피하려면, 동시에 여러 건을 처리하려는 욕심을 버려야 한다. 최대 1건 처리, 멱등키 기반 재시도, 중복 실행 방지, 로그 기반 관측 전략을 소개한다.
이전 글
시크릿은 매번 같은 길로 도망친다
다음 글
INFO, WARN, ERROR만으로는 부족하다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.