수학 과외를 하면서 만든 학습 도구의 RLS 정책을 좁히던 중이었어요. 필요 이상으로 열려 있는 것 같아서 정리하려던 참이었죠. 그런데 좁은 정책 하나를 지우는 마이그레이션을 적용하고 나서도 접근이 줄지 않았고, 라이브 DB를 들여다보고서야 이유를 알았습니다. 제 마이그레이션 파일 어디에도 없는 정책이 실제 서버에서 돌고 있었거든요. 결국 계획을 버린 게 아니라, 회수 범위를 넓혀 한 번 더 적용했어요.
RLS(Row Level Security): 테이블 단위가 아니라 행 단위로 접근을 통제하는 PostgreSQL 기능이에요. “이 사용자가 이 행을 SELECT할 수 있나”를 정책(policy)에 적힌 조건식으로 판단합니다.
마이그레이션: DB 구조 변경을 SQL 파일로 남겨 순서대로 적용하는 방식. 저장소의 이 파일들이 “우리가 의도한 DB 상태”의 정본이에요.
막힌 지점 — 저장소에 없는 정책이 라이브에 있었다
계획은 단순했어요. 리포트 테이블(reports)에 걸린 정책 중 하나를 지워서 접근 범위를 좁히는 것. 실제로 마이그레이션 파일도 만들어 뒀습니다.
supabase/migrations/20260816000001_drop_reports_select_parent.sql
이 파일은 8월 17일에 라이브에 적용했어요. 그런데 적용 뒤에도 학부모 계정이 reports를 여전히 통째로 읽을 수 있었습니다. 그래서 라이브 DB에 지금 실제로 걸려 있는 정책을 전부 뽑아 AI에게 붙여 줬어요. PostgreSQL에는 현재 정책을 조회하는 시스템 뷰가 있거든요.
select schemaname, tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
order by tablename, policyname;
pg_policies: 지금 이 DB에 실제로 걸려 있는 RLS 정책 목록을 보여주는 PostgreSQL 시스템 뷰예요. qual 열이 “이 행을 보여줄지” 판단하는 조건식입니다.
돌려보니 이런 목록이 나왔습니다. 제가 대화창에 붙여 넣은 결과 앞부분(reports·students 줄)을 그대로 옮깁니다.
| tablename | policyname | cmd | roles | qual |
| --------- | ----------------------------- | ------ | --------------- | ------------------------------------------------------------------------- |
| reports | reports_all_authenticated | ALL | {public} | (auth.role() = 'authenticated'::text) |
| reports | reports_all_super_admin | ALL | {authenticated} | (current_user_role() = 'super_admin'::text) |
| reports | reports_all_teacher | ALL | {authenticated} | (current_user_role() = ANY (ARRAY['teacher'::text, 'super_admin'::text])) |
| students | Students can view own profile | SELECT | {authenticated} | (auth.uid() = user_id) |
| students | students_all_authenticated | ALL | {public} | (auth.role() = 'authenticated'::text) |
| students | students_all_super_admin | ALL | {authenticated} | (current_user_role() = 'super_admin'::text) |
| students | students_all_teacher | ALL | {authenticated} | (current_user_role() = ANY (ARRAY['teacher'::text, 'super_admin'::text])) |
| students | students_select_parent | SELECT | {public} | (parent_id = ( SELECT auth.uid() AS uid)) |
_all_authenticated로 끝나는 두 줄에서 멈췄습니다. reports_all_authenticated와 students_all_authenticated는 제 마이그레이션 어디에도 없는 정책이었어요. 둘 다 적용 대상은 {public}(역할 제한 없음)입니다. 게다가 조건식이 auth.role() = 'authenticated' — 로그인만 했으면 통과입니다. 역할 검사도, 소유권 검사도 없어요.
왜 이게 계획을 무너뜨렸나
제가 지운 정책(reports_select_parent — 학부모에게 자기 자녀의 리포트만 열어 주는 정책)은 좁은 쪽이었습니다. 같은 reports 테이블에 훨씬 넓은 정책, reports_all_authenticated가 따로 걸려 있었던 거예요.
RLS는 여러 정책이 걸려 있으면 하나라도 통과하면 허용합니다(OR 결합). 그러니 좁은 정책 하나를 지워봐야, 넓은 정책이 그대로 통과시켜 줍니다. 제 계획은 아무것도 안 바꾸는 계획이었어요. 저장소 SQL만 읽고 세웠기 때문에 그 사실을 몰랐던 겁니다.
이게 소름 돋았던 이유는, 코드 리뷰로는 영영 안 보인다는 점이에요. 리뷰는 저장소에 있는 걸 읽는 작업인데, 이 정책은 저장소에 없으니까요. 없는 걸 읽을 수는 없습니다.
어쩌다 생겼을까 — 여기는 추정입니다
정직하게 말하면 확실하지 않습니다. 관리 콘솔에서 손으로 만든 것으로 보이지만, 언제 누가 했는지 기록이 없어요. 그래서 이 글에서도 추정까지만 씁니다.
정황은 하나 기록에 남아 있어요. 전날 좁은 정책을 지우려고 제가 터미널에서 supabase db push를 돌릴 때였습니다. 먼저 마이그레이션 18개를 migration repair로 “적용됨”이라고 표시해야 했고, 그러고 나서도 옛 마이그레이션 하나를 다시 넣으려다 이렇게 멈췄어요.
Applying migration 20251230_fix_security_definer_view.sql...
ERROR: duplicate key value violates unique constraint "schema_migrations_pkey" (SQLSTATE 23505)
Key (version)=(20251230) already exists.
라이브 DB가 “무엇을 적용했는지” 적어 두는 이력표(schema_migrations)부터 저장소와 어긋나 있었던 거예요. 버전 표시를 몇 번 더 고친 뒤에야 삭제 마이그레이션이 들어갔고, 이튿날 커밋한 기준선 문서에도 «원격 히스토리가 오늘까지 비어 있었음»이라고 적혔습니다. 저장소를 거치지 않은 변경이 있었다는 정황이지만, 문제의 두 정책을 누가 언제 만들었는지까지 말해 주지는 않아요.
다만 원인을 특정 못 해도 대응은 됩니다. 어떻게 생겼는지 몰라도, 지금 뭐가 걸려 있는지는 확인할 수 있으니까요. 저는 원인 추적 대신 현재 상태를 기준선으로 잡는 쪽을 택했습니다.
해결 — 세 단계
첫째, 라이브 덤프를 저장소에 기준선으로 박제했습니다. 작업을 감독하는 오케스트레이터 AI의 지시에 따라, AI가 public 스키마의 정책 전체를 파일로 떠서 커밋했어요.
supabase/pg_policies_baseline.md
이제 다음에 대조하면 저장소에 없는 정책이 또 생겼는지 바로 드러납니다.
기준선은 뜨자마자 값을 했어요. 전체 정책을 대조하니 같은 모양의 구멍(적용 대상 public, 조건식 auth.role()='authenticated')이 다른 테이블에도 남아 있었습니다.
- 학생 데이터 5개(
student_patterns·student_profile_history·student_progress·student_strengths·student_weaknesses) — 로그인만 하면 모든 학생의 약점·강점·진도·패턴·프로필 이력을 읽고 수정·삭제할 수 있는 상태 users—authenticated_select_all, 조건식이 그냥true- 설정 테이블 5개(시간표·과제·커리큘럼 등) — 로그인한 누구나 수정·삭제 가능
가장 급한 학생 데이터 5개는 제 승인을 받아 AI가 같은 날 회수 마이그레이션을 적용하고 실측까지 마쳤어요. users는 앱이 자기 역할을 읽는 데 기대고 있어서, 지우기만 하면 역할 판정이 깨집니다. 그래서 자기 행만 읽는 대체 정책을 먼저 설계하는 쪽으로 미뤘습니다.
둘째, 드리프트분을 마이그레이션으로 회수했습니다. 8월 17일에 좁은 정책을 지운 drop_reports_select_parent에 이어, 8월 18일에 두 _all_authenticated 정책까지 지우는 drop_all_authenticated_rls_holes로 회수 범위를 넓혔어요. 콘솔에서 손으로 지우면 그 삭제 자체가 또 기록에 안 남아요. 그래서 DROP POLICY를 마이그레이션 파일로 작성해 적용했습니다. 이력이 남는 방식으로요.
셋째, 판단 순서를 바꿨습니다. 이제 RLS 관련 작업은 pg_policies부터 뽑고 시작합니다.

