Multi-Tenant SaaS Architecture: 구축하기 전에 아무도 알려주지 않는 것

발행: (2026년 3월 30일 AM 04:57 GMT+9)
11 분 소요
원문: Dev.to

I’m ready to translate the article for you, but I’ll need the full text you’d like translated (the body of the post). Could you please paste the content you want converted to Korean? I’ll keep the source line exactly as you provided and preserve all formatting, markdown, and code blocks.

The Three Canonical Patterns

PatternDescriptionProsCons
1. Separate databases per tenant각 테넌트마다 별도의 데이터베이스 인스턴스를 제공합니다.• 완전한 데이터 격리
• 테넌트 간 데이터 누수 위험 없음
• 간편한 오프보딩
• 테넌트별 백업/복구가 간단함
• 프로비저닝 시간 증가
• 연결 풀 관리가 복잡해짐
• 스키마 마이그레이션을 N개의 데이터베이스에 걸쳐 실행해야 함
• 비용이 테넌트 수에 비례해 선형적으로 증가
2. Separate schemas, shared database하나의 데이터베이스 서버에 각 테넌트가 자체 스키마를 갖는 방식 (PostgreSQL‑native).• 별도 인스턴스의 오버헤드 없이 논리적 분리• 연결 풀(PgBouncer 등)은 스키마 수준이 아니라 연결 수준에서 작동함
• 요청당 search_path를 설정해야 함
• 일부 ORM은 이를 원활히 처리하지만, 다른 ORM은 처리하지 못함
• 마이그레이션은 여전히 모든 테넌트에 대해 조정이 필요함
3. Shared schema, shared database (row‑level tenancy)모든 테넌트가 동일한 테이블을 공유하고, 각 행에 tenant_id 컬럼을 둡니다.• 운영 비용이 가장 저렴함
• 마이그레이션이 가장 간단함
• 온보딩 속도가 가장 빠름
tenant_id 필터를 놓칠 경우 우발적인 데이터 누수 위험이 높음
• 강력한 방어 수단으로 **Row‑Level Security (RLS)**가 필요함

3️⃣ 공유‑스키마 접근 방식: 행 수준 보안 적용

If you choose the shared‑schema model, PostgreSQL RLS is not optional—it’s the last line of defence.

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Create a policy that restricts reads to the current tenant
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

요청당 테넌트 컨텍스트 설정

SET app.current_tenant_id = '{{tenant_uuid}}';

Even if application code forgets a tenant_id filter, the database enforces the boundary (defence‑in‑depth).

Cost: current_setting() adds a marginal overhead per query—negligible for most workloads, but benchmark if you run at very high query rates.

애플리케이션이 테넌트를 식별하는 방법은?

접근 방식예시시사점
서브도메인 기반acme.yourapp.comacme를 테넌트로 해석DNS 와일드카드 또는 테넌트별 레코드가 필요하며, 라우팅 복잡성이 증가합니다
맞춤 도메인app.acmecorp.com임의의 도메인을 엣지에서 테넌트 ID와 매핑해야 합니다
경로 기반yourapp.com/t/acme/dashboardDNS 설정은 간단하지만 URL 파싱이 필요합니다
토큰 기반Tenant ID encoded in JWT or session tokenAPI에 적합하지만, 보안 토큰 처리가 필요합니다

대부분의 팀은 접근 방식을 혼합합니다: 메인 UI는 서브도메인, API는 토큰 기반.

마이그레이션 전략

  • 단일 테넌트 앱: 마이그레이션이 한 번 실행됩니다.
  • 멀티 테넌트 (공유 스키마): 마이그레이션이 한 번 실행됩니다.
  • 멀티 테넌트 (스키마‑별 테넌트 또는 데이터베이스‑별 테넌트): 마이그레이션이 N 번 실행됩니다.

멀티 테넌트 마이그레이션 실행기를 위한 요구 사항

  1. 테넌트별 마이그레이션 상태 추적
  2. 마이그레이션을 병렬로 실행 (동시성은 설정 가능)
  3. 실패를 우아하게 처리 – 부분적인 롤아웃을 방지 (예: 일부 테넌트는 버전 7, 다른 테넌트는 버전 8)

검증된 패턴

관리 데이터베이스에 tenant_migrations 테이블을 유지합니다:

