Supabase 멀티 서비스 RLS와 Role로 안전하게 권한 분리하기
이 글에서 다루는 내용
Supabase에서 여러 서비스를 안전하게 운영하려면 데이터베이스 레벨의 보안 메커니즘인 RLS와 Role을 이해하는 것이 필수입니다. 이 글에서는 PostgreSQL의 두 보안 기능이 어떻게 동작하고 서로 어떻게 맞물리는지, 흔한 실수에는 무엇이 있는지부터 살펴봅니다. 그리고 한 프로젝트 안에서 여러 서비스를 운영할 때 한 이메일로 모든 서비스에 접근 가능해지는 동작 원리와, 이를 안전하게 통제하기 위한 권한 분리 패턴을 다룹니다. 마지막으로 Supabase 사용 단계별 의사결정 가이드를 표로 정리합니다.
RLS와 Role 개념
Supabase의 보안 모델은 PostgreSQL의 두 가지 기본 기능 위에 구축되어 있습니다. Role은 "누가 접속했는가" 를 결정하고, RLS는 "그 사람이 어떤 데이터를 볼 수 있는가" 를 결정합니다. 이 두 가지가 합쳐져서 백엔드 코드를 거치지 않고도 클라이언트가 직접 데이터베이스에 접근해도 안전한 구조가 만들어집니다.
Role - "누구냐"
Role은 DB에 접속하는 주체이자 권한 그룹입니다. PostgreSQL에서는 사용자 계정과 권한 그룹의 개념이 합쳐진 형태로, 각 role마다 어떤 테이블에 어떤 작업(SELECT, INSERT 등)을 할 수 있는지가 정의됩니다.
Supabase는 기본적으로 네 가지 role을 제공합니다. anon은 로그인하지 않은 익명 사용자가 anon key로 접속할 때 부여되고, authenticated는 로그인한 사용자에게 JWT 검증 후 부여됩니다. service_role은 백엔드 전용 슈퍼 권한이라 RLS를 우회하므로 절대 클라이언트에 노출되면 안 됩니다. postgres는 DB 관리용 최고 권한입니다.
클라이언트가 Supabase에 요청을 보내면 JWT 안의 정보를 바탕으로 자동으로 적절한 role이 부여되고, 그 role의 권한으로 DB에 접근하게 됩니다.
RLS (Row Level Security) - "어떤 데이터냐"
Role이 테이블 단위 권한이라면, RLS는 한 테이블 안에서 행(row) 단위로 접근을 제어합니다. 100만 개의 게시글 중 사용자 A는 자기가 쓴 글만 볼 수 있게 하고 싶다면 RLS 정책을 작성하면 됩니다.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "자기 글만 조회"
ON posts FOR SELECT
USING (auth.uid() = user_id);
이 정책이 활성화된 후에는 클라이언트가 단순히 select('*') 를 호출해도 DB가 자동으로 user_id가 본인인 행만 반환합니다. 백엔드 코드를 작성하지 않고도 보안이 보장되는 것이 RLS의 핵심 매력입니다. auth.uid()와 auth.jwt() 같은 헬퍼 함수가 JWT에서 사용자 정보를 꺼내주는 역할을 합니다.
Role과 RLS의 관계
두 보안 메커니즘은 계층적으로 동작합니다. 요청이 도착하면 먼저 JWT가 검증되어 role이 결정됩니다. 그다음 그 role이 해당 테이블에 접근 권한이 있는지 GRANT가 체크되고, 통과하면 RLS 정책이 평가됩니다. 마지막으로 정책을 통과한 행만 클라이언트에 반환됩니다.
쉬운 비유로 표현하면 Role은 빌딩 출입 카드(어느 층까지 들어갈 수 있는가)이고, RLS는 그 층에서 어느 사무실까지 들어갈 수 있는가(이름표 확인) 에 해당합니다.
흔한 실수
가장 자주 발생하는 사고는 테이블 생성 시 RLS를 켜지 않는 것입니다. RLS는 기본적으로 OFF 상태이므로, 켜지 않으면 anon이든 authenticated든 모든 데이터가 노출됩니다. 테이블 생성과 동시에 RLS를 켜는 습관이 필수입니다.
반대로 RLS만 켜고 정책을 만들지 않으면 모든 접근이 차단됩니다. RLS는 "명시적 허용 외에 모두 거부" 정책을 따르기 때문입니다.
가장 위험한 실수는 service_role 키를 클라이언트에 노출하는 것입니다. 이 키는 RLS를 우회하므로 클라이언트에 박히는 순간 모든 보안이 무너집니다. service_role은 반드시 백엔드 서버에서만 사용해야 합니다.
마지막으로 RLS 정책에 무거운 서브쿼리를 넣으면 모든 SELECT마다 실행되어 성능이 크게 저하됩니다. 정책에 사용되는 컬럼은 반드시 인덱스를 만들어두어야 합니다.
한 이메일로 여러 서비스 사용 가능 여부
결론부터
같은 Supabase 프로젝트에 여러 서비스를 올리면 한 이메일로 모든 서비스에 로그인이 가능합니다. Auth가 프로젝트 단위로 공유되기 때문입니다. auth.users 테이블은 프로젝트당 하나만 존재하므로, schema를 service_a, service_b로 나눠도 인증 정보는 공유됩니다.
이 동작이 장점이 될 수도 있고 단점이 될 수도 있습니다. 같은 사업의 여러 제품(메인 앱+어드민, 웹+모바일)이라면 자연스러운 SSO처럼 동작하지만, 완전히 무관한 서비스를 한 프로젝트에 묶으면 한 서비스 사용자가 다른 서비스에 자동으로 접근 가능해지는 위험한 상황이 발생합니다.
권한 분리 패턴
같은 auth를 공유하면서도 서비스별로 권한을 분리하려면 추가 설계가 필요합니다.
1. app_metadata.allowed_services 활용 (가장 권장)
auth.users 테이블의 app_metadata JSON 필드에 어떤 서비스에 가입했는지 표시하는 방법입니다. app_metadata는 JWT에 자동 포함되고 사용자가 직접 수정할 수 없어 권한 정보로 안전합니다.
await supabase.auth.admin.updateUserById(userId, {
app_metadata: {
allowed_services: ['service_a', 'service_b']
}
});
CREATE POLICY "service_a 사용자만"
ON service_a.posts FOR ALL
USING (
(auth.jwt() -> 'app_metadata' -> 'allowed_services') ? 'service_a'
);
user_metadata는 사용자가 직접 수정 가능하므로 권한용으로 절대 사용하면 안 됩니다.
2. 서비스별 프로필 테이블
각 서비스 schema에 profiles 테이블을 만들고, 가입한 사용자만 행을 추가하는 방식입니다. 프로필이 있는 사람만 RLS를 통과시키면 됩니다.
3. 멤버십 테이블 (B2B SaaS 스타일)
여러 조직이나 팀을 다루는 구조라면 memberships 테이블에서 user_id, service, role을 함께 관리합니다. 역할 기반 권한 제어가 자연스럽게 가능합니다.
4. 프로젝트 자체 분리
서비스가 정말 무관하다면 비용을 더 내더라도 프로젝트를 분리하는 것이 맞습니다. 사용자 데이터 완전 격리, 보안 사고 방지, 사업 분리 가능성, 법적·규제적 안전을 동시에 확보할 수 있습니다.
가입 흐름 설계
같은 auth를 공유할 때 가입 UX는 두 가지 패턴이 있습니다. 자동 통합 가입은 이미 다른 서비스에 가입한 이메일을 입력하면 통합 계정으로 사용할지 묻는 방식입니다. 사용자에게 통합 계정임을 명시하고 동의받는 것이 중요합니다.
서비스별 명시적 가입은 인증은 공유하되 각 서비스를 처음 사용할 때 추가 정보 수집과 약관 동의를 별도로 받는 방식입니다. 한국의 개인정보보호법은 수집 목적 명시를 요구하므로, 서비스마다 약관이 다르다면 이 방식이 법적으로 더 깔끔합니다.
흔한 실수
가입 시 권한 자동 부여를 누락하면 사용자가 가입했는데 app_metadata.allowed_services가 비어 있어 모든 RLS가 차단해버리는 상황이 발생합니다. Auth Hooks나 DB 트리거로 자동화해야 합니다.
프론트엔드에서만 권한 체크하는 것은 UX용으로는 OK이지만 실제 보안이 되지 않습니다. 진짜 차단은 반드시 RLS가 책임져야 합니다.
service_role 키로 백엔드에서 작업할 때 권한 체크를 누락하면 RLS가 우회되어 모든 데이터가 노출됩니다. 백엔드 코드에서 명시적으로 권한을 검증해야 합니다.
약관과 개인정보처리방침 처리 미흡도 자주 발생합니다. 서비스별 동의 이력을 별도 테이블로 관리해 어떤 서비스에 언제 어떤 버전의 약관에 동의했는지 추적할 수 있어야 합니다.
공유 권장 vs 분리 권장
| 상황 | 권장 |
|---|---|
| 같은 사업의 여러 제품 | 공유 |
| 메인 + 어드민 | 공유 + 권한 분리 |
| B2C 앱 패밀리 | 공유 |
| 완전 무관한 서비스 | 분리 |
| 다른 법인 운영 | 분리 |
| 규제 산업 (의료, 금융) | 분리 |
핵심 의사결정 요약
| 단계 | 상태 | 액션 |
|---|---|---|
| 시작 | MVP / 검증 | Free 플랜 |
| 첫 유료 사용자 | 프로덕션 시작 | Pro $25/월 |
| 청구서 $400+ | 성장기 | Team 전환 검토 |
| MAU 30~50만 | 스케일업 | Enterprise 협상 또는 Self-host 검토 |
| MAU 100만+ | 대규모 | 마이그레이션 또는 협상 본격화 |