핵심 정리
-- 1. RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 2. 정책 생성: 자신의 게시글만 읽기/쓰기
CREATE POLICY posts_owner
ON posts
USING (author_id = current_user_id()) -- SELECT/UPDATE/DELETE 필터
WITH CHECK (author_id = current_user_id()); -- INSERT/UPDATE 검증
-- 3. 세션 컨텍스트 설정 후 쿼리 — 자동으로 WHERE 절이 삽입된다
SET app.current_user_id = '42';
SELECT * FROM posts;
-- 실제 실행: SELECT * FROM posts WHERE author_id = 42문법
RLS는 보안 필터를 쿼리 실행 단계에 강제한다
RLS를 활성화하면 PostgreSQL은 해당 테이블에 대한 SELECT, INSERT, UPDATE, DELETE 실행 시 정책 표현식을 보안 필터처럼 적용한다. 겉으로는 WHERE 조건이 자동으로 붙는 것처럼 이해하면 충분하지만, 핵심은 애플리케이션 코드가 조건을 빠뜨려도 데이터베이스 레벨에서 접근 제어가 강제된다는 점이다.
-- 테이블 및 RLS 설정
CREATE TABLE documents (
id bigserial PRIMARY KEY,
owner_id bigint NOT NULL,
dept_id bigint NOT NULL,
content text,
is_public boolean DEFAULT false
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- 정책 1: 공개 문서는 누구나 볼 수 있다
CREATE POLICY doc_public_read
ON documents
FOR SELECT
USING (is_public = true);
-- 정책 2: 소유자는 자신의 문서를 모두 볼 수 있다
CREATE POLICY doc_owner_all
ON documents
FOR ALL
USING (owner_id = current_setting('app.user_id')::bigint);
-- 여러 정책은 OR로 결합된다 (PERMISSIVE 기본값)
-- 실제 SELECT: WHERE (is_public = true) OR (owner_id = ?)
-- 정책 간 관계: RESTRICTIVE는 AND로 결합
CREATE POLICY doc_dept_restrict
ON documents
AS RESTRICTIVE
FOR SELECT
USING (dept_id = current_setting('app.dept_id')::bigint);
-- 실제 SELECT: WHERE ((is_public OR owner_id=?) AND dept_id=?)RLS는 애플리케이션 WHERE 절을 믿지 않고 DB가 직접 필터를 강제하는 방식이다
RLS의 핵심은 "코드에서 조건을 빼먹어도 DB가 대신 막는다"는 점입니다. 테넌트 분리나 사용자별 접근 제어처럼 실수 비용이 큰 필터를 애플리케이션 코드에만 맡기기 싫을 때 의미가 큽니다.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY posts_owner
ON posts
USING (author_id = current_setting('app.user_id')::bigint);PERMISSIVE(기본값) 정책들은 OR로 결합되어 하나라도 통과하면 접근이 허용된다. RESTRICTIVE 정책은 AND로 결합되어 반드시 통과해야 한다. 두 종류를 혼합하면 (PERMISSIVE 조건들) AND (RESTRICTIVE 조건들)이 된다.
USING은 읽기 필터, WITH CHECK는 쓰기 필터다
USING은 기존 행을 볼 수 있는지 결정한다. SELECT뿐 아니라 UPDATE와 DELETE의 대상 행 필터에도 적용된다. WITH CHECK는 새로 쓰려는 행이 정책을 만족하는지 검증한다. INSERT와 UPDATE의 결과 행에 적용된다.
-- USING만 있는 정책: SELECT/UPDATE/DELETE 필터로만 동작
-- INSERT/UPDATE의 결과 행 검증은 WITH CHECK가 USING과 동일하게 적용
CREATE POLICY orders_read
ON orders
FOR SELECT
USING (customer_id = current_setting('app.user_id')::bigint);
-- WITH CHECK만 있는 정책: INSERT/UPDATE 결과 검증
CREATE POLICY orders_insert
ON orders
FOR INSERT
WITH CHECK (customer_id = current_setting('app.user_id')::bigint);
-- 두 절을 모두 사용하는 패턴 (UPDATE)
CREATE POLICY orders_update
ON orders
FOR UPDATE
USING (customer_id = current_setting('app.user_id')::bigint) -- 수정 대상 필터
WITH CHECK (customer_id = current_setting('app.user_id')::bigint); -- 수정 후 검증
-- 잘못된 예: customer_id를 다른 사용자로 변경 시도
-- USING 통과 (기존 행이 내 행) → WITH CHECK 실패 (결과 행이 다른 사람 소유)
UPDATE orders SET customer_id = 999 WHERE id = 1;
-- ERROR: new row violates row-level security policy for table "orders"WITH CHECK를 생략하면 USING 표현식이 쓰기 검증에도 동일하게 사용된다. INSERT는 기존 행이 없으므로 USING은 적용되지 않고 WITH CHECK만 유효하다.
current_user / current_setting으로 세션 컨텍스트를 정책에 활용하는 패턴
RLS 정책에서 세션 컨텍스트를 전달하는 가장 흔한 방법은 SET LOCAL로 트랜잭션 범위의 설정값을 지정하는 것이다. 애플리케이션 서버가 요청마다 사용자 ID를 세션 변수로 설정하면, 정책 표현식에서 current_setting()으로 읽어 동적 필터를 구성한다.
-- 애플리케이션에서 트랜잭션 시작 시 컨텍스트 설정
BEGIN;
SET LOCAL app.user_id = '42';
SET LOCAL app.org_id = '7';
SET LOCAL app.role = 'member';
-- 정책에서 컨텍스트 참조
CREATE POLICY resource_policy ON resources
USING (
org_id = current_setting('app.org_id')::bigint
AND (
owner_id = current_setting('app.user_id')::bigint
OR current_setting('app.role') = 'admin'
)
);
-- missing_ok 파라미터: 설정이 없을 때 NULL 반환 (오류 방지)
CREATE POLICY safe_policy ON items
USING (
owner_id = current_setting('app.user_id', true)::bigint
);
-- Supabase/PostgREST 패턴: auth.uid() 함수 사용
CREATE POLICY supabase_pattern ON profiles
USING (id = auth.uid());
COMMIT;SET(세션 전체)보다 SET LOCAL(트랜잭션 범위)이 안전하다. 커넥션 풀 환경에서 세션이 재사용될 때 이전 사용자의 컨텍스트가 남아있는 오염을 방지한다.
RLS와 애플리케이션 WHERE 절은 같은 필터처럼 보여도 책임이 다르다
애플리케이션 WHERE 절은 기능 로직이고, RLS는 우회 경로까지 막는 보안 경계입니다. "코드에서도 tenant_id로 거르고 있으니 충분하다"는 접근은 관리자 쿼리, 배치 작업, 실수한 엔드포인트 하나로 쉽게 무너집니다. 테넌트 분리나 사용자별 접근 제한은 가능하면 RLS가 최종 방어선을 맡는 편이 안전합니다.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_tenant_isolation
ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);테이블 소유자와 SUPERUSER는 RLS를 우회한다 — BYPASSRLS 역할 부여 위험
테이블 소유자(owner)와 SUPERUSER 권한을 가진 역할은 RLS 정책을 무시하고 모든 행에 접근할 수 있다. 이를 의도적으로 활용하려면 FORCE ROW LEVEL SECURITY를 사용한다. BYPASSRLS 역할은 비슈퍼유저에게도 RLS 우회를 허용하므로 부여에 주의해야 한다.
-- 테이블 소유자도 RLS를 따르게 강제
ALTER TABLE sensitive_data FORCE ROW LEVEL SECURITY;
-- BYPASSRLS 역할 확인
SELECT rolname, rolbypassrls FROM pg_roles WHERE rolbypassrls = true;
-- 애플리케이션 역할에는 BYPASSRLS를 주지 않는다
CREATE ROLE app_user LOGIN PASSWORD '...';
-- GRANT BYPASSRLS TO app_user; ← 절대 하지 말 것
-- 관리용 역할만 BYPASSRLS 허용
CREATE ROLE admin_user BYPASSRLS LOGIN;
-- RLS 정책 목록 확인
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'documents';선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 사용자별 행 접근을 제한해야 한다 | ENABLE ROW LEVEL SECURITY + CREATE POLICY |
| 읽기 접근만 제어한다 | USING 절만 사용 |
| 쓰기 결과 행을 검증해야 한다 | WITH CHECK 절 추가 |
| 여러 조건 중 하나라도 통과하면 된다 | PERMISSIVE 정책 (기본값, OR 결합) |
| 모든 조건을 반드시 통과해야 한다 | RESTRICTIVE 정책 (AND 결합) |
| 소유자도 RLS를 따르게 해야 한다 | FORCE ROW LEVEL SECURITY |
| 트랜잭션 범위 컨텍스트가 필요하다 | SET LOCAL app.xxx = ... |
주의할 점
ENABLE ROW LEVEL SECURITY 후 정책을 하나도 만들지 않으면 기본 deny-all이 적용되어 아무 행도 반환되지 않는다. 슈퍼유저와 테이블 소유자만 접근 가능하다.
-- ❌ 정책 없이 RLS 활성화 — 모든 비소유자 쿼리가 빈 결과 반환
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 일반 사용자로 쿼리
SET ROLE app_user;
SELECT * FROM orders; -- 0 rows (오류 없이 빈 결과)
-- ✅ RLS 활성화와 동시에 정책을 반드시 정의한다
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_isolation
ON orders
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);SET app.user_id = '42';
SELECT * FROM posts;커넥션 풀 환경에서 세션 변수 정리를 잘못하면 이전 요청의 컨텍스트가 남을 수 있습니다. 이 경우 정책 자체보다 컨텍스트 주입 방식이 더 큰 위험이 되므로, 보통 SET LOCAL을 트랜잭션 범위로 쓰는 편이 안전합니다.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY posts_owner
ON posts
USING (author_id = current_setting('app.user_id')::bigint);FOR SELECT 정책만 두고 쓰기 정책은 따로 만들지 않으면, 읽기는 막아도 INSERT나 UPDATE에 기대한 제약이 걸리지 않을 수 있습니다. 또한 WITH CHECK를 생략한 정책은 같은 명령에서 USING을 재사용할 수는 있어도, 명령 종류를 나눠 설계했다면 읽기와 쓰기 정책이 모두 있는지 반드시 따로 확인해야 합니다.