NestJS 멀티테넌시 설계

NestJS 멀티테넌시란?

하나의 애플리케이션으로 여러 고객(테넌트)을 서비스하는 멀티테넌시(Multi-tenancy)는 SaaS의 핵심 아키텍처입니다. NestJS에서는 요청마다 테넌트를 식별하고, 데이터를 격리하며, 테넌트별 설정을 주입하는 패턴이 필요합니다. 이 글에서는 테넌트 식별 전략, 데이터 격리 3가지 모델, REQUEST 스코프 활용, 동적 데이터소스 전환까지 실전 패턴을 다룹니다.

테넌트 식별 전략

요청에서 테넌트를 식별하는 방법은 3가지입니다.

전략 예시 장점 단점
서브도메인 acme.app.com URL이 깔끔, 브랜딩 가능 DNS·SSL 와일드카드 필요
헤더 X-Tenant-ID: acme 구현 간단, API 친화적 클라이언트 설정 필요
JWT 클레임 { tenantId: “acme” } 인증과 통합, 위변조 방지 토큰 갱신 시 반영

TenantMiddleware: 테넌트 추출

미들웨어에서 테넌트를 추출하여 요청 객체에 주입합니다. NestJS Middleware의 기본 패턴을 따릅니다.

// tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly tenantService: TenantService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    // 전략 1: 서브도메인에서 추출
    const host = req.hostname; // acme.app.com
    const tenantSlug = host.split('.')[0];

    // 전략 2: 헤더에서 추출
    // const tenantSlug = req.headers['x-tenant-id'] as string;

    // 전략 3: JWT에서 추출 (Guard 이후)
    // const tenantSlug = req.user?.tenantId;

    if (!tenantSlug) {
      throw new BadRequestException('Tenant not identified');
    }

    const tenant = await this.tenantService.findBySlug(tenantSlug);
    if (!tenant) {
      throw new NotFoundException(`Tenant '${tenantSlug}' not found`);
    }

    // 요청 객체에 테넌트 정보 주입
    req['tenant'] = tenant;
    next();
  }
}

// 인터페이스 정의
export interface Tenant {
  id: string;
  slug: string;
  name: string;
  databaseName?: string;
  plan: 'free' | 'pro' | 'enterprise';
  config: TenantConfig;
}

// 모듈에 미들웨어 등록
@Module({})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(TenantMiddleware)
      .exclude({ path: 'health', method: RequestMethod.GET })
      .forRoutes('*');
  }
}

AsyncLocalStorage로 테넌트 컨텍스트

Node.js의 AsyncLocalStorage를 사용하면 REQUEST 스코프 없이도 비동기 흐름 전체에서 테넌트 정보에 접근할 수 있습니다. 이 방식이 성능상 더 유리합니다.

// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';

export interface TenantContext {
  tenant: Tenant;
}

export const tenantStorage = new AsyncLocalStorage<TenantContext>();

// 현재 테넌트 조회 헬퍼
export function getCurrentTenant(): Tenant {
  const context = tenantStorage.getStore();
  if (!context) {
    throw new Error('Tenant context not initialized');
  }
  return context.tenant;
}

// 미들웨어에서 AsyncLocalStorage 초기화
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly tenantService: TenantService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const tenantSlug = req.hostname.split('.')[0];
    const tenant = await this.tenantService.findBySlug(tenantSlug);

    if (!tenant) {
      throw new NotFoundException(`Tenant '${tenantSlug}' not found`);
    }

    // AsyncLocalStorage에 컨텍스트 설정
    tenantStorage.run({ tenant }, () => {
      req['tenant'] = tenant;
      next();
    });
  }
}

// 서비스 어디에서든 접근 가능
@Injectable()
export class OrderService {
  async createOrder(dto: CreateOrderDto) {
    const tenant = getCurrentTenant();
    // tenant.id로 데이터 격리
    return this.orderRepository.save({
      ...dto,
      tenantId: tenant.id,
    });
  }
}

데이터 격리 모델 3가지

멀티테넌시의 핵심은 데이터 격리 수준입니다.

