02 — 멀티테넌트 패턴
한 줄 답: TypeORM에서 멀티테넌트는 두 가지 길로 수렴한다. (A) schema-per-tenant + 동적
DataSource, (B) row-level + 글로벌where필터. *(A)*는 격리는 강하지만 DataSource pool이 폭발하기 쉽고, *(B)*는 가볍지만 모든 쿼리에 tenant 조건이 새지 않도록 강제해야 한다.
Why — 왜 멀티테넌트가 TypeORM의 경계 시험인가
멀티테넌트는 NestJS DI의 singleton 가정을 무너뜨리는 첫 시나리오다. 01장에서 본 forFeature 패턴은 한 DataSource + 모든 요청이 같은 connection을 가정한다. 멀티테넌트는 정확히 그 가정을 깬다.
| 흔한 오해 | 실제 |
|---|---|
”tenant마다 DataSource를 만들면 끝” | connection pool이 tenant 수 × pool size로 폭발한다. tenant 100개 + pool 10 = 1,000 connection — DB가 거부 |
”row-level은 그냥 where: { tenantId } 추가하면 된다” | raw query · join · subquery · subscriber 등 조건이 빠질 수 있는 경로가 너무 많다 — 한 곳만 빠져도 데이터 누출 |
| ”TypeORM에 멀티테넌트 공식 가이드가 있다” | 공식 문서에는 짧은 예시만 있고, production-grade 패턴은 커뮤니티 분산 자료가 대부분 |
이 챕터는 그 분산된 패턴을 두 갈래로 모은다.
How — 어떻게 푸는가
(A) schema-per-tenant + 동적 DataSource
PostgreSQL의 schema 또는 MySQL의 database를 tenant마다 분리하는 방식. 물리적 격리가 강하다.
// src/tenant/tenant.module.ts
@Injectable()
export class TenantDataSourceService {
private readonly cache = new Map<string, DataSource>();
async getDataSource(tenantId: string): Promise<DataSource> {
if (this.cache.has(tenantId)) {
return this.cache.get(tenantId)!;
}
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
database: process.env.DB_NAME,
schema: tenantId, // PostgreSQL의 schema 분리
entities: [User, Order],
// 중요 — pool size를 작게
extra: { max: 5 },
});
await ds.initialize();
this.cache.set(tenantId, ds);
return ds;
}
async onApplicationShutdown() {
for (const ds of this.cache.values()) {
await ds.destroy();
}
}
}// src/tenant/tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly tenants: TenantDataSourceService) {}
async use(req: Request, _res: Response, next: NextFunction) {
const tenantId = req.header('x-tenant-id');
if (!tenantId) throw new BadRequestException('tenant 누락');
req['dataSource'] = await this.tenants.getDataSource(tenantId);
next();
}
}// src/users/users.service.ts — REQUEST scoped
@Injectable({ scope: Scope.REQUEST })
export class UsersService {
constructor(@Inject(REQUEST) private readonly req: Request) {}
private get repo() {
return (this.req['dataSource'] as DataSource).getRepository(User);
}
findAll() {
return this.repo.find();
}
}이 패턴의 비용:
Scope.REQUEST가 전염된다.UsersService를 쓰는 모든 provider도 request-scoped가 되고, 이는 singleton 가정 위에 만든 NestJS 최적화를 깬다.
(B) row-level + 글로벌 필터
모든 테이블에 tenant_id 컬럼을 두고, 쿼리마다 자동으로 필터하는 방식.
@Entity()
export class User extends TenantScopedEntity {
@Column()
email: string;
}
// src/common/entities/tenant-scoped.entity.ts
export abstract class TenantScopedEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'tenant_id' })
@Index() // 모든 쿼리에 들어감 — 인덱스 필수
tenantId: string;
}글로벌 필터는 TypeORM에 내장 기능이 없다. 두 가지 우회법이 있다.
| 방법 | 동작 | 함정 |
|---|---|---|
EntitySubscriber.beforeQuery (없음) | 없다 — Prisma의 $extends나 Sequelize의 defaultScope에 해당하는 게 공식적으론 부재 | 직접 짜야 함 |
| Repository wrapper 패턴 | TenantRepository<T> extends Repository<T>가 모든 메서드를 override해서 where에 tenantId를 자동 주입 | QueryBuilder는 우회 가능 — 누출 위험 |
AsyncLocalStorage + custom find | Node.js 16+의 AsyncLocalStorage에 tenantId를 저장 → custom find 헬퍼가 항상 읽음 | AsyncLocalStorage를 모든 진입점에서 set해야 함 |
// AsyncLocalStorage 패턴 (간략)
import { AsyncLocalStorage } from 'node:async_hooks';
export const tenantStorage = new AsyncLocalStorage<{ tenantId: string }>();
// middleware에서
tenantStorage.run({ tenantId: req.header('x-tenant-id')! }, () => next());
// repository wrapper
export class TenantRepository<T extends TenantScopedEntity> extends Repository<T> {
override find(options: FindManyOptions<T> = {}): Promise<T[]> {
const tenantId = tenantStorage.getStore()?.tenantId;
if (!tenantId) throw new Error('tenant 컨텍스트 누락');
return super.find({
...options,
where: { ...(options.where as any), tenantId } as FindOptionsWhere<T>,
});
}
}이 패턴의 비용:
QueryBuilder나createQueryRunner().query()같은 저레벨 경로에서 tenantId 누락이 일어날 수 있다 — 컴파일타임에 잡히지 않는다. 코드 리뷰와 린트 규칙으로 보강해야 한다.
두 길의 비교
| 기준 | (A) schema-per-tenant | (B) row-level |
|---|---|---|
| 격리 | 물리적(DB schema) | 논리적(컬럼) |
| connection pool | tenant 수 × pool — 폭발 위험 | 단일 pool |
| 백업/복원 | tenant별 독립 | 전체 DB 단위 |
| migration | tenant마다 반복 실행 — 부분 실패 가능 | 한 번만 |
| 쿼리 누락 위험 | (없음 — 다른 schema라 못 봄) | 모든 경로 점검 필요 |
| 적합한 규모 | tenant 수 적고(<100), 격리가 법적 요구 (의료·금융) | tenant 수 많고(>1000), 격리는 논리적으로 충분 |
What — 구체 사양
DataSource 생성 비용
출처: TypeORM 저장소
DataSource.initialize()구현 —pg/mysql2드라이버 connection pool 생성.
DataSource 한 개를 만들면 connection pool이 생긴다. 기본값은 드라이버마다 다르다.
| 드라이버 | 기본 pool size | 비고 |
|---|---|---|
pg (postgres) | max: 10 | TypeORM의 extra: { max }로 조절 |
mysql2 | connectionLimit: 10 | 같은 위치 |
better-sqlite3 | (pool 없음 — 단일 connection) | 멀티테넌트 부적합 |
tenant 100개 + pool 10 = 1,000 active connections. PostgreSQL max_connections 기본값이 100인 점을 고려하면 치명적이다.
동적 DataSource의 destroy 시점
DataSource.destroy()를 호출하지 않으면 pool이 계속 유지된다. tenant가 떠난 뒤에도 connection을 잡고 있다.
// LRU 정책 예
private async evictIfNeeded() {
if (this.cache.size > 50) {
const [oldest] = this.cache.keys();
const ds = this.cache.get(oldest)!;
await ds.destroy();
this.cache.delete(oldest);
}
}@nestjs/typeorm의 forRootAsync + tenant
01장에서 본 forRootAsync는 부트 시점에 한 번만 평가된다. 즉 요청마다 다른 DataSource를 위한 도구가 아니다 — 동적 DataSource는 forRoot 밖에서 직접 관리한다.
EntityManager.transaction 안에서의 멀티테넌트
await manager.transaction(async (txManager) => {
// ⚠️ txManager는 root DataSource의 manager
// tenant DataSource를 쓰려면 그 DataSource의 manager.transaction을 호출해야 함
});이 함정이 멀티테넌트의 가장 흔한 실패 모드다 — Scope.REQUEST 안에서 어떤 manager가 흐르고 있는지가 흐려진다.
What-if — 잘못 이해하면
1) “tenant마다 DataSource를 만들면 격리가 끝”이라고 믿으면
→ pool이 tenant 수 × pool size로 곱해진다. tenant 100·pool 10이면 1,000 connection. PostgreSQL의 max_connections 기본 100을 넘어 DB 거부.
대응: pool size를 작게(2~5), tenant 캐시에 LRU + destroy 정책, 또는 pgbouncer 같은 외부 pooler를 앞에 두기.
2) “row-level + where만 추가하면 안전”이라고 믿으면
→ createQueryBuilder(), dataSource.query(), EntitySubscriber.beforeInsert의 raw 호출 등 우회 경로가 너무 많다. 코드 리뷰에서 한 번이라도 빠지면 데이터 누출.
대응: ESLint custom rule + DB 레벨 tenant_id NOT NULL, 가능하면 PostgreSQL의 *Row-Level Security(RLS)*를 DB에서 강제 — TypeORM이 깜빡해도 DB가 막는다.
3) “Scope.REQUEST를 한 곳만 쓰면 된다”고 믿으면
→ request-scoped provider는 그것을 의존하는 모든 provider를 request-scoped로 전염시킨다. NestJS 공식 문서가 명시적으로 경고하는 부분.
대응: Scope.REQUEST 대신 AsyncLocalStorage로 DI 밖에서 컨텍스트를 흘리기. NestJS DI scope를 건드리지 않음.
4) “schema-per-tenant + migration은 한 번 짜면 끝”이라고 믿으면
→ tenant 100개에 migration을 한 명씩 실행해야 한다. 50번째에서 실패하면 부분 적용된 스키마 상태로 prod이 중간에 깨진다. 대응: migration runner를 직접 짜서 진행 상태를 DB 레코드로 저장, 부분 실패 시 재개 가능하게.
5) “Prisma·Drizzle도 같은 함정”이라고 믿으면
→ 다르다. Prisma는 공식 멀티테넌트 가이드와 RLS 통합 예제가 명시되어 있다(prisma.io/docs/orm/prisma-client/queries/rls). Drizzle은 더 명시적 — connection을 함수처럼 다룬다. TypeORM의 공식 가이드 부재가 이주 동기가 되는 한 축이다.
대응: 멀티테넌트가 핵심 도메인이라면 ORM 선택 자체를 재검토 (05장 참고).
Insight — 흥미로운 이야기
”NestJS 공식 문서의 멀티테넌트 가이드가 최근에야 추가됐다”
docs.nestjs.com/recipes/multitenancy 같은 공식 멀티테넌트 가이드는 오랫동안 부재했다. 커뮤니티 블로그·discord·issue가 유일한 자료였다. 2020년대 초 NestJS conference에서 멀티테넌트가 단골 주제였다는 사실이 공식 자료의 빈자리를 역설적으로 보여준다.
”PostgreSQL RLS를 TypeORM에서 쓰는 사람은 거의 없다”
PostgreSQL의 Row-Level Security는 row-level 멀티테넌트의 DB 차원 정답이다. 하지만 TypeORM에서 RLS를 활용한 production 사례는 공개적으로 거의 없다 — SET LOCAL app.current_tenant = ...를 transaction마다 호출해야 하는데, TypeORM의 QueryRunner 인터페이스로는 깔끔하게 안 된다. Prisma는 공식 가이드가 있다는 점이 비교된다.
”Saas Boilerplate들이 멀티테넌트로 팔린다”
GitHub에서 nestjs-saas-boilerplate로 검색하면 수백 개 저장소가 나온다. 거의 모두가 멀티테넌트를 차별화 포인트로 내세운다 — 즉 TypeORM + NestJS로 직접 짜기 어렵다는 시장 신호. 공식이 비워 둔 자리를 boilerplate들이 채우고 있다.
”동적 DataSource의 풀 폭발 사고는 실제로 흔하다”
NestJS discord와 GitHub issue를 보면 connection limit exceeded 또는 remaining connection slots are reserved 같은 PostgreSQL 에러가 반복적으로 등장한다. 거의 항상 tenant 캐시에 destroy 정책이 없을 때. 이 사고가 production에서 처음 만나는 멀티테넌트 함정이다.
요약 + 다이어그램
멀티테넌트는 NestJS DI의 singleton 가정을 깨는 첫 시나리오다. (A) schema-per-tenant + 동적 DataSource는 격리가 강하지만 pool 폭발이 위험하고, (B) row-level + 글로벌 필터는 가볍지만 쿼리 누락이 위험하다. 어느 길이든 TypeORM은 공식 가이드가 빈약해 — 팀이 직접 짜야 하는 부분이 많다. 이 빈자리가 Prisma·Drizzle 이주 동기의 한 축을 만든다.
참고 자료
- NestJS 공식 —
docs.nestjs.com/fundamentals/injection-scopes - TypeORM 공식, Multiple connections —
typeorm.io/multiple-connections - PostgreSQL RLS —
postgresql.org/docs/current/ddl-rowsecurity.html - Prisma 공식, Row-Level Security guide —
prisma.io/docs/orm/prisma-client/queries/rls - NestJS discord, multi-tenant 주제 (검색)
node:async_hooks.AsyncLocalStorage공식 문서 —nodejs.org/api/async_context.html
다음 문서:
03-soft-delete-and-audit.mdx—@DeleteDateColumn한 줄과 EntitySubscriber로 audit log를 자동화하는 패턴.