tenant_idmigration_versionlast_run_at

배포 파이프라인:

  1. tenant_migrations를 조회하여 현재 버전이 아닌 테넌트를 찾습니다.
  2. 배치 단위로 마이그레이션을 실행합니다 (동시성 제한에 따라 병렬 또는 순차).
  3. 성공 시 테이블을 업데이트하고, 실패 시 재시도를 위해 로그에 기록합니다.

새로운 테넌트 온보딩

모델온보딩 단계일반 지연 시간
공유‑스키마tenants 테이블에 행을 삽입하고, 생성된 ID를 모든 쓰기 작업의 tenant_id 로 사용합니다.거의 즉시 (원자적)
테넌트당‑스키마새 스키마를 생성하고 → 기본 마이그레이션을 실행합니다.작은 스키마는 몇 초, 큰 스키마는 몇 분 (종종 “작업 공간을 준비 중” UI와 함께 비동기 처리)
테넌트당‑데이터베이스새로운 DB 인스턴스를 프로비저닝하고 → 접근 권한을 설정하고 → 마이그레이션을 실행하고 → 라우팅 테이블을 업데이트합니다.몇 분 (백그라운드 작업)

프로비저닝 모델을 기준으로 UX를 설계하세요; 출시 후에 불일치를 발견하지 않도록 하세요.

핵심 요점

멀티테넌시는 인프라 문제가 아니라 데이터 아키텍처 및 애플리케이션 설계 문제입니다.
멀티테넌트 앱은 단일 서버에서 실행할 수도 있고 수백 대에 걸쳐 실행할 수도 있으며, 싱글테넌트 앱도 Kubernetes에서 50개의 레플리카로 실행할 수 있습니다. 격리 모델은 데이터 계층과 애플리케이션 로직에 존재하며, 확장성, 가용성 및 배포는 별개의(하지만 동등하게 중요한) 결정 사항입니다.

추가 읽을거리

  • Actinode 가이드 on multi‑tenant SaaS architecture – 컴플라이언스, 패턴 트레이드오프 및 마이그레이션 전략을 포괄하는 의사결정 매트릭스.

테넌트 격리 옵션

모델범위비용마이그레이션 복잡도최적 대상
별도 데이터베이스전체높음높음엔터프라이즈, 규제 산업
별도 스키마논리적중간중간중간 규모 SaaS
공유 스키마 + RLS행‑수준낮음낮음대용량 B2B, 대부분 스타트업

Note: 여기서 선택한 옵션은 수년간 지속됩니다. 신중하게 결정하세요.

테넌트 격리 테스트

어떤 모델을 선택하든, 명시적인 테스트를 작성해 테넌트 격리가 유지되는지 검증하세요.

  • 단순히 쿼리 로직에 대한 단위 테스트만이 아니라, 통합 테스트를 통해 교차 테넌트 접근 시도를 시뮬레이션하고 데이터베이스 레이어에서 실패하는지 확인합니다.

예시 테스트 스위트

1. 별도 데이터셋을 가진 두 테스트 테넌트를 생성
2. 테넌트 A로 인증
3. 애플리케이션 자체 API 라우트를 통해 테넌트 B의 레코드를 읽으려 시도
4. 응답에 테넌트 B 레코드가 전혀 포함되지 않았는지 검증
  • 이 테스트는 모든 병합 시 CI 파이프라인에서 실행되어야 합니다.
  • 테넌트 격리 실패는 뉴스에 오를 정도로 심각한 버그이며, CI에서 잡는 비용은 프로덕션에서 발견했을 때의 비용에 비해 매우 낮습니다.

RLS 정책 검증

-- RLS 정책이 활성화된 상태로 연결
SET app.current_tenant_id = '<tenant_uuid>';

-- 테넌트 B의 행을 직접 SELECT 시도
SELECT * FROM sensitive_table WHERE tenant_id = '<other_tenant_uuid>';
  • RLS가 정상적으로 동작한다면, 쿼리는 0행을 반환합니다.
  • 만약 행이 반환된다면, 정책에 빈틈이 있는 것입니다.

핵심 요약

격리는 정확성 속성이며, 단순히 설계 선호도가 아닙니다.
그것을 정확성 속성처럼 테스트하세요.

0 조회
Back to Blog

관련 글

더 보기 »