멀티테넌시란 무엇인가
멀티테넌시(Multi-Tenancy)는 하나의 애플리케이션 인스턴스가 여러 고객(테넌트)의 데이터를 격리하여 서비스하는 아키텍처입니다. SaaS 제품에서 필수적인 패턴이며, NestJS의 DI 시스템과 미들웨어 체인을 활용하면 깔끔하게 구현할 수 있습니다.
3가지 멀티테넌트 전략
멀티테넌트 구현은 데이터 격리 수준에 따라 세 가지로 나뉩니다.
| 전략 | 설명 | 격리 수준 | 비용 |
|---|---|---|---|
| DB per Tenant | 테넌트마다 별도 DB | 최상 | 높음 |
| Schema per Tenant | 같은 DB, 별도 스키마 | 중간 | 중간 |
| Row-Level (공유 테이블) | tenant_id 컬럼으로 구분 | 낮음 | 최저 |
이 글에서는 실무에서 가장 많이 쓰이는 Row-Level 전략과 DB per Tenant 전략을 NestJS로 구현합니다.
테넌트 식별: 미들웨어로 추출
모든 요청에서 테넌트를 식별하는 것이 첫 번째 단계입니다. 헤더, 서브도메인, JWT 등에서 추출할 수 있습니다.
// tenant.middleware.ts
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 방법 1: 커스텀 헤더
const tenantId = req.headers['x-tenant-id'] as string;
// 방법 2: 서브도메인 (acme.app.com → acme)
// const tenantId = req.hostname.split('.')[0];
// 방법 3: JWT 클레임에서 추출
// const tenantId = req.user?.tenantId;
if (!tenantId) {
throw new BadRequestException('Tenant ID is required');
}
// Request 객체에 테넌트 정보 주입
req['tenantId'] = tenantId;
next();
}
}
AsyncLocalStorage로 테넌트 컨텍스트 전파
미들웨어에서 추출한 테넌트 ID를 서비스 레이어까지 전달해야 합니다. cls-hooked나 Node.js 내장 AsyncLocalStorage를 사용하면 파라미터 전달 없이 어디서든 접근할 수 있습니다.
// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';
export interface TenantContext {
tenantId: string;
}
export const tenantStorage = new AsyncLocalStorage<TenantContext>();
// 현재 테넌트 ID를 어디서든 조회
export function getCurrentTenantId(): string {
const ctx = tenantStorage.getStore();
if (!ctx?.tenantId) {
throw new Error('Tenant context not initialized');
}
return ctx.tenantId;
}
// tenant.middleware.ts (AsyncLocalStorage 버전)
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new BadRequestException('Tenant ID is required');
}
// AsyncLocalStorage에 컨텍스트 저장 후 다음 핸들러 실행
tenantStorage.run({ tenantId }, () => next());
}
}
Row-Level 멀티테넌시: Prisma 구현
공유 테이블에 tenant_id 컬럼을 추가하고, 모든 쿼리에 자동으로 필터를 적용하는 방식입니다.
// schema.prisma
model Product {
id Int @id @default(autoincrement())
name String
price Decimal
tenantId String @map("tenant_id")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@map("products")
}
// prisma-tenant.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getCurrentTenantId } from './tenant-context';
@Injectable()
export class PrismaTenantService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
// Prisma Client Extension으로 자동 필터링
this.$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId: getCurrentTenantId() };
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
return query(args);
},
async delete({ args, query }) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
return query(args);
},
},
},
});
}
}
이렇게 하면 서비스 코드에서 tenantId를 명시하지 않아도 모든 쿼리에 자동으로 테넌트 필터가 적용됩니다. Prisma Client Extensions의 query 확장 기능을 활용한 것입니다.
DB per Tenant: 동적 DataSource 전환
테넌트별 완전한 격리가 필요하면, 요청마다 다른 DB에 연결합니다. NestJS의 REQUEST 스코프와 팩토리 프로바이더를 조합합니다.
// tenant-datasource.provider.ts
import { Scope } from '@nestjs/common';
export const TENANT_PRISMA = 'TENANT_PRISMA';
// 테넌트별 DB URL 매핑
const tenantDbUrls: Record<string, string> = {
acme: 'postgresql://user:pass@db:5432/acme_db',
globex: 'postgresql://user:pass@db:5432/globex_db',
};
// 커넥션 풀 캐시 (메모리 누수 방지)
const clientCache = new Map<string, PrismaClient>();
export const TenantPrismaProvider = {
provide: TENANT_PRISMA,
scope: Scope.REQUEST,
useFactory: async (req: Request) => {
const tenantId = req['tenantId'];
const dbUrl = tenantDbUrls[tenantId];
if (!dbUrl) {
throw new Error(`Unknown tenant: ${tenantId}`);
}
// 캐시된 클라이언트 반환 (매 요청마다 새로 만들지 않음)
if (!clientCache.has(tenantId)) {
const client = new PrismaClient({
datasources: { db: { url: dbUrl } },
});
await client.$connect();
clientCache.set(tenantId, client);
}
return clientCache.get(tenantId);
},
inject: ['REQUEST'],
};
// product.service.ts
@Injectable({ scope: Scope.REQUEST })
export class ProductService {
constructor(
@Inject(TENANT_PRISMA) private readonly prisma: PrismaClient,
) {}
async findAll() {
// tenantId 필터 불필요 — 이미 해당 테넌트 DB에 연결됨
return this.prisma.product.findMany();
}
}
Guard로 테넌트 권한 검증
테넌트 식별 후에는 해당 사용자가 그 테넌트에 접근 권한이 있는지 검증해야 합니다.
// tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private readonly tenantService: TenantService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user; // JWT에서 추출된 사용자
const tenantId = request['tenantId'];
// 사용자가 해당 테넌트의 멤버인지 확인
const isMember = await this.tenantService.isUserMember(
user.id,
tenantId,
);
if (!isMember) {
throw new ForbiddenException(
`User ${user.id} is not a member of tenant ${tenantId}`,
);
}
return true;
}
}
// 글로벌 적용
@Module({
providers: [
{ provide: APP_GUARD, useClass: TenantGuard },
],
})
export class AppModule {}
Guard 패턴에 대한 자세한 내용은 NestJS Custom Decorator 심화에서 확인할 수 있습니다.
테넌트별 캐시 격리
캐시 키에 테넌트 ID를 prefix로 붙여야 다른 테넌트의 데이터가 노출되는 사고를 방지합니다.
// tenant-cache.interceptor.ts
@Injectable()
export class TenantCacheInterceptor implements NestInterceptor {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const tenantId = request['tenantId'];
const cacheKey = `${tenantId}:${request.url}`;
const cached = await this.cache.get(cacheKey);
if (cached) {
return of(cached);
}
return next.handle().pipe(
tap(async (data) => {
await this.cache.set(cacheKey, data, 300); // 5분 TTL
}),
);
}
}
테스트: 테넌트 격리 검증
멀티테넌트 시스템에서 가장 중요한 테스트는 크로스 테넌트 데이터 접근 차단입니다.
describe('Multi-Tenant Isolation', () => {
it('테넌트 A는 테넌트 B의 데이터에 접근할 수 없다', async () => {
// 테넌트 A로 상품 생성
await request(app.getHttpServer())
.post('/products')
.set('x-tenant-id', 'tenant-a')
.send({ name: 'Product A', price: 10000 })
.expect(201);
// 테넌트 B로 조회 시 빈 배열
const res = await request(app.getHttpServer())
.get('/products')
.set('x-tenant-id', 'tenant-b')
.expect(200);
expect(res.body).toHaveLength(0);
});
it('테넌트 ID 없이 요청하면 400 에러', async () => {
await request(app.getHttpServer())
.get('/products')
.expect(400);
});
});
운영 시 주의사항
- 마이그레이션: DB per Tenant 전략은 테넌트 수만큼 마이그레이션을 반복 실행해야 함 → 자동화 스크립트 필수
- 커넥션 풀: 테넌트 수가 많으면 DB 커넥션이 폭발 → PgBouncer 같은 커넥션 풀러 도입
- 인덱스: Row-Level 전략에서
tenant_id는 반드시 복합 인덱스에 포함 - 로깅: 모든 로그에
tenantId를 포함해야 디버깅 가능 → Pino 구조화 로깅과 조합 권장 - Rate Limiting: 테넌트별로 API 호출 제한을 분리 적용
NestJS의 미들웨어 → Guard → Interceptor 파이프라인과 AsyncLocalStorage를 조합하면, 서비스 코드를 거의 수정하지 않고도 멀티테넌트를 투명하게 적용할 수 있습니다. Row-Level로 시작하고, 격리 요구사항이 높아지면 DB per Tenant로 전환하는 것이 실무에서 가장 현실적인 접근입니다.