NestJS Multi-Tenant 설계

멀티테넌시란 무엇인가

멀티테넌시(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로 전환하는 것이 실무에서 가장 현실적인 접근입니다.

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