데이터가 통째로 사라진 밤, 원인은 키 하나였다
RLS를 켠 뒤 SELECT가 에러 한 번 없이 조용히 빈 배열을 돌려주고, anon 키로 쏜 요청이 왜 권한 밖으로 처리되는지 모른 채 이틀을 보냈다면 이 글이 해답이 된다. 정책이 쿼리를 침묵시키는 세 가지 실패 패턴, service_role과 anon 키의 역할 혼동, 그리고 로컬에서 정책이 통과하는지 실제로 검증하는 디버깅 절차를 사례와 함께 차례대로 정리했다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
배포 다음 날 아침, 운영 API가 빈 배열만 돌려줬다. 에러 로그는 깨끗했다. HTTP 200, 쿼리 성공, 응답 본문은 []. 데이터가 사라진 게 아니라 '존재하지 않는 것'으로 처리된 것이었다. 원인은 결국 JWT 키 하나였다. 그 밤에 추가된 정책은 하나였고, 의도는 "익명 접근에 읽기만 허용한다"는 단순한 것이었다.
200 OK가 거짓말하는 순간
PostgreSQL의 Row Level Security는 쿼리를 거부하지 않는다. 권한 밖의 행은 그냥 결과에서 빠질 뿐이다. 내부적으로 정책은 실행 계획에 AND 조건으로 붙고, 걸러진 결과가 0행이면 클라이언트는 정상 응답을 받는다. 오류 전달이 아니라 필터링이라서, 이 실패는 로그를 전혀 남기지 않는다.
그래서 RLS를 켠 첫날 가장 흔한 실수는 "에러가 날 것"이라는 예측이다. enable row level security만 걸고 정책을 만들지 않으면, 그 테이블은 테이블 소유자 외에는 아무도 읽지 못한다. select는 성공하고 결과만 비어 있다. 서버 로그는 온통 200이고 SQL 에러는 없으니, 원인을 찾는 데 이틀이 걸려도 이상하지 않다.
빈 배열이 쏟아지는 타이밍은 대체로 셋 중 하나다.
- 정책이 아예 없거나 테이블 일부에만 붙은 경우
- 정책 조건이 어떤 행과도 한 번 성립하지 않는 경우
- RLS를 우회하는 키와 우회하지 않는 키를 섞어 쓴 경우
증상은 같지만 접근법이 갈린다. 첫 번째는 스키마 점검, 두 번째는 조건 점검, 세 번째는 키 관리 점검이 필요하다.
정책은 조인에도 따로따로 적용된다는 점을 빼먹으면 또 헷갈린다. 쿼리에 들어온 테이블마다 정책이 각각 붙고, 한쪽이 빈 결과를 내면 조인 전체가 줄어든다. 메인 테이블은 멀쩡한데 관계 테이블 정책만 누락된 상태라면, 데이터가 반 토막 나서 보인다.
익명 키와 관리 키를 바꿔 끼운 밤
세 번째가 제일 교활하다. Supabase는 JWT의 role 클레임으로 정책 대상을 가른다. anon 키로 만든 토큰은 role=anon, service_role 키는 role=service_role. 문제는 service_role이 RLS를 통째로 우회한다는 사실이다. 정책이 좋든 나쁘든 무시하고 전 행을 내려준다.
흔한 시나리오를 하나 보자. 서버 코드의 조회가 전부 service_role로 나가고, 테이블 정책은 using (auth.uid() = user_id)라고 짜여 있다. 이 정책은 사실상 안 쓰인다. service_role은 정책 평가 없이 모든 행을 돌려주니까. 보안은 그때부터 뚫려 있었고, 개발자는 "정책이 잘 돌아간다"고 착각한다. 나중에 앱에서 anon 키로 직접 조회하는 화면을 붙이는 순간 전부 빈 배열로 바뀐다. anon은 로그인 세션이 없어서 auth.uid()가 null이고, null과의 비교는 항상 false니까.
방향을 거꾸로 꼰 경우도 있다. 익명 조회를 허용하려고 to anon 정책을 넣었는데 서버가 service_role로 호출한다면, 정책은 무시된 채 전 행이 열린다. 이번엔 빈 배열이 아니라 과잉 노출이다. 같은 엔드포인트가 어떤 키를 거치느냐에 따라 텅 비는 지점과 전부 열리는 지점이 갈라진다. "어떤 키로 쿼리가 나가고, 그 키의 role이 어느 정책의 대상인가"를 한 표로 정리해 두지 않으면 다음 밤도 다시 날아간다.
키는 발급 위치도 다르다. anon과 service_role은 대시보드에서 각각 발급되고 시크릿도 다르다. 환경변수를 하나 잘못 매핑하면 운영에서 관리 키를 호출마다 흘리는 셈이다. 여기서 스스로에게 물어볼 질문이 있다. 정말 관리 권한이 필요한 호출이 몇 개인가. 대부분 한두 개다. 그걸 제외한 모든 쿼리는 anon이나 authenticated로 두는 게 기본값이어야 한다. 관리 키는 배포 시점의 마이그레이션, 스케줄러, 서버 간 배치에만 남기고 그 외에는 발급조차 꺼두는 편이 안전하다.
로컬에서 통과한 정책이 운영에서 죽는 이유
정책이 존재해도 죽는 유형은 조건의 타입 불일치다. auth.jwt()->>'sub'는 텍스트다. user_id 컬럼이 bigint면 문자열과 숫자의 비교는 무조건 false로 처리되고, 그 결과도 에러가 아니다. "로컬에서는 나오는데?" 하는 순간 운영은 이미 며칠째 빈 배열이다.
조건에 쓰인 값이 토큰에 없는 경우도 같은 증상을 낸다. 조직 단위 접근을 org_id = (auth.jwt()->>'org_id') 같은 식으로 짰는데, 로그인 토큰에 org_id 클레임을 넣는 로직을 깜빡했다면? JWT는 존재하지만 그 필드는 없으니 비교가 전부 null이 된다. 정책 조건이 의존하는 클레임을 토큰에서 확인하는 습관이 없으면, 이 경우는 설명 자체가 안 된다.
로컬 검증을 했다는 사람도 이 함정에 빠진다. 어드민 계정으로 psql을 켜서 select를 때리면 슈퍼유저는 RLS를 우회한다. 그 결과가 '정책을 통과한 결과'처럼 보이는 것뿐이다. 정책이 쿼리를 실제로 통과시키는지 보려면 호출의 관점을 흉내 내야 한다.
유사한 우회 경로가 하나 더 있다. security definer로 만든 함수는 함수 소유자의 권한으로 실행된다. 소유자가 테이블 소유자라면 그 안의 쿼리는 RLS를 거치지 않는다. 익명 API가 이 함수를 경유한다면, 정책은 멀쩡해 보이는데 데이터는 계속 열리는 상태가 된다. RPC를 쓰고 있다면 함수마다 실행 역할을 확인해야 한다.
SELECT 한 줄로 정책을 심문하는 방법
정책 평가가 읽는 값은 세션에 들어 있다. request.jwt.claims가 토큰을 담고, auth.uid()는 그 안의 sub를 꺼낸다. 로컬 psql에서 이렇게 바꿔 치고 쿼리를 돌리면 된다.
set local role authenticated; set local request.jwt.claims = '{"sub":"test-user-id","role":"authenticated","email":"a@b.c"}'; select * from profiles where id = 'test-user-id';
role을 authenticated로 바꾸면 로그인 세션으로, anon으로 바꾸면 익명으로 쿼리하는 것과 같다. 같은 select를 두 번 돌려 행 수가 갈리는지 보는 게 첫 검증이다. 여기서 걸리는 사람이 많다. sub는 텍스트로 들어가는데 정책이 정수 컬럼과 비교하도록 짜였다면, 정확히 이 시점에 드러난다.
두 번째 검증은 explain analyze다. 정책이 붙으면 실행 계획에 Filter 줄이 생긴다. rows=0이면서 Filter: ((uid)::text = ...) 항목이 보이면 데이터가 없는 게 아니라 정책이 다 걸러낸 것이다. "데이터가 사라졌다"의 실체는 "어떤 조건이 모두 걸렀는가"다.
세 번째는 키부터 뒤지는 습관이다. 빈 배열이 보이면 호출부를 열어 어떤 키로 쿼리가 나갔는지 확인한다. anon이었으면 정책 대상이 anon인지, authenticated였으면 로그인 흐름에서 세션이 실제로 붙었는지 따진다. JWT는 base64url로 디코딩하면 role이 바로 보인다. 의심이 갈 때마다 까보는 게 낫고, 꽤 자주 '로그인된 상태'라고 믿은 호출이 실제로는 세션 없는 익명이었다.
마지막으로 정책이 인덱스를 타는지도 확인한다. auth.uid() 같은 조건은 컬럼 그대로 비교되는 게 아니라서, 인덱스를 안 탈 때가 많다. 운영 테이블이라면 정책 조건이 인덱스와 맞물리는지 실행 계획으로 확인하고, 안 맞으면 조건 컬럼에 인덱스를 걸어둘지 정책을 단순화할지 고르면 된다.
정책이 무사할 때까지 붙이는 최소 루틴
정책이 하나 늘 때마다 anon과 authenticated로 같은 쿼리를 각각 돌려보는 루틴이면 대부분의 밤샘을 막는다. 이 과정은 5분이면 끝난다. CI에서 정책 SQL을 적용할 때 그 뒤에 위 테스트를 붙여 두면, 정책이 잘못 들어온 순간부터 빌드가 빨간불을 켠다. 운영이라면 쿼리 로그를 켜 두고, 빈 배열이 확인되는 순간 그 지점의 SQL을 그대로 복사해 호출 흐름을 재현해 보는 것도 빠르다.
pg_policies 뷰도 한 번쯤 읽어 둘 만하다. 테이블마다 어떤 정책이 붙었는지 한눈에 나온다. 정책이 5개를 넘는 테이블이 보이면, 대개 조건을 하나로 합쳐야 할 시점이다. 다만 이 뷰는 정책이 존재하는지만 알려줄 뿐, 어떤 키가 그 정책을 타는지는 알려주지 않는다. 키는 코드 리뷰에서 "이 쿼리에 관리 키가 정말 필요한가"라는 질문으로만 잡힌다.
이틀을 날린 것은 RLS라는 기능에 대한 무지가 아니라, "빈 배열 = 데이터가 없음"이라는 가정이었다. 에러가 없다는 것은 권한을 통과했다는 뜻이 아니다. 정책을 걸었으면 그 다음부터는 모든 select를 의심부터 하자.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.
당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
이전 글
토큰이 로그인을 기억한다는 착각
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.