모델 격리 수준 비용 적합 시나리오
Row-Level (공유 DB) 낮음 최저 SaaS Free 플랜
Schema-Level 중간 중간 Pro 플랜, 준수 요건
Database-Level 높음 최고 Enterprise, 규제 산업

모델 1: Row-Level 격리

// 모든 엔티티에 tenantId 컬럼 추가
@Entity()
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  @Index()  // 성능을 위한 인덱스 필수
  tenantId: string;

  @Column()
  amount: number;

  @Column()
  status: string;
}

// Repository에서 자동 테넌트 필터링
@Injectable()
export class TenantAwareRepository<T> {
  constructor(
    @InjectRepository(Order) private repo: Repository<T>,
  ) {}

  async find(options?: FindManyOptions<T>): Promise<T[]> {
    const tenant = getCurrentTenant();
    return this.repo.find({
      ...options,
      where: {
        ...options?.where,
        tenantId: tenant.id,
      } as any,
    });
  }

  async save(entity: Partial<T>): Promise<T> {
    const tenant = getCurrentTenant();
    return this.repo.save({
      ...entity,
      tenantId: tenant.id,
    } as any);
  }
}

// TypeORM Global Subscriber로 자동 필터링 (권장)
@EventSubscriber()
export class TenantSubscriber implements EntitySubscriberInterface {
  afterLoad(entity: any) {
    // 로드된 엔티티의 테넌트 검증
    const tenant = getCurrentTenant();
    if (entity.tenantId && entity.tenantId !== tenant.id) {
      throw new ForbiddenException('Cross-tenant access detected');
    }
  }

  beforeInsert(event: InsertEvent<any>) {
    const tenant = getCurrentTenant();
    if (event.entity && 'tenantId' in event.entity) {
      event.entity.tenantId = tenant.id;
    }
  }
}

모델 3: Database-Level 격리

테넌트별 별도 데이터베이스를 사용하는 가장 강력한 격리 모델입니다. NestJS DI Scope와 동적 모듈을 활용합니다.

// 동적 DataSource 관리자
@Injectable()
export class TenantDataSourceManager {
  private dataSources = new Map<string, DataSource>();

  async getDataSource(tenant: Tenant): Promise<DataSource> {
    const existing = this.dataSources.get(tenant.id);
    if (existing?.isInitialized) {
      return existing;
    }

    const dataSource = new DataSource({
      type: 'postgres',
      host: process.env.DB_HOST,
      port: 5432,
      username: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: tenant.databaseName,  // 테넌트별 DB
      entities: [Order, Product, User],
      synchronize: false,
    });

    await dataSource.initialize();
    this.dataSources.set(tenant.id, dataSource);
    return dataSource;
  }

  // 커넥션 풀 정리 (애플리케이션 종료 시)
  async destroyAll() {
    for (const [id, ds] of this.dataSources) {
      if (ds.isInitialized) {
        await ds.destroy();
      }
    }
    this.dataSources.clear();
  }
}

// REQUEST 스코프 프로바이더로 테넌트별 Repository 주입
@Injectable({ scope: Scope.REQUEST })
export class TenantRepository<T extends ObjectLiteral> {
  private repository: Repository<T>;

  constructor(
    @Inject(REQUEST) private request: Request,
    private dataSourceManager: TenantDataSourceManager,
  ) {}

  async getRepo(entity: EntityTarget<T>): Promise<Repository<T>> {
    const tenant = this.request['tenant'] as Tenant;
    const dataSource = await this.dataSourceManager.getDataSource(tenant);
    return dataSource.getRepository(entity);
  }
}

// 서비스에서 사용
@Injectable({ scope: Scope.REQUEST })
export class OrderService {
  constructor(private tenantRepo: TenantRepository<Order>) {}

  async findAll(): Promise<Order[]> {
    const repo = await this.tenantRepo.getRepo(Order);
    return repo.find();  // 이미 테넌트 DB에 연결됨
  }
}

테넌트별 설정 주입

플랜에 따라 기능 제한, Rate Limit, 스토리지 용량 등을 다르게 적용합니다.

