PostgreSQL Row Level Security(RLS)로 멀티테넌트 SaaS 구축하기: 코드, 함정, PgBouncer 조합까지
SaaS를 처음 만들 때 가장 두려운 버그 리포트가 뭔지 아시나요? 저는 "A 고객이 B 고객 데이터를 조회할 수 있다"였습니다. 실제로 겪어보지 않아도 상상만으로도 식은땀이 납니다. 문제는, 전통적인 방어법인 WHERE tenant_id = ?는 개발자가 한 번이라도 빠뜨리는 순간 전체 데이터가 노출되는 구조라는 점입니다.
Row Level Security(RLS)는 PostgreSQL 9.5부터 지원되는 기능인데, 솔직히 생각보다 훨씬 적은 코드로 이 문제를 근본적으로 해결할 수 있어서 처음 접했을 때 꽤 충격이었습니다. 이 글을 다 읽고 나면, 30줄짜리 미들웨어 헬퍼 하나로 모든 쿼리에 테넌트 격리를 자동 적용하는 구조를 손에 넣을 수 있습니다. RLS의 핵심 동작 원리부터, 실무에서 자주 놓치는 함정(특히 current_setting 에러와 PgBouncer 조합), Express/Supabase/Drizzle 각 환경별 코드까지 실제로 써먹을 수 있는 수준으로 다룹니다.
핵심 개념
RLS가 실제로 하는 일
일반 WHERE 절과 RLS의 차이는 "누가 책임지느냐"에 있습니다. WHERE 절은 쿼리를 작성하는 개발자의 책임이고, RLS 정책은 쿼리 실행 시 PostgreSQL 엔진이 자동으로 삽입하는 숨겨진 WHERE 절입니다.
예를 들어 SELECT * FROM orders를 날렸을 때, RLS가 활성화되어 있으면 DB 내부에서 실제로 실행되는 쿼리는 이렇게 됩니다:
SELECT * FROM orders
WHERE tenant_id = current_setting('app.current_tenant')::uuid개발자가 WHERE를 빠뜨려도 상관없습니다. DB가 알아서 붙여줍니다.
Fail-closed 설계: RLS를 활성화하고 정책을 아직 정의하지 않은 상태에서
SELECT * FROM orders를 날리면 0건이 반환됩니다. 반대로 전통적인WHERE절 방식은 빠뜨리면 전체 데이터가 노출됩니다. RLS는 "실수해도 안전한" 방향으로 설계되어 있습니다.
어떤 상황에서 RLS가 필요한가
RLS가 빛을 발하는 위치를 이해하려면 멀티테넌시 아키텍처 유형을 먼저 짚어봐야 합니다.
| 유형 | 설명 | RLS 필요성 | 비용 |
|---|---|---|---|
| 테넌트별 독립 DB | 가장 강한 격리 | 불필요 | 매우 높음 |
| 공유 DB + 스키마별 분리 | PostgreSQL 스키마 단위 격리 | 보조적 활용 | 중간 |
| 공유 DB + 공유 테이블 | 하나의 테이블에 모든 테넌트 데이터 | 핵심 필수 | 낮음 |
스타트업 초기에는 세 번째 방식이 현실적입니다. 테넌트가 늘어도 마이그레이션을 한 번만 실행하면 되고, 인프라를 공유하니 비용도 최적화됩니다. RLS가 없다면 이 구조에서 테넌트 격리는 온전히 개발자 실수에 달려 있게 됩니다.
3단계로 RLS 설정하기
저도 처음엔 설정 순서를 헷갈렸는데, 크게 세 단계로 나뉩니다.
1단계: tenant_id 컬럼 추가 및 인덱스 생성
ALTER TABLE orders ADD COLUMN tenant_id uuid NOT NULL;
CREATE INDEX ON orders (tenant_id);2단계: RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 테이블 소유자(owner)도 RLS를 우회하지 못하도록 강제
ALTER TABLE orders FORCE ROW LEVEL SECURITY;FORCE ROW LEVEL SECURITY를 빠뜨리면 테이블 소유자 계정으로 접속했을 때 정책이 무시됩니다. 실무에서 자주 빠뜨리는 부분이라 따로 강조해둡니다.
3단계: 정책 정의
CREATE POLICY tenant_isolation ON orders
USING (
tenant_id = current_setting('app.current_tenant', true)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant', true)::uuid
);USING은 SELECT/UPDATE/DELETE 시 적용되는 조건이고, WITH CHECK는 INSERT/UPDATE로 데이터를 쓸 때 적용됩니다. 둘 다 명시하는 것을 권장합니다.
missing_ok파라미터 주의:current_setting('app.current_tenant')처럼 두 번째 인자 없이 쓰면, 세션에 값이 설정되지 않았을 때unrecognized configuration parameter "app.current_tenant"에러가 발생합니다. 두 번째 인자에true를 넣으면 값이 없을 때 NULL을 반환하고, NULL은 어떤tenant_id와도 매칭되지 않으므로 접근이 차단됩니다. 처음 세팅할 때 에러 원인을 못 찾고 헤매는 경우가 많으니 꼭 기억해두시면 좋겠습니다.
인덱스를 생성했다면 RLS 정책 조건이 실제로 인덱스를 타는지 확인해보는 것도 좋습니다. EXPLAIN ANALYZE SELECT * FROM orders를 실행해서 Index Scan using orders_tenant_id_idx가 보이면 쿼리 플래너가 정책 조건을 인덱스 스캔으로 처리하고 있다는 뜻입니다.
세션 컨텍스트 주입: 가장 중요한 연결 고리
RLS 정책이 아무리 완벽해도, 애플리케이션이 app.current_tenant에 올바른 값을 넣지 않으면 소용없습니다. 이 부분이 실제로 가장 취약한 지점입니다. 아래에서 각 환경별 구현 방법을 살펴봅니다.
실전 적용
예시 1: Express + PostgreSQL — 기본 패턴
가장 기본적인 패턴입니다. 핵심은 executeWithTenant 헬퍼 함수입니다. 트랜잭션 블록 안에서 set_config로 테넌트 ID를 주입하고, 트랜잭션이 끝나면 세션 변수가 자동으로 초기화되어 다음 요청에 이전 테넌트 컨텍스트가 흘러들어가지 않습니다.
// helpers/tenant.ts
import { PoolClient } from 'pg';
export async function executeWithTenant<T>(
client: PoolClient,
tenantId: string,
queryFn: (client: PoolClient) => Promise<T>
): Promise<T> {
await client.query('BEGIN');
try {
await client.query(
`SELECT set_config('app.current_tenant', $1, true)`,
[tenantId]
);
const result = await queryFn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
}
}이 헬퍼를 Express 미들웨어에서 활용하면, 이후 모든 라우트 핸들러에서 테넌트 격리가 자동으로 적용됩니다.
// middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { Pool, PoolClient } from 'pg';
import { executeWithTenant } from '../helpers/tenant';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const tenantId = req.user?.tenantId; // JWT에서 추출된 테넌트 ID
if (!tenantId) {
return res.status(401).json({ error: 'Tenant context missing' });
}
req.db = {
query: async <T>(queryFn: (client: PoolClient) => Promise<T>): Promise<T> => {
const client = await pool.connect();
try {
return await executeWithTenant(client, tenantId, queryFn);
} finally {
client.release();
}
},
};
next();
}| 코드 포인트 | 역할 |
|---|---|
set_config(..., true) |
세 번째 인자 true가 "트랜잭션 로컬" 설정. SET LOCAL과 동일 |
BEGIN 이후 ROLLBACK |
BEGIN 성공 후에만 ROLLBACK을 시도하므로 "트랜잭션 미시작 ROLLBACK" 에러를 방지 |
finally의 client.release() |
성공·실패 무관하게 반드시 실행되어 연결 누수를 방지 |
예시 2: Supabase — JWT Claims 패턴
Supabase를 사용한다면 훨씬 간결하게 연결할 수 있습니다. auth.uid()와 auth.jwt()는 Supabase 전용 내장 함수로, 일반 PostgreSQL 환경에서는 사용할 수 없으니 혼동하지 않도록 주의가 필요합니다.
-- Supabase 전용: auth.uid()로 소속 조직을 조회하는 방식
CREATE POLICY tenant_isolation ON orders
USING (
tenant_id = (
SELECT organization_id
FROM memberships
WHERE user_id = auth.uid()
)
);JWT에 organization_id를 직접 클레임으로 담으면 서브쿼리를 생략할 수 있어서 성능이 더 좋습니다.
-- JWT 클레임에서 organization_id를 직접 읽는 방식
CREATE POLICY tenant_isolation ON orders
USING (
tenant_id = (auth.jwt() ->> 'organization_id')::uuid
);// Supabase 클라이언트 — JWT가 자동으로 RLS에 연결됨
const { data, error } = await supabase
.from('orders')
.select('*');
// WHERE tenant_id = <JWT의 organization_id> 가 자동으로 적용됨Supabase가 자체적으로 세션 컨텍스트를 JWT와 연결해주기 때문에, 별도로 set_config를 호출할 필요가 없습니다. 플랫폼의 편의성이 확실히 느껴지는 부분입니다.
예시 3: Drizzle ORM — TypeScript에서 정책 정의하기
2024년 이후 Drizzle ORM이 RLS를 네이티브로 지원하기 시작했습니다. TypeScript 코드 안에서 직접 정책을 선언하고 마이그레이션으로 반영할 수 있어서, 정책이 코드베이스와 함께 버전 관리됩니다. 새 팀원이 스키마 파일 하나만 봐도 "이 테이블은 RLS로 격리된다"는 사실을 바로 알 수 있어서 실제로 꽤 유용합니다.
// schema/orders.ts
import { pgTable, uuid, text, pgPolicy } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const orders = pgTable(
'orders',
{
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull(),
status: text('status').notNull(),
},
(table) => [
pgPolicy('tenant_isolation', {
as: 'PERMISSIVE',
for: 'ALL',
to: 'authenticated',
using: sql`${table.tenantId} = current_setting('app.current_tenant', true)::uuid`,
withCheck: sql`${table.tenantId} = current_setting('app.current_tenant', true)::uuid`,
}),
]
);as: 'PERMISSIVE'는 정책 결합 방식을 지정합니다. PERMISSIVE(기본값)는 같은 대상에 여러 정책이 있을 때 OR로 결합하고, RESTRICTIVE는 AND로 결합합니다. 정책이 하나뿐일 때는 차이가 없지만, 나중에 역할별 정책을 추가할 때 이 차이가 중요해집니다.
예시 4: PgBouncer 트랜잭션 모드 — 인프라 레이어 주의사항
앞의 세 예시가 "어떤 도구로 RLS를 쓸 것인가"의 문제라면, 이 섹션은 "연결 풀러가 끼면 무엇이 달라지는가"의 문제입니다.
PgBouncer는 다수의 애플리케이션 연결을 소수의 DB 연결로 묶어주는 미들웨어입니다. 트랜잭션 모드로 동작할 때는 커넥션이 여러 클라이언트 간에 재사용되기 때문에, 세션 변수(SET SESSION)가 다음 요청에 흘러들어갈 수 있습니다.
-- 위험한 방식 (PgBouncer 트랜잭션 모드에서)
SET app.current_tenant = 'tenant-uuid'; -- 세션 변수로 설정
SELECT * FROM orders;
-- 커넥션 반환 후에도 세션 변수가 남아서 다음 테넌트 요청이 오염될 수 있음
-- 올바른 방식: 반드시 트랜잭션 블록 안에서 SET LOCAL
BEGIN;
SET LOCAL app.current_tenant = 'tenant-uuid';
SELECT * FROM orders;
COMMIT;
-- 트랜잭션 종료 후 app.current_tenant는 자동으로 초기화됨PgBouncer 모드 선택:
- statement 모드: 트랜잭션 자체를 지원하지 않으므로 RLS와 함께 사용할 수 없습니다.
- 트랜잭션 모드:
SET LOCAL을 트랜잭션 블록 내에서 사용하는 것이 필수입니다.- 세션 모드:
SET SESSION도 사용 가능하지만 연결 풀링 효율이 낮습니다.Supabase를 사용한다면 자체 연결 풀러인 Supavisor가 RLS 컨텍스트를 격리해주므로 이 문제를 따로 신경 쓰지 않아도 됩니다.
이 내용을 처음 알았을 때 실무에서 쓰던 코드를 뒤져봤는데, SET SESSION을 무심코 쓴 곳이 두 군데 있었습니다. 수정하기 전까지는 눈에 보이지 않는 버그가 잠재되어 있던 셈이었죠.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| Fail-closed 보안 | 정책 미정의 시 기본적으로 모든 행 접근 차단. WHERE 절 누락 버그와 달리 "실수해도 안전" |
| 개발자 부담 감소 | 비즈니스 로직 쿼리에서 테넌트 필터링을 매번 신경 쓰지 않아도 됨 |
| 스키마 관리 단순화 | 테넌트 추가 시 마이그레이션 1회로 종료. 스키마 분리 방식 대비 운영 복잡도 대폭 감소 |
| 성능 오버헤드 미미 | 적절한 인덱스가 있으면 일반적으로 무시할 수 있는 수준의 지연만 추가됨 |
| 인프라 비용 최적화 | 수백~수천 테넌트가 같은 인스턴스를 공유 |
단점 및 주의사항
장단점 표에서 제가 실제로 장애로 이어질 뻔했던 건 두 번째 항목, 컨텍스트 전파 취약성이었습니다. 미들웨어 단에서 에러가 나면 조용히 이전 테넌트 값을 그대로 쓰는 상황이 발생하는데, 테스트 환경에서는 잘 잡히지 않아서 원인을 찾는 데 꽤 오래 걸렸습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 슈퍼유저/BYPASSRLS 우회 | BYPASSRLS 속성의 역할은 모든 RLS 정책을 전면 무시함 |
앱 연결 계정은 일반 역할로 제한. FORCE ROW LEVEL SECURITY 적용 필수 |
| 컨텍스트 전파 취약 | 미들웨어 오류 시 컨텍스트 누락 또는 이전 테넌트 값 재사용 위험 | 트랜잭션 블록 + SET LOCAL 패턴 준수. 컨텍스트 누락 시 401 에러로 명시적 차단 |
| 복잡한 로직 불가 | 시간 기반 접근, 외부 API 호출, 워크플로 상태 기반 제어 불가 | Permit.io 같은 애플리케이션 레이어 권한 관리로 보완. RLS는 테넌트 격리에만 집중 |
| 디버깅 어려움 | 정책 실행 로그 확인 어려움. 조용한 실패 가능 | SET row_security = off 후 EXPLAIN ANALYZE로 정책 적용 전후 결과를 비교 |
| 연결 풀링 호환성 | PgBouncer 트랜잭션 모드에서 세션 변수 공유 위험 | SET LOCAL을 트랜잭션 블록 내에서 사용. Supavisor 고려 |
| 테넌트 간 쿼리 불가 | 관리자 대시보드 등 전체 집계 시 별도 관리자 역할 필요 | BYPASSRLS 속성을 가진 전용 관리자 역할을 만들고, 해당 역할의 자격증명을 일반 서비스 계정과 완전히 분리해서 관리 |
BYPASSRLS: PostgreSQL 역할(Role)에 부여할 수 있는 속성입니다. 이 속성이 있는 역할로 접속하면 모든 RLS 정책이 무시됩니다. 크로스-테넌트 집계가 필요한 관리자 전용 역할에만 한정적으로 사용하고, 일반 서비스 계정에는 절대 부여하지 않는 것을 권장합니다.
실무에서 가장 흔한 실수
-
FORCE ROW LEVEL SECURITY누락:ENABLE ROW LEVEL SECURITY만 설정하면 테이블 소유자 계정으로 접속 시 정책이 무시됩니다. 두 명령을 항상 세트로 실행하는 것을 권장합니다. -
SET LOCAL없이 세션 변수 사용: PgBouncer 트랜잭션 모드에서SET SESSION이나 단순SET을 사용하면, 커넥션이 반환된 후 다음 테넌트의 요청이 이전 테넌트 컨텍스트로 실행될 수 있습니다. 반드시 트랜잭션 블록 내에서SET LOCAL을 사용하는 것을 권장합니다. -
WITH CHECK누락:USING절만 정의하면 SELECT/UPDATE/DELETE는 격리되지만, INSERT 시에는 다른 테넌트의tenant_id를 직접 넣어 데이터를 오염시킬 수 있습니다.WITH CHECK를 항상 함께 명시하는 것을 권장합니다.
마치며
RLS는 "WHERE 절을 빠뜨릴 수 없는 구조"를 만들어주는 도구입니다. 애플리케이션 코드가 버그를 만들어도 DB가 마지막 방어선을 지켜줍니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 테이블 하나에 RLS를 켜보는 것부터 시작해볼 수 있습니다. 정책 없이
ALTER TABLE ... ENABLE ROW LEVEL SECURITY만 적용하고SELECT * FROM orders를 날리면 0건이 반환되는데, 이 순간 fail-closed 동작을 직접 확인할 수 있습니다. -
missing_ok파라미터를 포함한 정책을 만들고,executeWithTenant헬퍼를 미들웨어에 통합해볼 수 있습니다. 이 구조가 갖춰지면 이후 모든 쿼리에서 컨텍스트 주입을 별도로 신경 쓰지 않아도 됩니다. -
EXPLAIN ANALYZE SELECT * FROM orders로 정책이 인덱스를 타는지 확인해볼 수 있습니다. 실행 계획에Index Scan using orders_tenant_id_idx와Filter: (tenant_id = current_setting(...))가 함께 보이면 RLS가 올바르게 작동하고 있는 것입니다.
RLS의 기본 구조에 익숙해졌다면, 이 위에 팀 멤버/관리자/뷰어처럼 역할 기반 접근 제어를 얹는 것이 자연스러운 다음 단계입니다.
참고 자료
- Multi-tenant data isolation with PostgreSQL Row Level Security | AWS Blog
- Row-level security recommendations | AWS Prescriptive Guidance
- Shipping multi-tenant SaaS using Postgres Row-Level Security | Nile
- How to Implement PostgreSQL Row Level Security for Multi-Tenant SaaS | TechBuddies
- Row Level Security for Tenants in Postgres | Crunchy Data
- Postgres RLS Implementation Guide: Best Practices and Common Pitfalls | Permit.io
- Postgres Row-Level Security (RLS) Limitations and Alternatives | Bytebase
- Supabase RLS Best Practices: Production Patterns for Secure Multi-Tenant Apps | MakerKit
- Enforcing Row Level Security in Supabase: LockIn's Multi-Tenant Architecture | DEV Community
- Drizzle ORM — Row-Level Security (RLS) 공식 문서
- How to Architect a Multi-Tenant SaaS with PostgreSQL RLS | Skyline Codes
- Why PostgreSQL Row-Level Security Is the Right Approach to Django Multitenancy | DEV Community