한 줄 요약: RLS 정책의 조건식 안에서 다른 테이블을 SELECT하고 있다면, 그 조회는 호출자 권한으로 평가됩니다. 그 사용자에게 그 테이블이 0행이면 조건식은 항상 거짓이 되고, 읽기는 에러 없이 0건, 쓰기는 42501로 갈립니다. 코드를 grep해도 안 나오는 이유는 문제가 정책식 안에 있기 때문이에요.
수학 과외를 하면서 만든 학습 도구에서 코멘트 기능이 조용히 죽어 있는 걸 검수 AI의 지적으로 뒤늦게 알게 됐습니다. 에러도 안 나고, 화면도 멀쩡하고, 그냥 아무것도 안 보이는 상태였어요. 원인을 찾고 정리한 점검 순서를 공유합니다.
RLS(Row Level Security): 행 단위로 접근을 통제하는 PostgreSQL 기능. “이 사용자가 이 행을 볼 수 있나”를 정책의 조건식(USING 절)으로 판단합니다.
42501: PostgreSQL의 권한 부족 오류 코드. RLS 정책에 막혀 INSERT가 거부될 때 이게 떨어집니다.
점검 1 — 정책식 안에서 다른 테이블을 참조하는 정책을 전부 뽑았나
제 문제가 여기서 시작했어요. 코멘트 테이블(report_comments)의 정책이, 그 코멘트가 달린 리포트(reports)를 다시 조회해서 소유권을 확인하는 구조였습니다. 대략 이런 모양이죠.
-- report_comments 정책 (문제가 된 구조)
USING (
exists (
select 1 from reports r -- ← 정책식 안에서 다른 테이블 SELECT
where r.id = report_comments.report_id
)
)
실패 사례: 저는 이걸 애플리케이션 코드에서 찾으려 했습니다. 그런데 못 찾아요. .from('reports') 같은 호출부를 전부 훑어도 이 참조는 안 나옵니다. 제가 짠 코드가 아니라 정책식 안에 있으니까요. 정책 목록을 뽑아서 qual 열(조건식 본문)을 직접 읽어야 보입니다.
그래서 다음 작업을 “정책식 내부 참조 전수 점검”으로 정했어요. 이튿날 AI가 라이브 DB의 정책 66개를 전부 뽑아 분류해 보니, 정책식 안에서 다른 테이블을 참조하는 정책이 29개였습니다. AI가 상태 기록에 남긴 교훈도 같았어요. 앱 코드에서 .from()을 grep하는 방식으로는 정책식 안의 참조를 못 잡는다는 것.
점검 2 — 그 SELECT가 “누구 권한”으로 평가되는지 아는가
핵심입니다. 정책식 안의 SELECT도 호출자의 RLS를 그대로 탑니다. 관리자 권한으로 슬쩍 봐주지 않아요.
그래서 이렇게 됩니다. 그 사용자가 reports를 한 행도 못 보는 상태라면, 정책식 안의 exists(...)도 항상 0행입니다. 조건식이 “그 리포트가 존재하는가”를 묻는데 볼 수 있는 리포트가 하나도 없으니 답은 언제나 거짓이죠.
실패 사례: 제 경우가 정확히 그랬습니다. 학부모 계정의 reports 가시성이 0행이었어요. 정책은 문법도 논리도 멀쩡했습니다. 다만 참이 될 수 없는 조건이었어요.
점검 3 — 증상이 읽기와 쓰기에서 갈린다는 걸 아는가
여기가 제일 오래 헤맨 부분이에요. 같은 원인인데 증상이 두 갈래입니다.
SELECT: 에러가 안 납니다. 그냥 0행이 돌아와요. 화면엔 “코멘트가 없습니다”라고 뜹니다. 정상 동작과 구분이 안 돼요.INSERT:42501권한 오류가 납니다.
실패 사례: 읽기만 보고 있었으면 저는 아직도 몰랐을 겁니다. “아직 코멘트가 없나 보다” 하고 넘어갔을 테니까요. 읽기가 무증상이라는 게 이 함정의 진짜 위험이에요. 의심되면 쓰기를 직접 시도해보세요. 읽기는 침묵하지만 쓰기는 소리를 냅니다.
점검 4 — 정책식의 바깥 테이블 참조를 끊었나
처방은 그 SELECT가 호출자 RLS를 타지 않게 만드는 것입니다. PostgreSQL에는 함수를 정의자 권한으로 실행시키는 옵션이 있어요.
-- 소유권 확인을 SECURITY DEFINER 함수로 분리 (실제 마이그레이션에서 발췌)
create or replace function public.is_parent_of_report(p_report_id bigint)
returns boolean
language sql
security definer -- ← 호출자 RLS를 타지 않는다
stable
set search_path = public, pg_temp
as $$
select exists (
select 1
from public.reports r
join public.students s on s.id = r.student_id
where r.id = p_report_id
and s.parent_id = auth.uid() -- 호출자가 그 리포트 학생의 학부모인가
);
$$;
SECURITY DEFINER: 함수를 호출한 사람이 아니라 함수를 정의한 사람의 권한으로 실행하는 옵션. 정책이 판단하려고 잠깐 들여다보는 조회를, 사용자 가시성과 분리할 때 씁니다.
정책식이 테이블을 직접 조회하는 대신 이 함수를 부르게 바꾸면, 사용자 가시성 때문에 조건식이 무너지는 일이 사라집니다. 즉 판단에 필요한 조회와 사용자에게 보여줄 조회를 분리하는 거예요.
점검 5 — 고친 뒤 세 가지를 직접 실행해 확인했나
“고쳤다”는 말은 고쳐진 걸 확인했다여야 합니다. AI에게 수리를 맡긴 뒤 세 가지를 실제로 돌려보게 했어요.
INSERT가 되는가 — 막혔던 등록이 42501 없이 들어가는지SELECT가 되는가 — 0행이던 조회에 코멘트가 다시 보이는지- 남의 것은 안 보이는가 — 다른 사용자 리포트의 코멘트는 여전히 안 보이는지
실패 사례를 덧붙이면, 3번이 제일 중요합니다. 1·2번만 확인하면 “권한을 그냥 열어버린” 수리와 구별이 안 되거든요. 막혔던 게 뚫렸는지와 막혀야 할 게 여전히 막혔는지는 반드시 같이 봐야 합니다.
실제로 제가 학부모 계정으로 로그인해 두고, AI가 수리 전후를 재서 상태 기록에 남긴 숫자는 이랬습니다.
| 확인 항목 | 수리 전 | 수리 후 |
|---|---|---|
코멘트 쓰기(INSERT) | 42501 | 201 |
코멘트 읽기(SELECT) | 0행(무증상) | 1행 |
| 다른 학부모 쪽 조회 | — | 0행 유지 |
세 번째 줄이 위의 3번 확인이에요. 막혔던 게 뚫리는 동안 남의 것은 여전히 0행으로 남아 있어야 “권한을 열어버린 수리”가 아니라는 게 증명됩니다. 수리 마이그레이션에는 AI가 되돌릴 롤백 SQL도 미리 짝지어 써 뒀어요.
이 사고가 시작된 지점
마지막으로 정직하게 덧붙일 게 있어요. 이 결함은 제 작업 순서 때문에 생겼습니다. RLS를 좁히는 작업을 하면서 부모 쪽(reports) 정책을 먼저 좁혔고, 자식 쪽(report_comments) 정책을 reports에서 떼어 내는 수리는 뒤로 밀렸어요. 원래 계획은 그 반대 순서였는데, 적용 순서가 뒤집힌 거예요. 그 사이 기간 동안 기능이 조용히 죽어 있었던 겁니다.
게다가 부모 쪽을 좁힌 게 한 번이 아니었어요. 8월 17일에 학부모용 좁은 정책(reports_select_parent)을 지웠고, 이튿날에는 라이브에서 따로 찾아낸 넓은 정책(reports_all_authenticated)까지 회수했습니다. 그 회수 이야기는 코드에 없는 규칙이 실제 서버에서 돌고 있었던 날에 따로 적었어요. 두 번째 회수까지 끝나자 학부모의 reports 가시성이 0이 됐고, 코멘트 정책의 exists(...)는 항상 거짓이 됐습니다. 이 파손을 먼저 짚은 건 제가 아니라 검수를 맡은 오케스트레이터 AI였어요. 읽기가 조용히 0건이라 작업하던 쪽은 아무도 눈치채지 못했거든요.

RLS를 좁힐 때는 순서가 곧 안전이더라고요. 참조가 걸린 구조에서는 참조하는 쪽(정책식 안에서 다른 테이블을 읽는 쪽)을 먼저 떼어 내고, 참조당하는 쪽은 그다음에 좁혀야 중간에 구멍이 안 생깁니다.