멀티테넌트 SaaS 백엔드 구조화 방법
데이터베이스 격리 전략부터 인증, 결제, 기능 플래그까지, 멀티테넌트 SaaS 아키텍처에 대한 실용적인 가이드입니다.

앱이 하나 이상의 조직에 서비스를 제공하기로 결정하는 순간, 모든 아키텍처 결정은 갈림길에 서게 됩니다. 데이터베이스 설계, 인증, 권한 부여, 결제, 심지어 로깅까지 — 이 모든 것이 이제 '어떤 테넌트를 위한 것인가?'라는 질문을 안게 됩니다.
저는 매우 다양한 도메인에 걸쳐 멀티테넌트 백엔드를 출시했습니다. 프로덕션 환경에서 살아남는 패턴은 화이트보드에서 가장 깔끔해 보이는 것과는 거리가 멀었습니다. 이 게시물은 실제로 중요한 결정, 아무도 경고하지 않는 트레이드오프, 그리고 여러분이 활용할 수 있는 구체적인 구현 패턴을 다룹니다.
데이터베이스 격리 전략
이것은 첫 번째이자 가장 중요한 결정입니다. 잘못하면 코드베이스의 모든 쿼리에 영향을 미치는 마이그레이션을 해야 할 수도 있습니다.
세 가지 주요 접근 방식이 있습니다:
| 전략 | 격리 수준 | 복잡성 | 비용 | 최적의 경우 |
|---|---|---|---|---|
| 공유 DB, 공유 스키마 | 낮음 | 낮음 | 가장 낮음 | 초기 단계 SaaS, 100개 미만 테넌트 |
| 공유 DB, 테넌트별 스키마 | 중간 | 중간 | 중간 | 중간 규모, 규제 산업 |
| 테넌트별 데이터베이스 | 높음 | 높음 | 가장 높음 | 엔터프라이즈 고객, 엄격한 규정 준수 |
공유 데이터베이스, 공유 스키마
모든 테넌트의 데이터는 동일한 테이블에 저장됩니다. tenant_id 컬럼으로 행을 구분합니다. 대부분의 SaaS 제품은 여기서 시작해야 합니다.
장점은 단순성입니다. 하나의 마이그레이션 경로, 하나의 연결 풀, 간단한 쿼리. 단점은 WHERE tenant_id = ? 절이 누락되면 데이터 유출로 이어진다는 것입니다. 인프라 수준에서는 안전망이 없으며, 격리는 전적으로 애플리케이션 코드에 달려 있습니다.
이 접근 방식은 대규모 테넌트의 쿼리가 다른 모든 사용자의 성능을 저하시키기 시작하거나, 엔터프라이즈 고객의 규정 준수 팀이 경쟁사의 데이터와 물리적으로 어떻게 분리되어 있는지 묻기 전까지는 작동합니다. 그 시점이 되면 다음 단계로 넘어가야 합니다.
테넌트별 스키마
각 테넌트는 공유 데이터베이스 인스턴스 내에서 자체 PostgreSQL 스키마(또는 이에 상응하는 것)를 가집니다. 테이블은 스키마 간에 동일하지만 물리적으로 분리됩니다. 연결 시 검색 경로를 전환합니다.
이것은 수십 개의 데이터베이스 인스턴스를 관리할 필요 없이 실제 격리를 제공합니다. 마이그레이션은 더 복잡합니다(N번 실행해야 함). 하지만 node-pg-migrate 또는 Prisma의 멀티 스키마 지원과 같은 도구는 이를 합리적으로 잘 처리합니다.
문제는 연결 풀링이 까다로워진다는 것입니다. PgBouncer를 사용한다면, 스키마 전환이 풀링된 연결과 어떻게 상호작용하는지에 대해 신중해야 합니다. 저는 트랜잭션 도중에 연결이 풀로 반환되어 테넌트 A가 일시적으로 테넌트 B의 스키마를 읽는 미묘한 버그를 본 적이 있습니다.
테넌트별 데이터베이스
최대 격리. 각 테넌트는 전용 데이터베이스 인스턴스(또는 최소한 전용 논리 데이터베이스)를 가집니다. 테넌트 컨텍스트에 따라 연결을 동적으로 라우팅합니다.
이것은 비용이 많이 들고 운영상 부담이 크지만, 일부 고객은 기꺼이 비용을 지불할 것입니다. 헬스케어 및 금융 서비스에서 종종 요구됩니다. 격리 외의 주요 이점은 테넌트를 독립적으로 확장, 백업 및 복원할 수 있다는 것입니다. 시끄러운 이웃이 공유 인스턴스를 망가뜨릴 수 없습니다.
실제로 대부분의 팀은 하이브리드 방식을 사용합니다. 대다수에게는 공유 DB를 사용하고, 비용을 지불하는 엔터프라이즈 계정에는 전용 인스턴스를 사용합니다.
인증 및 테넌트 해결
모든 인바운드 요청은 두 가지 질문에 답해야 합니다. 이 사용자는 누구인가? 그리고 이 사용자는 어떤 테넌트에 속하는가?
테넌트 컨텍스트를 해결하기 위한 세 가지 일반적인 전략이 있습니다:
서브도메인 기반: acme.yourapp.com은 Acme 테넌트에 매핑됩니다. 깔끔하고 직관적이며, 각 고객이 자체 작업 공간을 갖는 B2B 제품에 매우 적합합니다. Host 헤더에서 테넌트 슬러그를 추출합니다.
JWT 클레임 기반: 테넌트 ID는 로그인 시 액세스 토큰에 포함됩니다. 인프라 수준의 라우팅이 필요 없습니다. 서브도메인 라우팅이 비실용적인 모바일 앱 및 SPA에 잘 작동합니다.
헤더 기반: 클라이언트가 X-Tenant-ID 헤더를 보냅니다. 간단하지만, 인증된 사용자의 권한에 대해 유효성 검사를 하지 않으면 위험합니다. 내부 서비스 간 호출에만 사용하세요.
대부분의 시스템에서 저는 초기 로그인 흐름에는 서브도메인 해결을 사용하고, 후속 API 호출에는 JWT 클레임을 결합합니다. 미들웨어는 다음과 같습니다:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
이것을 라우트 핸들러 앞에 마운트하면 모든 다운스트림 함수가 req.tenantId에 접근할 수 있습니다. 애플리케이션의 나머지 부분은 테넌트 해결이 어떻게 작동하는지 고민할 필요 없이 요청 컨텍스트에서 ID를 읽기만 하면 됩니다.
모든 데이터베이스 쿼리 스코프 지정
모든 요청에 테넌트 컨텍스트가 확보되면, 모든 쿼리에 적용되도록 보장해야 합니다. 스코프를 놓치면 데이터 유출이 발생합니다. 이것은 개발자의 규율에 맡길 일이 아닙니다.
제가 찾은 가장 좋은 패턴은 스코프를 자동으로 적용하는 리포지토리 계층입니다:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
라우트 핸들러에서는 원시 쿼리를 직접 구성하지 않습니다. 항상 스코프가 적용된 인터페이스를 통해 접근합니다:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
Prisma와 같은 ORM을 사용한다면, 모든 findMany, findFirst, update, delete 호출에 tenant_id 필터를 주입하는 미들웨어를 사용하여 동일한 작업을 수행할 수 있습니다. 요점은 안전하지 않은 경로를 안전한 경로보다 더 어렵게 만드는 것입니다.
테넌트 내 역할 기반 접근 제어
멀티테넌시는 두 계층의 권한 부여 모델을 도입합니다. 첫째: 이 사용자가 이 테넌트에 속하는가? 둘째: 이 테넌트 내에서 무엇을 할 수 있는가?
저는 tenant_memberships 테이블을 사용하여 이를 간단하게 유지합니다:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
역할은 일반적으로 owner, admin, member이며, 때로는 viewer 또는 도메인별 역할이 있습니다. 제품이 정말로 필요하지 않다면 전체 권한 매트릭스를 구축하는 것을 피하세요. 대부분의 B2B SaaS는 3-4개의 역할로 충분합니다.
권한 부여 확인은 테넌트 해결 후에 이루어집니다:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
나중에 골치 아픈 일을 줄여주는 한 가지 설계 선택: 항상 사용자가 여러 테넌트에 속할 수 있도록 허용하세요. 오늘날 제품이 단일 작업 공간이더라도, 에이전시 계정, 화이트 라벨 리셀러 또는 컨설턴트 모드를 추가하는 순간 필요하게 될 것입니다.
테넌트별 기능 플래그
모든 테넌트가 모든 기능을 볼 필요는 없습니다. 때로는 요금제 등급에 따라 기능을 제한하기도 합니다. 때로는 점진적 출시를 진행하기도 합니다. 때로는 엔터프라이즈 고객이 맞춤형 기능에 비용을 지불하기도 합니다.
저는 타사 기능 플래그 서비스를 사용하는 대신 간단한 tenant_features 테이블을 사용합니다. 대부분의 SaaS 제품에는 이것으로 충분합니다:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
라우트 핸들러에서:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
테넌트가 요금제를 업그레이드하면, 관련 기능 행을 전환하고 캐시를 무효화합니다. 간단하고 감사 가능하며 외부 종속성이 없습니다.
결제 통합
Stripe는 합당한 이유로 이 분야의 표준입니다. 매핑이 자연스럽습니다. 테넌트당 하나의 Stripe 고객, 테넌트당 하나의 구독, 요금제 등급은 제품 및 가격에 매핑됩니다.
핵심 설계 규칙: Stripe 고객 ID를 사용자 기록이 아닌 테넌트 기록에 저장하세요. 결제는 신용 카드를 입력한 사람이 아닌 조직에 속합니다.
웹훅 흐름은 다음과 같습니다:
checkout.session.completed— Stripe 고객 ID를 테넌트에 연결하고 구독을 활성화합니다.invoice.paid— 청구 기간을 연장하고tenant.paidUntil을 업데이트합니다.invoice.payment_failed— 테넌트를 연체 상태로 표시하고 독촉 이메일을 보냅니다.customer.subscription.updated— 요금제 등급 변경을 동기화하고, 그에 따라 기능 플래그를 업데이트합니다.customer.subscription.deleted— 무료 등급으로 다운그레이드하거나 정지합니다.
핵심은 멱등성입니다. Stripe는 동일한 웹훅을 여러 번 보낼 수 있습니다. 모든 핸들러는 동일한 이벤트 페이로드로 두 번 실행해도 안전해야 합니다.
데이터 격리 및 보안
쿼리 스코프 외에도, 초기에 제대로 처리해야 할 몇 가지 분명하지 않은 사항들이 있습니다:
행 수준 보안(RLS): PostgreSQL을 사용한다면, RLS를 두 번째 방어선으로 활성화하세요. 애플리케이션 코드에 버그가 있더라도, 데이터베이스 자체는 현재 테넌트 컨텍스트와 일치하지 않는 행을 반환하는 것을 거부할 것입니다. 연결 시 app.current_tenant를 설정하고 이에 대한 정책을 작성하세요.
로깅 컨텍스트: 모든 로그 라인에는 tenantId가 포함되어야 합니다. 새벽 3시에 무언가 고장 났을 때, 요청 추적을 파고들지 않고도 어떤 테넌트가 영향을 받았는지 알아야 합니다. 로거 컨텍스트에 테넌트 필드가 포함된 구조화된 로깅은 이를 자동으로 처리합니다.
백업 및 데이터 삭제: 테넌트는 이탈할 것이고, 일부는 GDPR 또는 데이터 삭제 권한을 행사할 것입니다. 모든 데이터가 공유 테이블에 있다면, 단일 테넌트를 제거하는 것은 모든 테이블에 걸쳐 신중하게 작성된 DELETE 문을 의미합니다. 스키마별 또는 데이터베이스별 테넌트의 경우, DROP SCHEMA 또는 DROP DATABASE입니다. 첫날부터 이를 계획하세요.
테넌트별 속도 제한: 전역 속도 제한기는 충분하지 않습니다. 한 테넌트의 배치 가져오기가 다른 테넌트의 속도 예산을 잠식해서는 안 됩니다. IP나 사용자뿐만 아니라 tenantId별로 속도 제한기를 스코프하세요.
저장 데이터 암호화: 민감한 산업의 경우, 테넌트 데이터를 테넌트별 키로 저장 시 암호화하세요. 이는 키 관리의 복잡성을 증가시키지만, 테넌트의 접근 권한을 취소하는 것이 키를 파괴하는 것만큼 간단하다는 것을 의미합니다. 오프보딩 후 디스크에 읽을 수 있는 데이터가 남아있지 않습니다.
결론
멀티테넌시는 설치하는 라이브러리가 아닙니다. 데이터베이스부터 CDN에 이르기까지 스택의 모든 계층에 영향을 미치는 일련의 아키텍처 결정입니다. 좋은 소식은 패턴이 잘 이해되고 있다는 것입니다. 까다로운 부분은 특정 시장과 단계에 맞는 적절한 수준의 격리를 적용하는 것입니다.
모든 것을 공유하고 규율 있는 스코프 계층으로 시작하세요. 고객이 요구함에 따라 격리 경계를 추가하세요. 아직 없는 규정 준수 요구 사항에 대해 과도하게 설계하지 마세요. 하지만 아키텍처가 재작성 없이 더 엄격한 격리 방향으로 발전할 수 있도록 하세요.
저는 레스토랑 플랫폼부터 의료 SaaS에 이르기까지 멀티테넌트 시스템을 구축했으며, 각 시스템은 고유한 격리 및 확장 요구 사항을 가졌습니다. 이 게시물의 패턴은 이 모든 시스템에서 유효했던 것들입니다.