🗄️ TypeORM9. 실전 사례02 — 멀티테넌트 패턴

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해서 wheretenantId를 자동 주입QueryBuilder우회 가능 — 누출 위험
AsyncLocalStorage + custom findNode.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>,
    });
  }
}

이 패턴의 비용: QueryBuildercreateQueryRunner().query() 같은 저레벨 경로에서 tenantId 누락이 일어날 수 있다 — 컴파일타임에 잡히지 않는다. 코드 리뷰와 린트 규칙으로 보강해야 한다.

두 길의 비교

기준(A) schema-per-tenant(B) row-level
격리물리적(DB schema)논리적(컬럼)
connection pooltenant 수 × pool — 폭발 위험단일 pool
백업/복원tenant별 독립전체 DB 단위
migrationtenant마다 반복 실행 — 부분 실패 가능한 번
쿼리 누락 위험(없음 — 다른 schema라 못 봄)모든 경로 점검 필요
적합한 규모tenant 수 적고(<100), 격리가 법적 요구 (의료·금융)tenant 수 많고(>1000), 격리는 논리적으로 충분

What — 구체 사양

DataSource 생성 비용

출처: TypeORM 저장소 DataSource.initialize() 구현 — pg/mysql2 드라이버 connection pool 생성.

DataSource 한 개를 만들면 connection pool이 생긴다. 기본값은 드라이버마다 다르다.

드라이버기본 pool size비고
pg (postgres)max: 10TypeORM의 extra: { max }로 조절
mysql2connectionLimit: 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/typeormforRootAsync + 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 대신 AsyncLocalStorageDI 밖에서 컨텍스트를 흘리기. 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 connectionstypeorm.io/multiple-connections
  • PostgreSQL RLS — postgresql.org/docs/current/ddl-rowsecurity.html
  • Prisma 공식, Row-Level Security guideprisma.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를 자동화하는 패턴.