시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 👉 RLS 보안 — Row Level Security 완벽 가이드 (현재 글) 5편 – 실시간 기능 (Realtime) — 구독 & 채널 …
들어가며
2025년 1월, 보안 연구자들이 Lovable로 만들어진 170개 이상의 앱에서 데이터베이스가 통째로 노출된 사건이 있었습니다. 원인은 단 하나였습니다. RLS를 활성화하지 않은 것입니다.
Row Level Security(RLS)는 Supabase를 안전하게 운영하기 위한 핵심 보안 장치입니다. anon 키가 클라이언트에 노출되어 있어도, RLS만 제대로 설정되어 있으면 사용자는 자신이 볼 수 있는 데이터만 볼 수 있습니다. API 레벨이 아닌 데이터베이스 레벨에서 강제되는 보안이기 때문입니다.
이번 편에서 다룰 내용:
- RLS의 작동 원리와 핵심 개념
- USING vs WITH CHECK 절의 차이
- SELECT / INSERT / UPDATE / DELETE 정책 패턴
- 자주 쓰는 실전 패턴 (개인 데이터, 공개 데이터, 팀/조직, 역할 기반)
- JWT 클레임을 활용한 고급 정책
- 멀티테넌트 앱 RLS 설계
- 성능 최적화 (인덱스, initPlan 캐싱)
- 정책 테스트 방법
- 자주 하는 실수 모음
RLS의 작동 원리
개념: DB 레벨의 WHERE 절
RLS 정책은 쉽게 말하면 모든 쿼리에 자동으로 붙는 WHERE 절입니다. 예를 들어 이런 정책이 있다면:
CREATE POLICY "본인 데이터만 조회"
ON todos
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
클라이언트가 SELECT * FROM todos를 실행해도, 실제로는 SELECT * FROM todos WHERE user_id = '현재사용자UUID'처럼 동작합니다. 아무리 교묘한 쿼리를 날려도 RLS를 우회할 수 없습니다.
Supabase 역할(Role) 구조
RLS 정책은 역할(Role) 에 적용됩니다. Supabase에는 두 가지 핵심 역할이 있습니다.
| 역할 | 설명 | 언제 사용 |
|---|---|---|
anon | 비로그인 사용자 | 공개 데이터 조회 허용 시 |
authenticated | 로그인 사용자 | 대부분의 데이터 접근 |
service_role | 관리자 (RLS 우회) | 서버사이드 관리 작업 |
RLS 활성화
테이블 생성 후 RLS는 기본적으로 비활성화 상태입니다. SQL Editor나 대시보드에서 반드시 활성화해야 합니다.
-- RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 중요: RLS 활성화 후 정책 없으면 아무도 접근 불가
-- (service_role 제외)
⚠️ RLS 활성화 후 정책이 하나도 없으면 모든 접근이 차단됩니다. 정책은 허용 규칙입니다. 기본은 “모두 거부”입니다.
USING vs WITH CHECK
정책을 작성할 때 두 가지 절을 사용합니다.
| 절 | 적용 시점 | 사용 용도 |
|---|---|---|
USING | 쿼리 실행 전 (읽기) | SELECT, UPDATE, DELETE 대상 행 필터링 |
WITH CHECK | 쿼리 실행 후 (쓰기) | INSERT, UPDATE 결과 행 검증 |
-- SELECT: USING만 사용 (어떤 행을 볼 수 있는가)
CREATE POLICY "본인 게시글 조회"
ON posts FOR SELECT
TO authenticated
USING ((select auth.uid()) = author_id);
-- INSERT: WITH CHECK만 사용 (어떤 행을 삽입할 수 있는가)
CREATE POLICY "본인 게시글 작성"
ON posts FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = author_id);
-- UPDATE: USING(대상 선택) + WITH CHECK(결과 검증) 모두 사용
CREATE POLICY "본인 게시글 수정"
ON posts FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id) -- 수정할 행 선택
WITH CHECK ((select auth.uid()) = author_id); -- 수정 후 결과 검증
-- DELETE: USING만 사용 (어떤 행을 삭제할 수 있는가)
CREATE POLICY "본인 게시글 삭제"
ON posts FOR DELETE
TO authenticated
USING ((select auth.uid()) = author_id);
💡
FOR ALL대신SELECT,INSERT,UPDATE,DELETE를 각각 분리하는 것이 권장 패턴입니다. 의도가 명확해지고 유지보수가 쉬워집니다.
자주 쓰는 핵심 헬퍼 함수
-- 현재 로그인 사용자의 UUID 반환
auth.uid()
-- JWT 전체 페이로드 반환 (JSONB)
auth.jwt()
-- JWT에서 특정 클레임 추출 예시
auth.jwt() ->> 'role' -- 사용자 역할
(auth.jwt() -> 'app_metadata') ->> 'role' -- app_metadata의 role
auth.jwt() ->> 'email' -- 이메일
실전 RLS 패턴 모음
패턴 1: 개인 데이터 (본인만 접근)
가장 기본적인 패턴입니다. 각 사용자는 자신의 데이터만 읽고 쓸 수 있습니다.
-- todos 테이블 예시
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 할 일 조회"
ON todos FOR SELECT
TO authenticated
USING ((select auth.uid()) = user_id);
CREATE POLICY "본인 할 일 생성"
ON todos FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "본인 할 일 수정"
ON todos FOR UPDATE
TO authenticated
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "본인 할 일 삭제"
ON todos FOR DELETE
TO authenticated
USING ((select auth.uid()) = user_id);
패턴 2: 공개 읽기 + 본인 쓰기
블로그 게시글처럼 누구나 읽을 수 있지만 작성자만 수정할 수 있는 패턴입니다.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 공개 게시글은 누구나 조회 (anon 포함)
CREATE POLICY "공개 게시글 조회"
ON posts FOR SELECT
TO anon, authenticated
USING (published = true);
-- 본인 게시글은 미공개여도 조회 가능
CREATE POLICY "본인 미공개 게시글 조회"
ON posts FOR SELECT
TO authenticated
USING ((select auth.uid()) = author_id);
-- 로그인한 사용자만 게시글 작성
CREATE POLICY "게시글 작성"
ON posts FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = author_id);
-- 본인 게시글만 수정
CREATE POLICY "본인 게시글 수정"
ON posts FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id)
WITH CHECK ((select auth.uid()) = author_id);
-- 본인 게시글만 삭제
CREATE POLICY "본인 게시글 삭제"
ON posts FOR DELETE
TO authenticated
USING ((select auth.uid()) = author_id);
패턴 3: 팀/조직 기반 접근 (멀티테넌트)
팀 멤버만 팀 데이터에 접근할 수 있는 패턴입니다. SaaS 서비스에서 가장 많이 쓰입니다.
-- 팀 테이블
CREATE TABLE teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
-- 팀 멤버 테이블
CREATE TABLE team_members (
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member', -- 'owner', 'admin', 'member'
PRIMARY KEY (team_id, user_id)
);
-- 프로젝트 테이블
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
name TEXT NOT NULL
);
-- RLS 활성화
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 팀 멤버인 경우 팀 조회
CREATE POLICY "팀 멤버 팀 조회"
ON teams FOR SELECT
TO authenticated
USING (
id IN (
SELECT team_id FROM team_members
WHERE user_id = (select auth.uid())
)
);
-- 팀 멤버인 경우 프로젝트 조회
CREATE POLICY "팀 멤버 프로젝트 조회"
ON projects FOR SELECT
TO authenticated
USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = (select auth.uid())
)
);
-- 팀 owner/admin만 프로젝트 생성
CREATE POLICY "관리자 프로젝트 생성"
ON projects FOR INSERT
TO authenticated
WITH CHECK (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = (select auth.uid())
AND role IN ('owner', 'admin')
)
);
패턴 4: 역할 기반 접근 제어 (RBAC)
JWT의 app_metadata에 역할을 저장하고, RLS에서 활용하는 패턴입니다. 관리자 콘솔 같은 기능에 유용합니다.
-- JWT의 app_metadata에서 역할 추출하는 헬퍼 함수
CREATE OR REPLACE FUNCTION get_user_role()
RETURNS TEXT AS $$
SELECT (auth.jwt() -> 'app_metadata' ->> 'role')::TEXT;
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- 관리자만 전체 조회 가능
CREATE POLICY "관리자 전체 조회"
ON posts FOR SELECT
TO authenticated
USING (get_user_role() = 'admin');
-- 본인 또는 관리자 조회
CREATE POLICY "본인 또는 관리자 조회"
ON posts FOR SELECT
TO authenticated
USING (
(select auth.uid()) = author_id
OR get_user_role() = 'admin'
);
역할 부여는 서버사이드(service_role)에서만:
// app/admin/actions.ts — Server Action (service_role 사용)
'use server'
import { createClient } from '@supabase/supabase-js'
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // 절대 클라이언트에 노출 금지
)
export async function assignAdminRole(userId: string) {
const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
})
if (error) throw new Error(error.message)
}
패턴 5: 댓글 — 게시글 접근 권한 상속
댓글은 게시글이 공개된 경우에만 볼 수 있어야 합니다.
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
-- 공개 게시글의 댓글만 조회 가능
CREATE POLICY "공개 게시글 댓글 조회"
ON comments FOR SELECT
TO anon, authenticated
USING (
post_id IN (
SELECT id FROM posts WHERE published = true
)
);
-- 로그인 사용자만 댓글 작성 (작성자 ID 강제)
CREATE POLICY "댓글 작성"
ON comments FOR INSERT
TO authenticated
WITH CHECK (
(select auth.uid()) = author_id
AND post_id IN (
SELECT id FROM posts WHERE published = true
)
);
-- 본인 댓글만 수정
CREATE POLICY "본인 댓글 수정"
ON comments FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id)
WITH CHECK ((select auth.uid()) = author_id);
-- 본인 댓글 삭제 (또는 게시글 작성자가 삭제)
CREATE POLICY "댓글 삭제"
ON comments FOR DELETE
TO authenticated
USING (
(select auth.uid()) = author_id
OR (select auth.uid()) IN (
SELECT author_id FROM posts WHERE id = post_id
)
);
고급: JWT 클레임 커스텀
Custom Access Token Hook을 사용하면 JWT에 커스텀 클레임을 추가할 수 있습니다. 이를 RLS에서 활용하면 별도 테이블 조회 없이 빠른 정책을 만들 수 있습니다.
-- 1. JWT에 추가할 클레임을 만드는 Hook 함수
CREATE OR REPLACE FUNCTION custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
claims JSONB;
user_role TEXT;
BEGIN
-- profiles 테이블에서 역할 조회
SELECT role INTO user_role
FROM public.profiles
WHERE id = (event->>'user_id')::UUID;
claims := event->'claims';
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
RETURN jsonb_set(event, '{claims}', claims);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- 2. Supabase 대시보드 → Authentication → Hooks에서 등록
-- 3. RLS에서 JWT 클레임 바로 사용 (테이블 조회 없음 = 빠름)
CREATE POLICY "역할 기반 접근"
ON admin_data FOR SELECT
TO authenticated
USING (
(auth.jwt() ->> 'user_role') = 'admin'
);
성능 최적화
RLS 정책은 모든 쿼리에 실행되므로 성능에 직접 영향을 줍니다.
1. 인덱스 추가 (필수)
-- RLS 정책에 사용되는 컬럼에 인덱스 추가
CREATE INDEX ON posts (author_id);
CREATE INDEX ON team_members (user_id);
CREATE INDEX ON team_members (team_id, user_id); -- 복합 인덱스
CREATE INDEX ON comments (post_id);
2. auth.uid()를 서브쿼리로 감싸기 (initPlan 캐싱)
-- ❌ 느린 방식 — 매 행마다 auth.uid() 호출
USING (auth.uid() = author_id);
-- ✅ 빠른 방식 — 쿼리당 1회만 호출 (Postgres optimizer가 캐싱)
USING ((select auth.uid()) = author_id);
3. 복잡한 JOIN은 security definer 함수로 분리
RLS 정책 안에 다른 테이블과의 JOIN이 있으면 성능이 급격히 저하될 수 있습니다. 이런 경우 SECURITY DEFINER 함수로 분리합니다.
-- ❌ 느린 방식 — 정책 안에서 JOIN
CREATE POLICY "팀 멤버 접근"
ON projects FOR SELECT
TO authenticated
USING (
team_id IN (
SELECT team_id FROM team_members -- 매 쿼리마다 JOIN 발생
WHERE user_id = (select auth.uid())
)
);
-- ✅ 빠른 방식 — 함수로 캐싱
CREATE OR REPLACE FUNCTION get_my_team_ids()
RETURNS SETOF UUID AS $$
SELECT team_id FROM team_members
WHERE user_id = (select auth.uid());
$$ LANGUAGE sql STABLE SECURITY DEFINER;
CREATE POLICY "팀 멤버 접근"
ON projects FOR SELECT
TO authenticated
USING (team_id IN (SELECT get_my_team_ids()));
⚠️
SECURITY DEFINER함수는 반드시 공개 스키마(public)가 아닌 비공개 스키마에 만들거나, API 노출 설정에서 제외해야 합니다.
RLS 정책 테스트
대시보드에서 사용자 가장하기
Supabase 대시보드 → Authentication → Policies에서 특정 사용자로 가장한 쿼리를 실행할 수 있습니다.
SQL로 직접 테스트
-- 1. anon 역할로 테스트 (비로그인 시뮬레이션)
SET ROLE anon;
SELECT * FROM posts; -- 공개 게시글만 보여야 함
RESET ROLE;
-- 2. 특정 사용자로 가장
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub": "특정-사용자-UUID", "role": "authenticated"}';
SELECT * FROM posts; -- 해당 사용자의 접근 권한으로 조회
RESET ROLE;
Next.js에서 RLS 동작 확인
// 테스트: anon 클라이언트로 다른 사용자 데이터 접근 시도
const supabase = createClient(url, anonKey)
const { data, error } = await supabase
.from('todos')
.select('*')
// RLS가 제대로 설정되어 있다면 → 본인 데이터만 반환
// RLS가 없다면 → 모든 사용자의 데이터 반환 (보안 위협!)
console.log('반환된 데이터 수:', data?.length)
Storage에서의 RLS
스토리지 버킷도 RLS로 보호할 수 있습니다. storage.objects 테이블에 정책을 적용합니다.
-- 본인 파일만 업로드 가능 (경로: {user_id}/파일명)
CREATE POLICY "본인 파일 업로드"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
-- 본인 파일만 조회
CREATE POLICY "본인 파일 조회"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (select auth.uid())::TEXT
);
-- 공개 버킷: 누구나 읽기
CREATE POLICY "공개 이미지 조회"
ON storage.objects FOR SELECT
TO anon, authenticated
USING (bucket_id = 'public-images');
자주 하는 실수 TOP 5
1. RLS 활성화만 하고 정책을 안 만드는 경우
-- ❌ 이 상태면 아무도 데이터에 접근 불가
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 정책 없음 → 모든 접근 차단
-- ✅ 반드시 정책을 함께 생성
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "..." ON posts FOR SELECT TO authenticated USING (...);
2. FOR ALL을 사용해 의도치 않은 권한 부여
-- ❌ FOR ALL을 쓰면 WITH CHECK 없이 INSERT도 허용될 수 있음
CREATE POLICY "모든 작업"
ON posts FOR ALL
USING (auth.uid() = author_id);
-- ✅ 각 동작을 명시적으로 분리
CREATE POLICY "조회" ON posts FOR SELECT ...;
CREATE POLICY "생성" ON posts FOR INSERT ...;
CREATE POLICY "수정" ON posts FOR UPDATE ...;
CREATE POLICY "삭제" ON posts FOR DELETE ...;
3. 정책 없이 service_role로만 운영
service_role 키는 RLS를 우회합니다. 클라이언트에서 service_role 키를 쓰면 RLS가 의미 없어집니다.
// ❌ 절대 클라이언트에서 service_role 사용 금지
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
// ✅ 클라이언트는 anon 키 사용 + RLS로 보호
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
4. 뷰(View)에서 RLS 미적용
-- ❌ 기본 뷰는 postgres 사용자로 생성되어 RLS를 우회
CREATE VIEW post_summaries AS SELECT ...;
-- ✅ security_invoker = true 추가 (Postgres 15 이상)
CREATE VIEW post_summaries
WITH (security_invoker = true)
AS SELECT ...;
5. auth.uid() 반환값이 null인 경우를 처리 안 함
-- ❌ auth.uid()가 null이면 모든 행이 null = null (false)로 처리됨
-- → 접근 불가 (의도치 않은 차단)
USING (auth.uid() = author_id);
-- ✅ 명시적으로 인증 여부 확인
USING (
auth.uid() IS NOT NULL
AND auth.uid() = author_id
);
블로그 서비스 전체 RLS 예시
2편에서 만든 블로그 스키마에 RLS를 완전하게 적용하는 예시입니다.
-- =====================
-- categories 테이블
-- =====================
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
-- 카테고리는 누구나 조회 가능
CREATE POLICY "카테고리 공개 조회"
ON categories FOR SELECT
TO anon, authenticated
USING (true);
-- =====================
-- posts 테이블
-- =====================
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "공개 게시글 조회"
ON posts FOR SELECT
TO anon, authenticated
USING (published = true);
CREATE POLICY "본인 게시글 전체 조회"
ON posts FOR SELECT
TO authenticated
USING ((select auth.uid()) = author_id);
CREATE POLICY "게시글 작성"
ON posts FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = author_id);
CREATE POLICY "본인 게시글 수정"
ON posts FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id)
WITH CHECK ((select auth.uid()) = author_id);
CREATE POLICY "본인 게시글 삭제"
ON posts FOR DELETE
TO authenticated
USING ((select auth.uid()) = author_id);
-- =====================
-- comments 테이블
-- =====================
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "공개 게시글 댓글 조회"
ON comments FOR SELECT
TO anon, authenticated
USING (
post_id IN (SELECT id FROM posts WHERE published = true)
);
CREATE POLICY "댓글 작성"
ON comments FOR INSERT
TO authenticated
WITH CHECK (
(select auth.uid()) = author_id
AND post_id IN (SELECT id FROM posts WHERE published = true)
);
CREATE POLICY "본인 댓글 수정"
ON comments FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id)
WITH CHECK ((select auth.uid()) = author_id);
CREATE POLICY "댓글 삭제"
ON comments FOR DELETE
TO authenticated
USING (
(select auth.uid()) = author_id
OR (select auth.uid()) IN (
SELECT author_id FROM posts WHERE id = post_id
)
);
-- =====================
-- 인덱스 추가 (성능)
-- =====================
CREATE INDEX ON posts (author_id);
CREATE INDEX ON posts (published);
CREATE INDEX ON comments (post_id);
CREATE INDEX ON comments (author_id);
Next.js에서 RLS와 함께하는 올바른 패턴
RLS가 잘 설정되어 있으면 Next.js 코드가 훨씬 단순해집니다.
// app/dashboard/posts/page.tsx — Server Component
// RLS가 자동으로 본인 게시글만 필터링하므로 별도 where 조건 불필요
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function MyPostsPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
// RLS가 author_id = auth.uid() 조건을 자동 적용
const { data: posts } = await supabase
.from('posts')
.select('id, title, published, created_at')
.order('created_at', { ascending: false })
// posts에는 자동으로 본인 게시글만 담김
return (
<ul>
{posts?.map(post => (
<li key={post.id}>
{post.title} {post.published ? '(공개)' : '(비공개)'}
</li>
))}
</ul>
)
}
마치며
RLS는 Supabase 보안의 핵심입니다. 처음에는 복잡하게 느껴지지만, 몇 가지 패턴을 익히고 나면 아주 강력하고 우아한 보안 도구가 됩니다. 특히 “API 레이어가 뚫려도 DB는 안전하다”는 Defense in Depth 개념은 실제 서비스에서 엄청난 안전망이 됩니다.
핵심을 다시 정리하면:
- RLS는 반드시 활성화하고, 활성화하면 반드시 정책도 함께 작성
(select auth.uid())로 성능 최적화FOR ALL대신 각 동작을 분리해서 명시적으로 정의service_role키는 절대 클라이언트에 노출 금지- 정책 작성 후 반드시 테스트
다음 편에서는 Supabase의 실시간 기능(Realtime) 을 다룹니다. WebSocket 기반 실시간 구독으로 채팅, 라이브 대시보드, 협업 도구를 어떻게 만드는지 Next.js 코드와 함께 살펴봅니다.