PostgreSQL Row-Level Security: 멀티테넌트 격리를 DB가 강제하게 만들기
멀티테넌트 SaaS의 최악의 사고는 성능이 아니라 다른 회사의 데이터가 보이는 것입니다. 원인은 대개 한 줄입니다. 어느 쿼리에선가 WHERE tenant_id = ?가 빠진 것. 코드 리뷰와 규율로 막는 데에는 한계가 있습니다. 쿼리는 수백 개고, 새 팀원은 계속 들어오고, 한 번의 누락이 곧 사고입니다. Row-Level Security(RLS)는 이 필터를 애플리케이션의 규율에서 DB의 강제로 옮깁니다. 기초 9편에서 “무결성은 DB 층에서"라고 했던 원칙의 접근 제어판입니다.
기본형: 정책 하나로 시작합니다 #
-- 1) 테이블에 RLS를 켭니다
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 2) 정책: 세션에 설정된 테넌트의 행만 보이고, 만들 수 있습니다
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);이제 이 테이블의 모든 SELECT·UPDATE·DELETE에는 USING 조건이, INSERT·UPDATE의 새 값에는 WITH CHECK 조건이 자동으로 적용됩니다. 애플리케이션 쿼리에 WHERE가 빠져도 다른 테넌트의 행은 존재하지 않는 것처럼 보이고, 남의 테넌트로 행을 만들려는 시도는 에러가 납니다. USING은 “무엇이 보이는가”, WITH CHECK는 “무엇을 쓸 수 있는가"의 구분입니다.
두 가지 전제를 놓치면 RLS는 장식이 됩니다. 첫째, 테이블 소유자와 superuser에게는 기본적으로 정책이 적용되지 않습니다. 애플리케이션이 소유자 계정으로 접속하면 RLS가 통째로 무시되므로, 기초 9편에서 만든 것 같은 비소유자 최소 권한 롤로 접속하는 구조가 선행 조건입니다(소유자에게도 강제하려면 FORCE ROW LEVEL SECURITY). 둘째, 정책이 하나도 없는 RLS 활성 테이블은 전부 거부가 기본값입니다. “켰더니 아무것도 안 보인다"는 정상 동작입니다.
테넌트 지정: current_setting 패턴과 커넥션 풀 #
정책이 참조하는 current_setting('app.tenant_id')는 세션(또는 트랜잭션) 변수입니다. 요청 처리의 시작에서 인증 결과의 테넌트를 심는 것이 애플리케이션의 몫입니다.
BEGIN;
SET LOCAL app.tenant_id = '42'; -- 이 트랜잭션 동안만 유효
-- ... 이 요청의 모든 쿼리 ...
COMMIT;핵심은 SET이 아니라 SET LOCAL입니다. 커넥션 풀을 쓰는 이상 커넥션은 요청들이 돌려 쓰는 공유물이고, 세션 단위 SET은 다음 요청에 남의 테넌트 설정이 남는 사고로 이어집니다. SET LOCAL은 트랜잭션 종료와 함께 사라지므로 PgBouncer transaction 모드와도 궁합이 맞습니다. 대부분의 프레임워크에서 “요청 시작 미들웨어에서 트랜잭션 열고 SET LOCAL"이 표준 형태입니다.
성능: 조건은 단순하게, 인덱스는 필수로 #
RLS의 실체는 “모든 쿼리에 자동으로 붙는 WHERE 조건"이므로, 성능 원칙도 일반 쿼리와 같습니다. tenant_id에 인덱스가 있으면 정책 필터는 평범한 인덱스 조건이고 오버헤드는 미미합니다. 반대로 인덱스가 없으면 모든 쿼리가 테넌트 필터 풀 스캔이 됩니다. 복합 인덱스라면 tenant_id를 선행 컬럼으로 두는 것이 인덱스 기본 규칙과 만나는 지점입니다. 그리고 정책 조건 안에서 서브쿼리나 다른 테이블 조회를 하는 설계는 모든 쿼리에 그 비용을 곱하는 것이므로, 정책은 “현재 행의 컬럼 = 세션 변수” 수준의 단순 비교로 유지하는 것이 정석입니다. 확인은 언제나처럼 EXPLAIN이고, 계획에 정책 조건이 필터로 나타납니다.
RLS가 정답이 아닌 경우 #
- 접근 규칙이 복잡한 비즈니스 로직일 때(역할·상태·소유권이 얽힌 문서 공유 등). 정책이 로직 덩어리가 되면 디버깅과 성능 모두 고통스럽습니다. RLS는 tenant_id 같은 단순 경계에 쓰고, 복잡한 인가는 애플리케이션 층이 맞습니다.
- 테넌트 수가 적고 격리 요구가 극단적일 때는 스키마 분리·DB 분리가 후보입니다. RLS는 “공유 테이블 + 행 격리"라는 중간 지대의 답입니다.
- 분석·배치처럼 전 테넌트를 훑는 경로에는 정책을 우회하는 별도 롤(BYPASSRLS)을 명시적으로 두는 것이 낫습니다. 정책에 예외 분기를 넣어 일반 경로까지 복잡하게 만드는 것보다 깔끔합니다.
정리 #
- RLS는 “WHERE tenant_id 누락” 사고를 코드 규율이 아니라 DB 강제로 막습니다. USING은 가시성, WITH CHECK는 쓰기 검증입니다.
- 소유자·superuser에게는 기본 미적용입니다. 비소유자 최소 권한 롤 접속이 선행 조건입니다.
- 테넌트 지정은 트랜잭션 + SET LOCAL이 표준입니다. 커넥션 풀 환경에서 세션 SET은 오염 사고의 씨앗입니다.
- 성능 조건은 tenant_id 인덱스(복합이면 선행 컬럼)와 단순한 정책 조건입니다. 이것만 지키면 오버헤드는 미미합니다.
- 복잡한 인가 로직·극단적 격리 요구·전 테넌트 배치는 RLS 밖(애플리케이션 층·스키마 분리·BYPASSRLS 롤)이 맞습니다.