// 테넌트 설정 인터페이스
export interface TenantConfig {
  maxUsers: number;
  maxStorage: number;       // MB
  features: string[];       // 활성화된 기능 목록
  rateLimit: number;        // 분당 요청 수
  customDomain?: string;
}

// 플랜별 기본 설정
const PLAN_CONFIGS: Record<string, TenantConfig> = {
  free: {
    maxUsers: 5,
    maxStorage: 100,
    features: ['basic-reports'],
    rateLimit: 60,
  },
  pro: {
    maxUsers: 50,
    maxStorage: 5000,
    features: ['basic-reports', 'advanced-reports', 'api-access'],
    rateLimit: 600,
  },
  enterprise: {
    maxUsers: -1,  // 무제한
    maxStorage: -1,
    features: ['basic-reports', 'advanced-reports', 'api-access', 'sso', 'audit-log'],
    rateLimit: 6000,
  },
};

// Feature Guard: 테넌트 플랜별 기능 제한
@Injectable()
export class FeatureGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredFeature = this.reflector.get<string>(
      'feature', context.getHandler(),
    );
    if (!requiredFeature) return true;

    const tenant = getCurrentTenant();
    const hasFeature = tenant.config.features.includes(requiredFeature);

    if (!hasFeature) {
      throw new ForbiddenException(
        `Feature '${requiredFeature}' requires plan upgrade`,
      );
    }
    return true;
  }
}

// 데코레이터
export const RequireFeature = (feature: string) =>
  SetMetadata('feature', feature);

// 컨트롤러에서 사용
@Post('export')
@RequireFeature('advanced-reports')
@UseGuards(FeatureGuard)
async exportReport(@Body() dto: ExportDto) {
  return this.reportService.export(dto);
}

테넌트 Rate Limiting

// 테넌트별 동적 Rate Limit
@Injectable()
export class TenantThrottlerGuard extends ThrottlerGuard {
  protected async getTracker(req: Request): Promise<string> {
    const tenant = req['tenant'] as Tenant;
    return tenant.id;  // 테넌트 단위로 제한 (IP 대신)
  }

  protected async getLimit(): Promise<number> {
    const tenant = getCurrentTenant();
    return tenant.config.rateLimit;
  }

  protected async getTtl(): Promise<number> {
    return 60000;  // 1분
  }
}

테스트 전략

// 테넌트 컨텍스트를 주입하는 테스트 헬퍼
function withTenant<T>(tenant: Tenant, fn: () => Promise<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    tenantStorage.run({ tenant }, () => {
      fn().then(resolve).catch(reject);
    });
  });
}

describe('OrderService', () => {
  const tenantA: Tenant = { id: 'a', slug: 'acme', name: 'ACME', plan: 'pro', config: PLAN_CONFIGS.pro };
  const tenantB: Tenant = { id: 'b', slug: 'beta', name: 'Beta', plan: 'free', config: PLAN_CONFIGS.free };

  it('테넌트 간 데이터 격리', async () => {
    // 테넌트 A로 주문 생성
    await withTenant(tenantA, async () => {
      await orderService.create({ amount: 100 });
    });

    // 테넌트 B에서는 조회 불가
    await withTenant(tenantB, async () => {
      const orders = await orderService.findAll();
      expect(orders).toHaveLength(0);
    });
  });
});

정리

NestJS 멀티테넌시는 테넌트 식별 → 컨텍스트 전파 → 데이터 격리 → 기능 제한의 4단계로 구성됩니다. AsyncLocalStorage로 비동기 흐름 전체에 테넌트를 전파하고, Row-Level/Schema/Database 격리 모델 중 비용과 보안 요구사항에 맞는 것을 선택합니다. FeatureGuard와 동적 Rate Limit으로 플랜별 차등 서비스를 구현하세요. 가장 중요한 것은 크로스 테넌트 접근 방지입니다. 모든 데이터 접근 경로에서 tenantId 필터링이 누락되지 않도록 Subscriber나 베이스 Repository로 강제화해야 합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux