오늘도 AI 한입 하세요 🍊
새 아티클이 발행되면 이메일로 알려드릴게요.
댓글을 남기려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 댓글을 남겨보세요!
Supabase(수파베이스)는 RLS를 안 켜면 모든 사용자 데이터가 노출됩니다. 아파트 택배함 비유로 RLS 보안 설정을 쉽게 이해하고, 왜 반드시 켜야 하는지 알아봅니다.
API(에이피아이)란 프로그램끼리 데이터를 주고받는 통로입니다. 카카오 로그인, 날씨 앱, 배달 앱 결제까지 — 비개발자도 이미 매일 쓰고 있는 API의 뜻과 원리를 쉽게 설명합니다.

Supabase(수파베이스)에 Prisma를 붙여서 서비스를 만들고 있다고 해보겠습니다. 대시보드에 뜨는 경고가 거슬려서 RLS를 켰고, 모든 테이블에 정책까지 꼼꼼히 걸어놨습니다. 그런데 Prisma로 짠 기존 쿼리가 아무 일도 없었다는 듯 그대로 동작합니다.
prisma.user.findMany() 한 줄이면 모든 사용자의 데이터가 다 딸려 나옵니다. 분명히 auth.uid() = user_id 정책을 걸어놨는데도요.
이 글은 그 이유를 찾는 과정과, 현실적으로 어디까지 막을 수 있는지 정리한 실전 기록입니다.
한 가지 전제부터 분명히 하겠습니다. 이 글은 Supabase를 Postgres 호스팅 용도로만 쓰는 경우를 다룹니다. Supabase Auth를 쓰지 않고 NextAuth.js(카카오, 구글 OAuth)로 인증을 처리하면서, DB 접근은 Prisma로만 하는 구성입니다. Supabase Auth를 쓰는 경우와는 결론이 조금 다릅니다.

문제의 원인부터 보려면 잠깐 뒤로 돌아가야 합니다. Supabase는 DB만 주는 서비스가 아닙니다. 새 프로젝트를 만드는 순간, 테이블마다 REST API가 자동으로 열립니다. 이걸 가능하게 만드는 건 Supabase에 내장된 PostgREST라는 오픈 소스 도구인데, PostgREST는 우리가 만든 PostgreSQL 스키마를 스캔해서 테이블별로 GET/POST/PATCH/DELETE 엔드포인트를 자동으로 뽑아냅니다. 우리가 추가 코드를 짜지 않아도 CRUD API가 생기는 이유가 바로 이것입니다.
이게 무슨 말이냐면, 아래 URL을 누구든 브라우저에서 때릴 수 있다는 뜻입니다.
https://{프로젝트ID}.supabase.co/rest/v1/users
apikey 헤더에 anon key만 같이 보내면 끝입니다. 그런데 이 anon key는 브라우저에서 쓰는 공개 키라서 개발자 도구만 열면 그대로 보입니다. 실제로 서비스의 네트워크 탭 요청 헤더를 뒤져보면, 모든 요청에 그 값이 붙어서 나갑니다.
정리하면 이렇습니다. Supabase에서 새 프로젝트를 만들면 자동으로 열린 뒷문(REST API)이 하나 생기고, 그 뒷문의 비밀번호(anon key)는 공개되어 있는 셈입니다.

여기서 "나는 Supabase JS 클라이언트를 아예 안 쓰고, 모든 요청을 Prisma로 처리하는데 왜?"라는 생각이 들 수 있습니다. 저도 처음에 그렇게 생각했습니다. 그런데 REST API는 우리 앱이 쓰든 안 쓰든 상관없이 프로젝트가 만들어지는 순간부터 열려 있습니다. 우리가 쓰지 않을 뿐, 외부에서는 여전히 접근할 수 있는 상태입니다.
그래서 Supabase 대시보드가 "RLS is not enabled on these tables"라는 경고를 노랗게 띄우는 겁니다. "뒷문은 열려 있는데 자물쇠가 없다, 지금 네 사용자 데이터가 공개 상태다"라는 경고죠.

이제 본론입니다. 경고를 본 저는 RLS를 켜고, users 테이블에 정책을 걸었습니다.
CREATE POLICY users_select_own ON users
FOR SELECT USING (auth.uid() = id);대시보드 경고도 사라졌고, 이제 안전하다고 생각했습니다. 그런데 Prisma로 prisma.user.findMany()를 해보니 위 화면처럼 모든 사용자 데이터가 그대로 나옵니다. 다른 사용자로 로그인한 세션에서도 결과가 똑같고, 응답 JSON에 이영희·박지민의 이메일까지 전부 딸려옵니다. curl로 같은 엔드포인트를 찔러봐도 Prisma가 만드는 요청은 동일하게 통과됩니다.
"RLS 정책이 적용 안 되고 있나?" 싶어서 Supabase 대시보드에서 같은 쿼리를 실행해봤습니다. 거기선 정상적으로 정책이 먹혀서 로그인한 사용자의 행 하나만 나옵니다. 그런데 Prisma로 쏘면 통과됩니다.
이 차이가 어디서 오는지 알려면 PostgreSQL이 RLS를 어떻게 적용하는지 봐야 합니다.

PostgreSQL에서 RLS는 접속한 역할(role)이 누구냐에 따라 다르게 동작합니다. Supabase 프로젝트에는 처음부터 네 가지 내장 역할이 있습니다.
| 역할 | 누가 이 역할로 접속하는가 | RLS 적용 여부 |
|---|---|---|
anon | 로그인 안 한 요청. 헤더에 anon key만 붙어 있는 경우 | 적용됨 |
authenticated | 로그인된 사용자 요청. 헤더에 Authorization: Bearer <access_token>이 붙어 있으면 PostgREST가 이 역할로 매핑 | 적용됨 |
service_role | 서버에서 service_role 키로 접근 (관리자용) | 무시됨 (BYPASSRLS) |
postgres | 슈퍼유저 (DB 오너) | 무시됨 (BYPASSRLS) |
여기서 핵심은 마지막 줄입니다. postgres 역할은 Supabase 프로젝트에서 관리자 권한을 가진 역할이자 대부분의 테이블 소유자라서, RLS 정책을 기본적으로 우회합니다. PostgreSQL 매뉴얼에 "슈퍼유저나 BYPASSRLS 속성을 가진 역할은 row security system을 건너뛴다"라고 명시되어 있는데, Supabase의 postgres 역할이 실질적으로 이 효과를 받는다고 생각하면 됩니다. 즉, 아무리 정책이 촘촘해도 postgres로 들어온 쿼리는 정책을 스킵하고 모든 행을 돌려줍니다.
그럼 Prisma는 어느 역할로 접속할까요? 기본값은 postgres입니다. Prisma의 DATABASE_URL에 보통 postgres://postgres:...@... 형태로 되어 있을 텐데, 저 앞의 postgres://는 프로토콜이지만 그 바로 뒤 postgres:가 실제 유저 이름입니다. Supabase의 기본 슈퍼유저로 접속하고 있는 거죠.

즉 이 구조입니다. Prisma는 슈퍼유저로 들어와서 "나 슈퍼유저야, RLS 같은 거 신경 안 써"라고 말하며 모든 행을 가져옵니다. RLS 정책은 분명히 걸려 있지만, postgres 역할에는 애초에 적용되지 않습니다.
Supabase 대시보드의 SQL Editor도 같은 postgres 역할로 접속하는데, 거기서는 왜 정책이 먹혔냐면, 대시보드가 "impersonation" 기능으로 일부러 authenticated 역할로 바꿔서 실행해주기 때문입니다. "이 쿼리를 일반 사용자 입장에서 돌려봐줘"라는 옵션이 붙어서 나가는 겁니다.

여기서 자연스럽게 의문이 생깁니다. Prisma 쿼리에 RLS가 안 먹힌다면, 대체 뭘 막아주는 건가요?
답은 단순합니다. RLS는 뒷문(REST API)을 막습니다. 우리가 Prisma로 쿼리를 쏠 때는 postgres 역할이라 정책이 무시되지만, 외부 공격자가 curl이나 브라우저로 직접 /rest/v1/users를 때릴 때는 anon 역할로 들어옵니다. 이쪽에는 RLS가 그대로 적용됩니다.
같은 DB지만 두 개의 다른 문이 있고, 우리가 쓰는 문에는 RLS가 안 먹히고, 외부에서 몰래 들어오는 문에는 RLS가 먹히는 구조입니다. 그렇다면 우리가 써야 할 RLS 정책의 목표도 달라집니다.
"Prisma 쿼리에 권한 제약을 주자"가 아니라 "뒷문을 닫자"입니다. Prisma 쿼리의 권한 제약은 RLS가 아닌 다른 곳에서 해야 합니다. API 라우트, 서버 컴포넌트, Server Action 안에서 세션을 확인하고 수동으로 where: { userId: session.user.id } 같은 필터를 넣어야 합니다. 이걸 애플리케이션 레이어 검증이라고 부릅니다.

원칙이 정해졌으니, 제 프로젝트(AI한입)의 15개 테이블을 실제로 어떻게 분류했는지 보여드리겠습니다. "뒷문을 닫는다"라는 목표 하나만 생각하면, 테이블마다 정책이 완전히 달라집니다.
블로그 글(articles), 카테고리(categories), 태그(tags) 같은 테이블입니다. 이 글 자체가 이 articles 테이블에 들어있고, 누구나 읽을 수 있어야 합니다. 이 경우 anon에 SELECT만 허용합니다.
CREATE POLICY articles_public_read ON articles
FOR SELECT TO anon, authenticated
USING (draft = false);TO anon, authenticated가 핵심입니다. 이 정책은 로그인 여부와 상관없이 누구나 읽을 수 있게 하되, draft = false인 행만 필터링합니다. INSERT/UPDATE/DELETE는 정책이 없으므로 기본 거절입니다.
사용자 정보(users), 읽기 기록(read_histories), 댓글(comments) 같은 테이블입니다. 이 데이터는 외부에 절대 노출되면 안 됩니다. 그런데 Prisma 쿼리는 여전히 slot이 필요합니다. 해법은 단순합니다.
아예 정책을 만들지 않습니다.
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- 정책 없음PostgreSQL에서 RLS를 켜고 정책을 하나도 안 만들면, anon/authenticated 역할은 무조건 거절됩니다. 반면 postgres 역할은 BYPASSRLS라서 여전히 통과합니다. Prisma 쿼리는 그대로 동작하고, REST API는 완전히 막힙니다. 정확히 원하는 그림입니다.
NextAuth가 쓰는 accounts, sessions, verification_tokens 같은 테이블입니다. 이건 외부에서 아예 존재 자체를 몰라야 합니다. 이것도 RLS 켜고 정책 0개로 같은 효과를 냅니다.
뉴스레터 구독 같은 기능이 있다면, 비로그인 상태에서 이메일을 INSERT만 할 수 있어야 합니다. 단, 기존 구독자 목록을 SELECT해서 긁어가는 건 막아야 합니다.
CREATE POLICY subscribers_insert_only ON subscribers
FOR INSERT TO anon
WITH CHECK (true);
-- SELECT, UPDATE, DELETE 정책은 만들지 않음이렇게 INSERT 정책 하나만 열어둡니다. WITH CHECK (true)는 "삽입되는 행에는 아무 조건도 걸지 않는다"는 뜻입니다. SELECT 정책이 없으니 읽기는 안 되고, INSERT만 통과합니다.
여기까지만 해도 "뒷문은 막혔고, 앞문(Prisma)은 애플리케이션 레이어로 검증한다"는 2단 방어가 완성됩니다. 그런데 저는 한 단계 더 나아가려 했습니다. Prisma 쿼리에도 RLS를 적용시킬 수는 없을까? 실수로 where 조건을 빼먹는 사고를 DB 레벨에서 걸러주면 좋지 않을까 하는 욕심이었습니다.
이 욕심이 통하지 않는 이유를 하나씩 맞닥뜨렸습니다. 같은 함정에 빠질 분들을 위해 그대로 기록합니다.
가장 먼저 든 생각은 "Prisma가 postgres가 아닌 다른 역할로 접속하면 되지 않을까"였습니다. 실제로 이렇게 할 수 있긴 합니다.
CREATE ROLE app_user NOINHERIT LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;그리고 DATABASE_URL의 사용자 이름을 postgres에서 app_user로 바꿉니다. app_user에는 BYPASSRLS가 없으니 RLS 정책이 그대로 먹힙니다. 논리적으로는 완벽합니다.
그런데 막상 배포하면 에러가 납니다.

