테넌트 격리를 스키마·DB·프로세스 세 층으로 나누어 트레이드오프를 정량화하기
멀티 테넌트 SaaS 초기 설계에서 흔한 출발점은 "tenant_id 컬럼 하나면 되지 않나"라는 판단입니다. 틀린 접근은 아니지만, 엔터프라이즈 고객이 데이터 격리 근거 자료를 요구하거나 HIPAA BAA 서명 요청이 들어올 때 그 출발점이 얼마나 좁았는지가 드러납니다.
테넌트 격리는 단일 선택지가 아니라 스펙트럼입니다. 논리적 경계에서 물리적 경계까지, 스키마·데이터베이스·프로세스 세 레이어에 걸쳐 깊어집니다. 각 레이어를 어디까지 파고들지는 비용, 규제 요건, 테넌트 수, 팀이 감당할 수 있는 운영 부담에 따라 달라집니다.
이 글에서는 세 격리 레이어의 구현 방식을 코드와 함께 살펴보고, 트레이드오프를 가능한 한 숫자와 조건으로 이야기합니다. "격리가 중요합니다"로 끝나는 글이 아니라, 실제 아키텍처 결정을 내릴 때 꺼내 쓸 수 있는 형태로 정리했습니다.
먼저 지도부터 — 세 레이어의 조합 요약
각 레이어를 파고들기 전에 전체 지도를 먼저 봅니다. 이 표는 뒤에서 세부 내용을 채워 넣을 뼈대이자, 팀 상황에 맞는 조합을 미리 감 잡는 기준입니다.
| 레이어 | 대표 옵션 | 격리 강도 | 상대적 비용/복잡도 | 우선 검토 상황 |
|---|---|---|---|---|
| 스키마 | 공유 테이블 + RLS | 논리 (DB 레벨 정책) | 낮음 | SMB 다수, 무료 티어 |
| 스키마 | Schema-per-tenant | 논리 (네임스페이스) | 중간 | 테넌트별 DDL 필요 |
| DB | Silo (전용 인스턴스/브랜치) | 물리 (프로세스·스토리지 분리) | 높음 | 엔터프라이즈, 규제 요건 |
| 프로세스 | K8s Namespace + RBAC | 논리 | 낮음 | 일반 B2B |
| 프로세스 | gVisor / Kata | 강화 (syscall·게스트 커널) | 중~높음 | 신뢰할 수 없는 워크로드 |
| 프로세스 | Firecracker microVM | 물리 (하드웨어 경계) | 높음 | 사용자 코드 실행, AI 샌드박스 |
격리 경계가 지금 다시 논의되는 배경
Serverless DB가 Silo 모델의 고정비를 낮췄다
기존에는 테넌트별 전용 DB 인스턴스는 운영 오버헤드와 고정 비용 때문에 중소 SaaS에서 현실적인 선택지가 아니었습니다. Neon 같은 serverless PostgreSQL은 이 방정식을 바꾸고 있는데, 여기서 한 가지 오해를 미리 정리해둘 필요가 있습니다. Neon의 브랜치는 독립 compute endpoint를 갖되 스토리지 레이어는 Copy-on-Write 방식으로 공유합니다. "완전히 격리된 인스턴스"가 아니라 "격리된 compute + 공유 스토리지"라는 점을 규제 검토 시 반드시 구분해야 합니다. 2025년 5월 Databricks의 Neon 인수는 이 방향에 대한 산업적 베팅으로 읽을 수 있습니다.
사용자 제출 코드 실행이 프로세스 격리를 제품 요구로 끌어올렸다
LLM이 생성한 코드나 사용자가 작성한 스크립트를 테넌트 컨텍스트에서 실행하는 기능이 늘면서, 프로세스 격리는 보안 팀의 관심사에서 제품 기능 설계의 핵심 결정으로 이동하고 있습니다. Firecracker와 gVisor는 원래 AWS Lambda 같은 인프라 서비스 레이어에서 쓰이던 기술이지만, 이제 B2B SaaS 애플리케이션 레이어에서도 채택 사례가 늘어나고 있습니다.
티어드 격리라는 절충안
무료 티어는 공유 스키마로, 엔터프라이즈 티어는 전용 DB로 분기하는 하이브리드 구조는 여러 벤더 백서와 사례 글에서 반복적으로 등장합니다. 대표적으로 AWS SaaS Tenant Isolation Strategies 백서는 Pool·Bridge·Silo 모델을 조합하는 접근을 권장합니다. 이 글의 구조도 세 레이어를 독립적으로 이해하고 조합할 수 있도록 정리합니다.
스키마 격리 — 가장 먼저 결정할 기반
공유 테이블과 RLS (Pool 모델)
모든 테넌트가 동일 테이블을 사용하고, PostgreSQL Row-Level Security로 테넌트 간 접근을 DB 레이어에서 강제합니다. 핵심은 애플리케이션의 WHERE tenant_id = ? 필터에만 의존하지 않는다는 점입니다. 조인 쿼리 하나에서 필터를 빠뜨리면 데이터 노출로 이어지므로, DB 레이어에서 한 번 더 막아두는 다중 방어 계층이 필요합니다.
-- RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 세션 변수 참조하는 격리 정책 (missing_ok=true로 안전하게)
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
-- 테이블 소유자에게도 RLS를 강제 (owner 예외 차단)
-- 주의: BYPASSRLS 특권을 가진 superuser는 이 명령과 무관하게 여전히 우회 가능
ALTER TABLE orders FORCE ROW LEVEL SECURITY;current_setting의 두 번째 인자 true(missing_ok)를 빠뜨리면 세션 변수가 설정되지 않은 상태에서 정책이 평가될 때 unrecognized configuration parameter 오류가 발생합니다. true를 넣으면 미설정 시 NULL이 반환되고, tenant_id = NULL 비교는 항상 거짓이 되어 접근이 차단됩니다.
FORCE ROW LEVEL SECURITY에 대한 흔한 오해도 짚어둡니다. 이 명령은 테이블 소유자에게 RLS를 강제하는 것이지 superuser의 우회를 막지 않습니다. BYPASSRLS 속성을 가진 역할은 여전히 정책을 우회하므로, 애플리케이션 접속용 역할에서 BYPASSRLS를 제거하는 것이 실질적인 방어입니다. 자세한 동작은 PostgreSQL 공식 문서의 CREATE POLICY 항목에 정리되어 있습니다.
-- 쿼리 실행 전 세션에 테넌트 컨텍스트 주입 (트랜잭션 범위)
SET LOCAL app.current_tenant = 'tenant_abc';
SELECT * FROM orders;애플리케이션에서는 요청 처리 시작 시점에 이 세션 변수를 세팅합니다. FastAPI에서는 아래 형태가 dependency로 동작합니다. @app.middleware("http") 데코레이터로 등록하는 미들웨어 패턴과는 다르므로, 사용 위치를 혼동하지 않아야 합니다.
# FastAPI dependency로 테넌트 컨텍스트 주입 (개념적 예시)
async def set_tenant_context(request: Request, db: AsyncSession):
tenant_id = extract_tenant_from_jwt(request)
await db.execute(
text("SET LOCAL app.current_tenant = :tid"),
{"tid": str(tenant_id)},
)
return dbRLS 성능은 어느 정도일까요? 한 측정 사례(PostgreSQL 16, 10M 행, 500 테넌트 조건의 Medium 벤치마크)에서는 단일 행 조회 오버헤드가 2.4%, 3-테이블 조인이 5.9%로 나타났습니다. 다만 이 수치는 인덱스 설계, 하드웨어, 쿼리 패턴에 따라 크게 달라지므로 일반화된 기준치로 받아들이지 말고, 각자 환경에서 재측정할 근거로만 활용하는 것이 맞습니다. 어떤 환경이든 (tenant_id, ...) 복합 인덱스가 오버헤드 흡수의 출발점입니다.
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);이 레이어에서 흔한 실수와 관찰가능성
Pool 모델에서 가장 자주 보이는 실수 두 가지를 이 자리에서 정리하는 편이 흐름상 자연스럽습니다.
애플리케이션 코드에만 의존하는 tenant_id 필터링. WHERE tenant_id = $1을 모든 쿼리에 붙이자는 규칙만으로는 부족합니다. 이것은 단일 방어 계층이며, 새 팀원이 조인을 잘못 쓰면 그대로 유출로 이어집니다. RLS를 DB 레이어에 두는 것이 다중 방어의 기본입니다.
테넌트별 관찰가능성 부재. 집계 지표는 특정 테넌트의 p99가 3초로 튀는 상황을 숨깁니다. OpenTelemetry로 tenant_id를 트레이스·메트릭·로그에 삽입하고 테넌트별 대시보드를 구성하는 것이 격리 설계의 필수 보완재입니다.
# OTEL 계측에 tenant_id 삽입 (개념적 예시)
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
async def handle_request(request):
tenant_id = extract_tenant_from_jwt(request)
with tracer.start_as_current_span("handle_request") as span:
span.set_attribute("tenant.id", tenant_id)
# 비즈니스 로직Schema-per-tenant
동일 DB 인스턴스 내에서 테넌트별로 별도 PostgreSQL 스키마를 할당합니다. RLS보다 네임스페이스 격리가 강하고, 테넌트별 DDL 변경(컬럼 추가, 인덱스 튜닝)이 가능하다는 장점이 있습니다.
CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.orders (
id UUID PRIMARY KEY,
amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
SET search_path TO tenant_abc;테넌트 수가 수천 개를 넘으면 마이그레이션이 고통스러워집니다. 스키마마다 DDL을 순차 적용해야 하므로 마이그레이션 도구가 이를 처리할 수 있어야 하고, 실패 시 부분 상태를 어떻게 복구할지 미리 설계해두어야 합니다.
# 모든 테넌트 스키마에 순차 적용 (개념적 예시)
for schema in get_all_tenant_schemas(db):
run_migration(db, schema_name=schema, migration_file="add_column.sql")DB 격리 — 물리적 경계가 필요한 순간
스키마 격리는 결국 논리적 분리입니다. 같은 DB 프로세스, 같은 커넥션 풀, 같은 스토리지를 공유합니다. Noisy Neighbor — 한 테넌트의 무거운 쿼리가 다른 테넌트의 응답 시간에 영향을 주는 현상 — 는 스키마 레벨로는 완전히 차단되지 않습니다.
Silo 모델과 Serverless DB
전통적 Silo 모델의 문제는 "인스턴스 200개 = 관리 부담 200세트"였습니다. Neon 같은 serverless DB는 유휴 테넌트의 compute 비용을 크게 낮춰 이 부담을 완화합니다. 단, 앞에서 짚었듯 스토리지는 CoW 공유이므로 "물리적 완전 격리"를 규제 근거로 삼을 때는 각 벤더의 격리 모델 문서를 반드시 확인해야 합니다.
# Neon CLI로 테넌트 온보딩 시 브랜치 생성
neon branches create \
--project-id $PROJECT_ID \
--name "tenant-$TENANT_ID" \
--parent main테넌트별 연결 문자열을 별도로 관리해야 하므로 프로비저닝 자동화와 시크릿 관리는 필수입니다. 프로비저닝 자동화를 뒤로 미루면 수동 온보딩·마이그레이션 툴링 미비·감사 추적 부재가 6개월 뒤 기술 부채로 돌아옵니다.
# 테넌트 온보딩 플로우 (개념적 예시)
async def provision_tenant(tenant_id: str):
branch = await neon_client.create_branch(
project_id=PROJECT_ID,
name=f"tenant-{tenant_id}",
)
await secrets_manager.put_secret(
key=f"db/tenant/{tenant_id}/connection_string",
value=branch.connection_string,
)
await run_migrations(branch.connection_string)규제 환경에서의 DB 격리
규제 요건이 격리 단계를 결정하는 경우가 많습니다. HIPAA BAA 의무가 있는 의료 플랫폼이라면 RLS로 쿼리 격리를 강제하는 데서 멈추지 않고, 테넌트별로 다른 KMS 키로 저장 데이터를 암호화해 DB 관리자도 타 테넌트 데이터를 복호화할 수 없도록 설계하는 패턴이 자주 인용됩니다. 참고할 만한 정리는 Building a Multi-Tenant SaaS – RLS, Schema Isolation, and Noisy Neighbor Prevention에 있습니다.
프로세스 격리 — 컴퓨팅 경계까지 분리하기
DB를 격리해도 애플리케이션 프로세스가 공유되면 메모리 오염, 사이드채널, CPU 경합 같은 위협은 남습니다. 특히 테넌트가 제출한 코드를 실행하는 기능이 있다면 프로세스 격리는 선택 문제가 아닙니다.
Kubernetes Namespace 기반 논리 격리
대부분의 B2B SaaS 출발점입니다. Namespace + RBAC + NetworkPolicy + ResourceQuota 조합은 Northflank의 Kubernetes multi-tenancy 정리에서도 표준 패턴으로 소개됩니다.
apiVersion: v1
kind: Namespace
metadata:
name: tenant-abc
labels:
tenant-id: abcapiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-isolation
namespace: tenant-abc
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
tenant-id: abc
egress:
# 자기 namespace 내부 통신
- to:
- namespaceSelector:
matchLabels:
tenant-id: abc
# 주의: 아래 DNS egress 예외가 없으면 Pod가 kube-dns에 접근하지 못해
# 서비스 디스커버리 자체가 불가능해진다. 실제 배포에서는 필수 예외.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-quota
namespace: tenant-abc
spec:
hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16GiOPA Gatekeeper나 Kyverno 같은 Admission Controller를 함께 두면 테넌트 경계 정책을 코드로 강제할 수 있습니다.
Istio Sidecar로 서비스 디스커버리까지 격리
Kubernetes NetworkPolicy가 트래픽 경로를 차단한다면, Istio의 Sidecar 리소스는 테넌트가 타 Namespace의 서비스 존재 자체를 탐색하지 못하도록 막습니다. 구체적 구성은 Oneuptime의 사례 정리에 있습니다.
apiVersion: networking.istio.io/v1beta1
kind: Sidecar
metadata:
name: tenant-sidecar
namespace: tenant-abc
spec:
egress:
- hosts:
- "./*"
- "istio-system/*"Envoy 사이드카는 Pod당 메모리·CPU를 추가로 소비합니다. 관행적으로는 퍼센트가 아니라 Pod당 절대치로 표현하는 것이 정확한데, Envoy 자체 힙에 더해 클러스터·엔드포인트 설정이 로드되면서 실측 메모리가 수십 MB 단위로 증가하고 요청 경로에 hop이 하나 추가되면서 지연도 소폭 늘어납니다. 정확한 수치는 워크로드에 따라 크게 달라지므로 도입 전에 각자 환경에서 부하 테스트로 측정한 뒤 리소스 예산을 계산하는 것을 권장합니다.
Firecracker microVM — 코드 실행 샌드박스
사용자 제출 코드 실행이 요구되면 Firecracker는 현재 가장 검증된 선택지 중 하나입니다. AWS Lambda 자체가 이 방식으로 동작합니다. Firecracker 공식 사이트에 따르면 microVM 시작 시간은 125ms 이하, 오버헤드는 VM당 약 5 MiB 수준으로 보고됩니다. "약 125ms"라는 표현은 상한값을 평균처럼 읽히게 하므로 "125ms 이하"가 정확한 서술입니다.
또한 프로덕션에서는 요청 경로에서 매번 VM을 콜드 부팅하지 않습니다. Lambda를 포함한 프로덕션 시스템은 microVM 풀을 사전 생성(pre-warming) 해두고 요청이 오면 대기 VM을 할당하는 방식으로 부팅 지연을 요청 경로 밖으로 밀어냅니다.
Kata Containers는 경량 VM에 게스트 커널을 두는 방식으로 컨테이너 인터페이스를 유지하면서 하드웨어 경계를 얻습니다. gVisor는 사용자 공간에서 syscall을 인터셉트해 커널 취약점 공격 표면을 줄이는 중간 지점입니다. 세 기술의 특성 비교는 Northflank의 정리를 참고할 만합니다.
트레이드오프를 숫자와 조건으로
스키마 격리 모델 비교
| 모델 | 상대적 비용 | 격리 강도 | 운영 복잡도 | 적합 규모 |
|---|---|---|---|---|
| 공유 테이블 + RLS | 최저 | 논리 (RLS 우회 버그 위험) | 낮음 | 소규모 ~ 수천 테넌트 |
| 스키마 분리 | 중간 | 논리 (네임스페이스) | 중간 | 수백 ~ 수천 테넌트 |
| DB 분리 (Silo) | 최고 | 물리 (프로세스 분리, 스토리지는 벤더 모델에 따라 다름) | 높음 | 엔터프라이즈 |
프로세스 격리 기술 비교
| 기술 | 격리 메커니즘 | 오버헤드 특성 | 주요 용도 |
|---|---|---|---|
| Kubernetes Namespace | RBAC + NetworkPolicy | 거의 없음 | 일반 B2B SaaS |
| gVisor | 사용자 공간 syscall 인터셉트 | syscall 경로 지연 증가 | 신뢰할 수 없는 워크로드 |
| Kata Containers | 경량 VM + 게스트 커널 | VM 부팅·메모리 오버헤드 | HIPAA/PCI-DSS 환경 |
| Firecracker microVM | 하드웨어 수준 격리 (Rust VMM) | 시작 125ms 이하, VM당 약 5 MiB (공식) | 사용자 코드 실행, AI 에이전트 샌드박스 |
결정 흐름 — 팀 상황에서 시작점 고르기
원칙을 나열하는 대신, 실제 결정 흐름으로 정리합니다. 각 분기는 "확정된 답"이 아니라 "우선 검토할 시작점"입니다.
몇 가지 부연 판단 기준입니다.
- 규제가 먼저입니다. HIPAA·PCI-DSS 감사를 논리 격리만으로 통과하기는 어렵습니다. 벤더가 제공하는 격리 모델의 실제 경계(예: Neon의 스토리지 CoW 공유)를 문서화된 근거와 함께 감사인에게 설명할 수 있어야 합니다.
- 코드 실행 요구는 나중에 끼워넣기 어렵습니다. 지금 당장 필요가 없더라도 프로세스 격리 자리를 미리 비워두는 편이 이후 리팩터링 비용을 줄입니다.
- 관찰가능성은 격리와 동시에 설계합니다. 어떤 테넌트가 경계를 압박하는지 볼 수 없다면 격리는 반쪽짜리입니다.
tenant.id속성은 첫날부터 모든 트레이스·메트릭·로그에 실려야 합니다.
참고 자료
- AWS Whitepaper: SaaS Tenant Isolation Strategies
- AWS Database Blog: Multi-Tenant Data Isolation with PostgreSQL Row Level Security
- PostgreSQL 공식 문서: CREATE POLICY
- Firecracker 공식 사이트
- Northflank: Kata Containers vs Firecracker vs gVisor
- Northflank: Kubernetes Multi-Tenancy Guide
- Oneuptime: Tenant Isolation with Istio Sidecar Resources
- Kodekx: Quantitative Performance Benchmarks for Multi-Tenant SaaS (단일 측정 사례)
- Women in Technology: RLS, Schema Isolation, and Noisy Neighbor Prevention
- Bytebase: Multi-Tenant Database Architecture Patterns Explained