정리 — 무엇을 정본으로 볼 것인가
| 저장소 마이그레이션 SQL | 라이브 pg_policies | |
|---|---|---|
| 성격 | “이렇게 되어야 한다”(의도) | “지금 이렇게 되어 있다”(사실) |
| 신뢰 구간 | 모든 변경이 마이그레이션으로만 갔을 때 | 항상 |
| 갈라지는 순간 | 콘솔 수동 생성 · 급한 임시 조치 · 이력 없는 손질 | — |
| 확인 비용 | 파일 읽기(쉬움) | 쿼리 한 번(몇 초) |
| 갈렸을 때 | 계획의 전제가 무효가 됨 | 이쪽 기준으로 다시 세움 |
한마디로, RLS 판단의 정본은 저장소 SQL이 아니라 라이브 정책 덤프입니다. 코드는 읽기 편하지만 그건 어디까지나 의도예요. pg_policies 한 번 돌리는 몇 초가, 전제가 틀린 계획을 실행하는 것보다 훨씬 쌉니다.
혹시 RLS를 쓰신다면 오늘 한 번만 해보세요. 위 쿼리를 돌려서 나온 목록과 저장소 마이그레이션을 나란히 놓고 비교하는 겁니다. 저는 이걸 좁은 정책을 지운 뒤에야 돌려서, 회수 범위를 넓힌 마이그레이션을 한 번 더 적용해야 했어요.
이 회수에는 뒷이야기가 있어요. 좁은 정책에 이어 넓은 정책까지 걷어 내자 학부모의 reports 가시성이 0이 됐고, 그 테이블을 정책식 안에서 참조하던 코멘트 정책이 조용히 막혔습니다. 그게 앞선 편에서 다룬 정책이 다른 테이블을 참조해 조용히 막히던 문제예요. 두 사고는 같은 뿌리였습니다. RLS는 눈에 잘 안 띄는 곳에서 일하고, 틀려도 소리를 내지 않는다는 것이요.