에러 메시지는 P1001: Can't reach database server인데, 이건 Prisma가 DB에 연결하지 못했을 때 일률적으로 내는 기본 에러 코드입니다. 진짜 원인은 그 아래 Supavisor authentication failed에 있습니다. Supabase의 Pooler(Supavisor)는 커스텀 역할을 별도 설정 없이 받아주지 않기 때문입니다. 좀 더 정확히는, Pooler가 접속 요청을 테넌트(tenant) 단위로 관리하는데 커스텀 역할은 이 테넌트 목록에 자동으로 등록되지 않고, 설령 억지로 등록하더라도 암호 해시 방식이 맞지 않습니다. Supabase Postgres의 기본 암호 방식은 SCRAM-SHA-256인데 Pooler는 MD5 해시만 받아들이거든요. 이 충돌을 풀려면 password_encryption을 MD5로 바꾸는 등 Supabase가 공식으로 지원하지 않는 편법을 써야 합니다. Vercel 같은 서버리스 환경에서는 커넥션 풀을 써야 해서 Pooler 경유가 사실상 필수라, 실질적으로 커스텀 역할 전략은 배포 단계에서 무너집니다.
"그럼 Pooler를 우회해서 직접 접속하면?" 하는 생각이 들지만, 서버리스 환경에서 직접 접속을 쓰면 함수 호출마다 새 커넥션을 열어서 몇 분 안에 DB 커넥션 상한에 도달합니다. 프로덕션에서 못 씁니다.
SET LOCAL ROLE로 런타임에 역할 바꾸기다른 방법. postgres로 접속한 뒤, 쿼리 전에 트랜잭션 안에서 역할을 바꿔보는 겁니다.
BEGIN;
SET LOCAL ROLE authenticated;
SELECT * FROM users; -- 이제 RLS가 먹히겠지
COMMIT;Prisma의 $transaction 안에서 $executeRaw로 SET LOCAL ROLE을 먼저 쏘는 방식입니다. 이것도 Pooler에서 걸립니다. Transaction Pooling 모드는 세션 상태(session state)를 요청 간에 유지해준다는 보장이 없습니다. Supabase 공식 문서에도 이 모드가 prepared statement 같은 세션 상태를 지원하지 않는다고 명시되어 있는데, 같은 이유로 SET LOCAL ROLE로 바꿔둔 역할이 바로 다음 쿼리에 반영되지 않을 수 있습니다. 역할 전환과 쿼리가 같은 커넥션에 붙지 않는 순간 postgres가 다시 돌아와서 RLS를 그대로 우회합니다.
ALTER ROLE postgres NOBYPASSRLS로 속성 제거마지막 희망. postgres 역할의 BYPASSRLS 속성을 아예 떼어버리는 방법입니다.
ALTER ROLE postgres NOBYPASSRLS;이 쿼리는 권한 부족으로 실패합니다. Supabase는 postgres 역할을 우리에게 완전히 내주지 않고, 더 상위 역할(supabase_admin)이 관리합니다. 내가 접속한 postgres가 내가 건드릴 수 없는 postgres인 셈입니다.
FORCE ROW LEVEL SECURITY테이블 오너도 RLS를 따르게 강제하는 옵션입니다.
ALTER TABLE users FORCE ROW LEVEL SECURITY;이것도 BYPASSRLS 앞에서는 무용지물입니다. FORCE RLS는 "테이블 소유자도 정책을 따르게 한다"는 뜻인데, BYPASSRLS는 "소유자든 아니든 정책 자체를 스킵한다"는 뜻입니다. 속성이 한 단계 더 위에 있어서 FORCE가 무시됩니다.
current_setting() 기반 커스텀 정책다른 각도. 정책 자체를 바꿔서, 트랜잭션 안에 세션 변수로 유저 ID를 넣고, 그걸 기준으로 정책을 만드는 패턴입니다.
CREATE POLICY users_select_own ON users
FOR SELECT USING (id = current_setting('app.user_id')::text);await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.user_id = '${userId}'`);
return tx.user.findMany();
});SET LOCAL은 SET ROLE과 달리 애플리케이션 변수(app.*)이므로 Pooler가 차단하지 않습니다. 그래서 여기까지는 동작합니다.
그런데 마지막 한 단계에서 또 BYPASSRLS에 걸립니다. postgres 역할에는 이 정책 자체가 적용되지 않습니다. app.user_id를 아무리 잘 세팅해도, 정책을 평가하는 단계에 가기 전에 postgres가 "난 스킵"이라고 지나가버립니다.
저는 여기서 멈췄습니다. Supabase에서 커스텀 역할 사용이나 BYPASSRLS 제거는 Pooler와 권한 제한 때문에 구조적으로 불가능하다는 게 명확해졌습니다. 이 포기는 슬프지만, 현실입니다. Supabase가 Pooler를 열어주거나 역할 관리를 개방하지 않는 이상, Prisma 쿼리에 RLS를 씌우는 모든 시도는 같은 벽에 부딪힙니다.
만약 이게 꼭 필요한 프로젝트라면, 선택지는 (1) Supabase를 버리고 자기 호스팅 Postgres로 이전, (2) Supabase를 계속 쓰되 Drizzle +
SET LOCAL+ 커스텀 역할을 Pooler 없이 쓰는 AWS RDS Proxy 같은 외부 풀러를 얹는 것, 두 가지 정도입니다. 둘 다 Supabase의 장점(간편함)을 크게 깎아먹습니다.

포기했다고 해서 보안이 약한 것은 아닙니다. 정리하면 이런 그림이 됩니다.
1단: RLS로 REST API 뒷문 차단
anon에 SELECT만 허용 (draft = false 필터)anon에 INSERT만 허용2단: Prisma 쿼리는 애플리케이션 레이어에서 검증
where: { userId: session.user.id } 필터 수동 추가Prisma 쿼리 레벨의 실수를 DB가 막아주진 못하지만, 뒷문이 닫혀 있고 앞문은 사람이 지키는 구조입니다. Supabase Auth를 쓰는 프로젝트라면 1단만으로도 defense-in-depth가 되지만, NextAuth 기반 프로젝트는 2단 방어가 현실적인 상한선입니다.
(SELECT current_setting()) 트릭
여담으로, RLS 정책을 설계할 때 알아두면 좋은 최적화가 하나 있습니다. 정책에서 current_setting()이나 auth.uid() 같은 함수를 호출하면, 기본적으로 행 하나마다 한 번씩 호출됩니다. 100만 행이면 100만 번입니다.
이걸 (SELECT ...)로 한 번 감싸면 PostgreSQL 옵티마이저가 initPlan으로 처리합니다. 쿼리 시작 시 딱 한 번만 평가하고, 그 값을 전체 행에 재사용합니다.
-- Before: 행마다 호출
USING (auth.uid() = user_id)
-- After: 쿼리 시작 시 한 번만 호출
USING ((SELECT auth.uid()) = user_id)Supabase 공식 문서에서도 이 트릭을 권장합니다. 큰 테이블에 정책을 걸 때는 잊지 말고 적용하세요.

마지막으로 실전 체크리스트로 마무리합니다. 이 글을 다 읽고 나서 실제로 해야 할 일은 다음과 같습니다.
지금 당장 할 일
anon SELECT 허용, 나머지는 정책 0개로 외부 차단합니다.fetch('https://{프로젝트ID}.supabase.co/rest/v1/users', { headers: { apikey: '{anon}' } })를 직접 쳐봅니다. 막혀있어야 합니다.받아들여야 할 현실
5. Prisma 쿼리에는 RLS가 안 먹힙니다. 애플리케이션 레이어에서 세션 검증 + where 필터를 수동으로 챙기는 수밖에 없습니다.
6. 커스텀 역할, FORCE RLS, ALTER ROLE NOBYPASSRLS 시도는 Supabase Pooler와 권한 제한 때문에 모두 막힙니다. 이 방향에 시간을 쓰지 마세요.
꼭 기억할 한 줄
RLS는 Supabase가 열어둔 뒷문을 닫는 장치이고, 앞문(Prisma)은 여전히 애플리케이션 레이어가 책임져야 합니다.
제 AI한입 프로젝트는 이 원칙대로 15개 테이블에 46개 정책을 적용해서 Supabase 대시보드 경고를 모두 해소했고, 기존 Prisma 쿼리는 한 줄도 수정하지 않았습니다. 그 과정을 그대로 옮겨 적은 게 이 글입니다.