B2B SaaS 제품을 개발할 때 가장 치명적인 사고는 A 고객사의 데이터가 B 고객사에게 노출되는 데이터 침해(Data Breach)입니다. 개발자가 모든 SQL 쿼리에 WHERE tenant_id = ? 조건을 누락 없이 작성하기란 현실적으로 불가능에 가깝습니다.
이 글에서는 애플리케이션 레이어가 아닌 데이터베이스 엔진 레벨에서 논리적 격리를 강제하는 PostgreSQL의 Row-Level Security(RLS) 구현 방법을 상세히 다룹니다.
TL;DR — PostgreSQL RLS는 테이블의 행(Row) 단위로 접근 권한을 제어하는 보안 기능으로, 세션 변수와 정책(Policy)을 결합해 애플리케이션 코드 실수에 의한 데이터 유출을 원천 차단합니다.
1. PostgreSQL RLS 개념
💡 비유로 이해하기: RLS는 아파트 공용 현관문이 아니라 각 세대의 현관문 키와 같습니다. 모든 입주민이 같은 복도(Shared Database)를 지나다니지만, 오직 자신이 가진 열쇠(Session ID)로만 자신의 집(Row)에 들어갈 수 있는 원리입니다.
PostgreSQL Row-Level Security(RLS)는 특정 사용자나 역할이 테이블의 어떤 행을 조회, 삽입, 수정, 삭제할 수 있는지 정의하는 보안 계층입니다. 최신 버전인 PostgreSQL 16/17에서도 이 기능은 멀티 테넌시 아키텍처의 핵심 요소로 권장됩니다.
과거에는 공유 데이터베이스(Shared Database) 방식에서 데이터 격리를 위해 복잡한 View를 생성하거나, 애플리케이션 단에서 모든 쿼리를 가로채서 필터를 추가해야 했습니다. 하지만 RLS를 사용하면 데이터베이스가 직접 정책을 검사하므로 보안 로직이 중앙 집중화됩니다.
2. 실무에서 RLS가 필요한 이유
B2B SaaS 환경에서 수천 개의 고객사가 단일 데이터베이스를 공유할 때, tenant_id 누락은 단순한 버그가 아닌 비즈니스 신뢰도 결여로 이어집니다. 특히 복잡한 Join 쿼리가 포함된 레거시 코드에서 개발자가 실수할 확률은 매우 높습니다.
GDPR이나 SOC2와 같은 글로벌 보안 규정을 준수해야 하는 경우, 데이터 격리가 애플리케이션 로직에 의존하지 않고 시스템적으로 보장된다는 점은 매우 강력한 증거가 됩니다. 또한, 데이터베이스 직접 연결을 통한 분석 도구 사용 시에도 실수로 타사 데이터를 조회하는 사고를 방지할 수 있습니다.
3. 단계별 구현 가이드
PostgreSQL RLS를 사용하여 테넌트 격리를 구현하는 표준 절차는 다음과 같습니다.
Step 1. 테이블 생성 및 RLS 활성화
먼저 테넌트를 구분할 tenant_id 컬럼을 포함한 테이블을 생성하고, 기본적으로 비활성화되어 있는 RLS를 명시적으로 활성화합니다.
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
title TEXT,
content TEXT
);
-- RLS 활성화
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
Step 2. 세션 변수를 활용한 정책 정의
애플리케이션은 쿼리를 실행하기 전 SET LOCAL 명령으로 현재 테넌트 ID를 세션에 기록합니다. RLS 정책은 이 값을 참조하여 접근 권한을 판단합니다.
-- 정책 정의: tenant_id가 세션 변수와 일치하는 행만 허용
CREATE POLICY tenant_isolation_policy ON documents
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
Step 3. 애플리케이션 적용 및 검증
데이터베이스 트랜잭션 시작 직후 테넌트 정보를 설정합니다. 이후 실행되는 모든 쿼리는 별도의 WHERE 절 없이도 자동으로 필터링됩니다.
BEGIN;
-- 현재 세션의 테넌트 ID 설정
SET LOCAL app.current_tenant_id = '550e8400-e29b-41d4-a716-446655440000';
-- 자동으로 해당 테넌트 데이터만 조회됨
SELECT * FROM documents;
COMMIT;
4. RLS vs 애플리케이션 필터링 비교
데이터 격리 방식에 따른 장단점을 비교하여 팀의 상황에 맞는 선택을 내려야 합니다.
| 기준 | 애플리케이션 필터링 | PostgreSQL RLS |
|---|---|---|
| 보안 신뢰성 | 개발자 실수에 취약 | 엔진 레벨 강제로 매우 높음 |
| 코드 복잡도 | 모든 쿼리에 필터 추가 필요 | 중앙 정책 관리로 간소화 |
| 성능 오버헤드 | 거의 없음 | 미세한 정책 검사 비용 발생 |
| 유지보수 | 비즈니스 로직에 산재 | DB 스키마로 관리 가능 |
단기적으로 빠른 개발이 목표라면 애플리케이션 필터링이 편할 수 있으나, 중장기적인 보안 안정성과 규제 대응을 고려한다면 RLS 도입이 필수적입니다.
5. 주의사항 및 트러블슈팅
⚠️ 가장 자주 하는 실수: 테이블 소유자(Owner)나 슈퍼유저(Superuser)는 기본적으로 RLS 정책을 무시하고 모든 데이터를 볼 수 있습니다.
애플리케이션이 사용하는 DB 계정은 반드시 테이블 소유자가 아닌 일반 유저 계정이어야 RLS가 정상 작동합니다. 만약 소유자 계정에서도 RLS를 강제하고 싶다면 FORCE ROW LEVEL SECURITY 옵션을 사용해야 합니다.
에러 메시지별 해결법
-- 에러: unrecognized configuration parameter "app.current_tenant_id"
-- 원인: 세션 변수가 설정되지 않은 상태에서 정책이 실행됨
-- 해결: 기본값을 설정하거나 트랜잭션 시작 시 반드시 SET LOCAL 실행
CREATE POLICY ... USING (tenant_id = COALESCE(NULLIF(current_setting('app.current_tenant_id', true), ''), '0000...')::UUID);
6. 실전 팁
RLS를 운영 환경에 적용할 때는 성능 최적화가 필수입니다. tenant_id 컬럼에는 반드시 인덱스를 생성해야 하며, 가능하면 복합 인덱스의 첫 번째 컬럼으로 배치하는 것이 유리합니다.
또한, 커넥션 풀링(Connection Pooling) 환경에서는 세션 변수가 오염될 수 있으므로 반드시 SET LOCAL을 사용하여 해당 트랜잭션이 종료될 때 자동으로 설정이 해제되도록 구현해야 합니다.
📌 핵심 요약
- RLS는 DB 레벨에서 행 단위 접근을 제어하여 테넌트 간 데이터 침범을 막습니다.
SET LOCAL세션 변수와CREATE POLICY를 조합하여 유연하게 격리할 수 있습니다.- 슈퍼유저 계정은 RLS를 우회하므로 애플리케이션 전용 계정을 별도로 운영해야 합니다.
Frequently Asked Questions
Q. RLS 사용 시 성능 저하가 심한가요?
A. 인덱스 설계만 잘 되어 있다면 체감 성능 저하는 1% 미만입니다.
Q. 모든 테이블에 RLS를 적용해야 하나요?
A. 테넌트 식별이 필요한 모든 비즈니스 데이터 테이블에 적용을 권장합니다.
Q. Superuser는 왜 RLS 영향을 안 받나요?
A. 시스템 관리 및 복구를 위한 기본 설계이며, FORCE 옵션으로 제어 가능합니다.
Post a Comment