Supabase 데이터가 안 보여요 (RLS의 배신)
1. "데이터가 없는데요? (사실 있습니다)"
Supabase 대시보드에서 보면 users 테이블에 데이터가 100개가 꽉 차 있습니다.
그런데 Flutter 앱에서 select()를 하면 빈 깡통([])만 돌아옵니다.
에러도 안 납니다. 그냥 데이터가 "없는 척" 합니다.
final data = await supabase.from('users').select();
print(data); // []
"아니 에러라도 뱉던가!" 이 소름 돋는 현상의 범인은 RLS (Row Level Security)입니다. Supabase는 기본적으로 "허락받지 않은 자에게는 데이터가 안 보인다"는 철학을 가집니다.
2. 원리 이해 - 묵시적 거부 (Implicit Deny)
RLS를 켜는 순간(Enable RLS), 모든 요청은 기본적으로 차단(Block)됩니다.
방화벽과 같습니다. 구멍을 뚫어주지 않으면 아무도 못 지나갑니다.
개발자가 "누구나 읽을 수 있음(Enable Read Access to Everyone)" 정책을 추가해줘야 비로소 보입니다.
Postgres의 권한 체계는 단순합니다.
- 테이블 Level 권한 (Select, Insert 등) -> 이건 Supabase가 알아서 해줌.
- Row Level 권한 (RLS) -> 이게 문제임.
RLS가 켜져 있으면, 쿼리 실행 결과에서 조건에 맞지 않는 행(Row)을 필터링해서 줍니다. 접근 금지가 아니라, "너한테 보여줄 데이터는 0건이야"라고 거짓말을 하는 겁니다. (보안상의 이유로 존재 자체를 숨김) 그래서 에러가 나는 게 아니라, 그냥 빈 결과가 나오는 것입니다.
3. 문제 1 - SELECT 정책 누락 (가장 흔함)
가장 흔한 실수입니다. 데이터를 넣는(INSERT) 정책만 만들고, 읽는(SELECT) 정책을 안 만든 경우입니다.
-- ❌ 반쪽짜리 정책 (쓰기만 가능)
CREATE POLICY "Users can insert their own profile"
ON public.profiles FOR INSERT
WITH CHECK (auth.uid() = user_id);
이렇게 하면 회원가입 후 내 프로필을 만들 수는 있는데, 조회하려고 하면 빈 배열이 뜹니다. "내가 쓴 글인데 왜 내가 못 봐?"라는 상황이 벌어집니다.
해결책: SELECT 정책을 반드시 추가해야 합니다.
-- ✅ 읽기 정책 추가
CREATE POLICY "Users can view their own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = user_id);
만약 "내 프로필은 나만 보고, 남의 프로필은 이름만 보여주고 싶다"면?
정책을 두 개 만들어야 합니다. 복잡해지죠? 그래서 보통은 profiles 테이블은 public 읽기 허용으로 풀고 민감한 정보(이메일, 폰번호)는 별도 테이블(private_profiles)로 분리합니다.
4. 문제 2 - anon vs authenticated 역할
"로그인 안 한 사람도 게시글을 보게 하고 싶어요."
이럴 땐 Target roles 설정을 조심해야 합니다. 정책 생성 시 기본적으로 authenticated (로그인 유저)만 선택되어 있는 경우가 많습니다.
authenticated: 로그인한 사용자만 적용 (JWT 토큰 보유).anon: 로그인 안 한 사용자(Guest)만 적용.public(기본값): 모두에게 적용.
로그인 여부 상관없이 보여주고 싶다면 Role을 비워두거나 public으로 설정해야 합니다.
TO authenticated라고 명시된 정책은 비로그인 유저에게는 아예 존재하지 않는 것처럼 동작합니다.
5. 심화: USING vs WITH CHECK
정책을 만들 때 두 가지 조건문이 있습니다. 헷갈리면 보안 구멍이 생깁니다.
- USING: 기존 데이터를 조회/삭제/수정할 때, "이 행(Row)을 건드려도 될까?"를 검사합니다. (SELECT, UPDATE, DELETE)
- WITH CHECK: 새로운 데이터를 입력/수정할 때, "이 데이터가 들어와도 될까?"를 검사합니다. (INSERT, UPDATE)
UPDATE의 경우 (둘 다 씀):
USING: "내가 쓴 글인가?" (대상 선정) ->auth.uid() == user_idWITH CHECK: "내용을 이상하게 바꾸진 않나?" (변경 후 유효성) ->new.role != 'admin'
만약 WITH CHECK 없이 USING만 쓰면?
"내 글(USING 통과)"을 수정해서 "user_id를 남의 것으로 바꿈"이 가능해집니다. 그러면 글의 소유권이 넘어가 버립니다.
Security Definer (함수의 권한) 더 알아보기
RLS는 강력하지만 가끔은 방해가 됩니다.
예를 들어, "회원가입 시 profiles 테이블에 행을 추가"하는 트리거가 있다고 칩시다.
회원가입 직후에는 아직 사용자가 로그인이 안 된 상태(anon)일 수도 있고, 권한 체크가 꼬일 수 있습니다.
이럴 때 쓰는 것이 Postgres Function과 SECURITY DEFINER 옵션입니다.
-- 이 함수는 호출한 놈(Invoker)의 권한이 아니라,
-- 만든 놈(Definer: 보통 Admin/Postgres)의 권한으로 실행됨.
CREATE OR REPLACE FUNCTION create_user_profile()
RETURNS TRIGGER
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.profiles (user_id) VALUES (new.id);
RETURN new;
END;
$$ LANGUAGE plpgsql;
SECURITY DEFINER를 쓰면 RLS를 우회해서(Bypass) 관리자 권한으로 데이터를 조작할 수 있습니다.
단, 남용하면 보안 구멍이 되므로 꼭 필요한 시스템 로직에만 써야 합니다.
RLS Performance (인덱싱) 자세히 살펴보기
RLS 정책도 결국은 SQL WHERE 절입니다.
auth.uid() = user_id라는 정책은 WHERE user_id = '...' 쿼리를 매번 실행하는 것과 같습니다.
즉, user_id 컬럼에 인덱스(Index)가 없으면 성능이 박살 납니다.
데이터가 적을 땐 모르지만, 10만 건이 넘어가면 SELECT * FROM posts가 엄청 느려집니다.
모든 행을 다 뒤지면서(Full Scan) "이게 니꺼냐?" 하고 물어봐야 하니까요.
RLS 조건에 사용되는 컬럼은 반드시 인덱스를 거세요.
8. Case Study: 어드민도 못 보는 대시보드 (함정)
한 번은 어드민 대시보드(users 테이블 전체 조회)를 만드는데, "분명 어드민 계정으로 로그인했는데 데이터가 0건"인 상황을 겪었습니다.
알고 보니 제가 만든 RLS 정책이 문제였습니다.
-- 기존 정책: 자기 자신만 볼 수 있음
CREATE POLICY "Users can see own data" ON users
FOR SELECT USING (auth.uid() = id);
이 정책 때문에 어드민조차 "자기 자신(데이터 1건)"밖에 못 봤던 겁니다.
Supabase는 is_admin 같은 매직 플래그가 없습니다. 어드민도 결국 authenticated 유저일 뿐입니다.
해결책은 정책에 OR 조건을 추가하는 것이었습니다.
CREATE POLICY "Users can see own data OR Admin sees all" ON users
FOR SELECT USING (
auth.uid() = id
OR
(SELECT role FROM profiles WHERE id = auth.uid()) = 'admin'
);
이렇게 하면 어드민은 모든 데이터를 볼 수 있게 됩니다. 교훈: RLS는 "예외"가 없습니다. 어드민을 위한 뒷문(Backdoor)도 명시적으로 정책(Policy)으로 뚫어줘야 합니다.
9. 디버깅 팁 - Role 임시 변경
Supabase SQL 에디터에서 권한 문제를 테스트하려면 이렇게 하세요.
-- 익명 유저로 빙의 (로그인 안 한 상태 테스트)
SET ROLE anon;
SELECT * FROM posts; -- 빈 배열이면 anon 정책 문제
-- 로그인 유저로 빙의
-- (JWT 토큰 흉내는 어렵지만, RLS 정책 조건에 내 UUID를 하드코딩해서 테스트)
또는 Supabase 대시보드의 "Policy Tester" 기능을 활용하면 특정 유저 ID로 쿼리를 시뮬레이션할 수 있습니다.
10. Glossary
- RLS (Row Level Security): A Postgres feature that restricts access to rows based on user roles and attributes.
- Security Definer: A setting that makes a function execute with the privileges of the user who created it (usually Administrator).
- Implicit Deny: The security principle where everything is denied by default unless explicitly allowed.
- JWT (JSON Web Token): The token used by Supabase Auth to identify users. RLS policies often check claims inside this token.
11. FAQ: Common Pitfalls
Q: I enabled RLS but still see everything.
A: Check if you are using the service_role key in your backend. Only the anon and public keys are subject to RLS. The Service Role key bypasses RLS entirely.
Q: Can I use RLS strictly for one column?
A: No, RLS is Row Level. For Column Level Security (e.g., hiding just the email address), you need to move that column to a separate table (e.g., private_profiles) or use a View.
Q: My policy logic is too complex.
A: If you have 10 OR conditions, performance will tank. Wrap complex logic in a Postgres Function and call that function in your policy. USING ( is_admin(auth.uid()) ).