두 개의 독립적인 관문이 있고, anon SELECT 는 둘 다 통과해야 합니다. RLS 는 그 중 하나일 뿐이에요.
판단 순서
anon key로 SELECT 요청
↓
① GRANT SELECT ON table TO anon ← 테이블 레벨 권한 (SQL 표준)
↓ 없으면 → permission denied 에러
② RLS enabled?
├─ NO → 통과, 전체 row 반환
└─ YES → ③ 이 SELECT 를 허용하는 정책이 있나?
├─ 없음 → 0건 반환 (에러 아님!)
└─ 있음 → 정책의 USING 조건 만족하는 row 만 반환
진리표
| RLS |
anon 에 SELECT GRANT |
SELECT 정책 |
결과 |
| OFF |
❌ |
(무관) |
permission denied for table ... 에러 |
| OFF |
✅ |
(무관) |
✅ 전체 row 반환 |
| ON |
❌ |
(무관) |
permission denied 에러 |
| ON |
✅ |
없음 |
⚠️ 빈 배열 [] (에러 없음, row 만 0건) |
| ON |
✅ |
USING (true) 같은 관대한 정책 |
✅ 전체 row 반환 |
| ON |
✅ |
USING (user_id = auth.uid()) 같은 조건 정책 |
정책 조건 통과한 row 만 반환 |
핵심 함정: RLS on + 정책 없음 = 0건 (에러 없음)
이게 지난번 getResidence 404 사건의 원인이었어요. 앱 입장에서 보면:
const { data, error } = await supabase.from('decozip_residence')...
// error = null ← 에러 아님!
// data = null ← 그냥 row 가 없다고 나옴
앱은 "row 가 존재하지 않는다"로 해석 → notFound() → 404. DB 에는 row 가 실제로 있어도 그렇게 됩니다.
이래서 "RLS 는 fail-closed(기본 거부)" 라고 표현해요. SQL 의 기본 조회 결과와 자연스럽게 통합돼서 티가 안 남.
Supabase 특수 사항
Supabase 는 프로젝트 생성 시 몇 가지 자동 설정을 해두어서 헷갈릴 수 있어요:
- 자동 GRANT: 새 테이블 생성 시
anon, authenticated, service_role 에 CRUD 를 자동으로 GRANT. 그래서 ① 관문은 자동 통과하는 경우가 대부분.
- 자동 RLS ON: Dashboard 로 테이블 생성하면 RLS 가 켜진 상태로 시작하는 게 요즘 기본값 (2023 이후). SQL 로
CREATE TABLE 하면 꺼진 상태.
- 경고 배너: RLS 를 껐거나 정책 없이 켜두면 Dashboard 에 "Insecure" 배지가 표시됨.
지난번 문제도 이 조합에서 발생: ALTER TABLE ... ADD COLUMN 하거나 Dashboard 에서 컬럼 편집하면 종종 RLS 스위치가 자동으로 켜져요. 그 순간부터 정책 없으니 anon 조회 결과가 다 0건이 되고, 앱은 404.
결정적 차이 요약
- GRANT = "이 role 이 이 테이블을 건드릴 수 있는가?" — 테이블 단위
- RLS = "이 role 이 이 특정 row 를 볼/쓸 수 있는가?" — row 단위
GRANT 는 SQL 표준의 20년 넘은 개념, RLS 는 PostgreSQL 이 나중에 얹은 세밀한 필터. 둘은 각기 다른 층에서 동작하며 AND 조건으로 결합됩니다.
실전 검증 명령
-- ① 관문 (GRANT 상태)
SELECT has_table_privilege('anon', 'public.decozip_residence', 'SELECT') AS anon_can_select;
-- ② + ③ 관문 (RLS 상태 + 정책 개수)
SELECT
c.relrowsecurity AS rls_on,
(SELECT COUNT(*) FROM pg_policies p
WHERE p.schemaname='public' AND p.tablename='decozip_residence'
AND p.cmd IN ('SELECT','ALL')
AND ('anon' = ANY(p.roles) OR 'public' = ANY(p.roles))
) AS anon_select_policies
FROM pg_class c
WHERE c.oid = 'public.decozip_residence'::regclass;
anon_can_select = true + rls_on = false → 전 row 조회 OK
anon_can_select = true + rls_on = true + anon_select_policies = 0 → 0건 (지난 404 상황)
anon_can_select = true + rls_on = true + anon_select_policies ≥ 1 → 정책 조건대로
anon_can_select = false → 에러 (GRANT 부터 문제)