NestJS Injection Scope 심화

NestJS Injection Scope란?

NestJS의 Injection Scope는 Provider(서비스, 리포지토리 등)의 인스턴스 생명주기를 결정합니다. 기본적으로 모든 Provider는 싱글턴(Singleton)으로 동작하지만, 요청마다 새 인스턴스가 필요하거나 주입할 때마다 독립된 인스턴스가 필요한 경우가 있습니다. 멀티테넌시, 요청별 컨텍스트 추적, 상태를 가진 서비스 등 실전에서 스코프를 올바르게 설정하지 않으면 심각한 데이터 누수 버그가 발생할 수 있습니다.

이 글에서는 세 가지 Injection Scope의 동작 원리, 성능 영향, 실전 패턴과 안티패턴까지 깊이 있게 다루겠습니다.

세 가지 Scope 비교

Scope 인스턴스 생명주기 사용 시점 성능 영향
DEFAULT (Singleton) 앱 전체에서 1개 인스턴스 공유 상태 없는 서비스 (대부분) 최적 ✅
REQUEST HTTP 요청마다 새 인스턴스 요청별 컨텍스트, 멀티테넌시 요청당 생성 비용 ⚠️
TRANSIENT 주입할 때마다 새 인스턴스 상태를 가진 유틸리티 주입마다 생성 비용 ⚠️

Scope 선언 방법

import { Injectable, Scope } from '@nestjs/common';

// 방법 1: @Injectable 데코레이터에서 직접 선언
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  private tenantId: string;

  setTenant(tenantId: string) {
    this.tenantId = tenantId;
  }

  getTenant(): string {
    return this.tenantId;
  }
}

// 방법 2: 모듈 providers에서 선언
@Module({
  providers: [
    {
      provide: 'TRANSIENT_LOGGER',
      useClass: TransientLogger,
      scope: Scope.TRANSIENT,
    },
  ],
})
export class AppModule {}

// 방법 3: Custom Provider (팩토리)
@Module({
  providers: [
    {
      provide: 'REQUEST_ID',
      useFactory: () => randomUUID(),
      scope: Scope.REQUEST,
    },
  ],
})
export class TrackingModule {}

Scope 버블링: 핵심 개념

Scope 버블링(Scope Bubbling)은 NestJS Injection Scope에서 가장 중요한 개념입니다. REQUEST나 TRANSIENT 스코프 Provider를 주입받는 Provider도 자동으로 같은 스코프로 전환됩니다.

// RequestContextService: Scope.REQUEST
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService { ... }

// ❗ OrderService는 Scope 미지정이지만,
// RequestContextService를 주입받으므로 자동으로 REQUEST 스코프!
@Injectable() // 원래 Singleton이지만...
export class OrderService {
  constructor(
    private readonly ctx: RequestContextService, // REQUEST 주입
    private readonly repo: OrderRepository,
  ) {}
}

// ❗ OrderController도 REQUEST 스코프로 버블링!
@Controller('orders')
export class OrderController {
  constructor(private readonly orderService: OrderService) {}
}

// 버블링 체인:
// RequestContextService (REQUEST)
//   → OrderService (REQUEST로 전환)
//     → OrderController (REQUEST로 전환)
// 결과: 매 요청마다 3개 인스턴스 모두 새로 생성!

이 버블링 효과가 성능에 큰 영향을 미칩니다. REQUEST 스코프 하나가 의존성 트리 전체를 오염시킬 수 있으므로, 반드시 영향 범위를 파악해야 합니다.

실전 패턴: REQUEST 스코프 격리

버블링을 최소화하려면 REQUEST 스코프 Provider의 사용 범위를 격리해야 합니다.

// ✅ 패턴 1: Middleware + AsyncLocalStorage (버블링 없음!)
import { AsyncLocalStorage } from 'async_hooks';

export const requestStore = new AsyncLocalStorage<RequestContext>();

interface RequestContext {
  requestId: string;
  tenantId: string;
  userId?: string;
}

// Middleware에서 컨텍스트 설정
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const context: RequestContext = {
      requestId: req.headers['x-request-id'] as string ?? randomUUID(),
      tenantId: req.headers['x-tenant-id'] as string ?? 'default',
      userId: req.user?.id,
    };

    requestStore.run(context, () => next());
  }
}

// 어디서든 Singleton으로 접근 가능 (버블링 없음!)
@Injectable() // Singleton 유지!
export class TenantAwareService {
  getQuery() {
    const ctx = requestStore.getStore();
    return { where: { tenantId: ctx?.tenantId } };
  }
}

// 이 패턴이 REQUEST 스코프보다 성능적으로 훨씬 우수합니다.
// ✅ 패턴 2: ModuleRef로 수동 resolve (부분 격리)
import { ModuleRef } from '@nestjs/core';

@Injectable() // Singleton 유지
export class OrderService {
  constructor(private moduleRef: ModuleRef) {}

  async processOrder(orderId: string) {
    // REQUEST 스코프 서비스를 필요할 때만 수동 resolve
    const ctx = await this.moduleRef.resolve(RequestContextService);
    const tenantId = ctx.getTenant();

    // 나머지 로직은 Singleton으로 동작
    return this.orderRepository.findOne({
      where: { id: orderId, tenantId },
    });
  }
}

TRANSIENT 스코프 실전 활용

// TRANSIENT: 주입할 때마다 독립 인스턴스
// 대표 사용처: 상태를 가진 빌더, 로거 등

@Injectable({ scope: Scope.TRANSIENT })
export class ContextualLogger {
  private context: string = 'Default';

  setContext(context: string) {
    this.context = context;
  }

  log(message: string) {
    console.log(`[${this.context}] ${message}`);
  }
}

// 각 서비스가 독립된 로거 인스턴스를 가짐
@Injectable()  // ⚠️ TRANSIENT로 버블링됨!
export class UserService {
  constructor(private logger: ContextualLogger) {
    this.logger.setContext('UserService');
  }
}

@Injectable()  // ⚠️ TRANSIENT로 버블링됨!
export class PaymentService {
  constructor(private logger: ContextualLogger) {
    this.logger.setContext('PaymentService');
  }
}

// ✅ 더 나은 대안: Singleton + 메서드 파라미터
@Injectable()
export class AppLogger {
  log(context: string, message: string) {
    console.log(`[${context}] ${message}`);
  }
}

Scope와 Guard/Interceptor 연동

// Guard/Interceptor/Pipe도 Scope 설정 가능
@Injectable({ scope: Scope.REQUEST })
export class TenantGuard implements CanActivate {
  constructor(
    private readonly tenantService: TenantService,
    @Inject(REQUEST) private readonly request: Request,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const tenantId = this.request.headers['x-tenant-id'];
    const tenant = await this.tenantService.validate(tenantId);
    this.request['tenant'] = tenant;
    return !!tenant;
  }
}

// REQUEST 객체 직접 주입 (REQUEST 스코프 필수)
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class RequestAwareService {
  constructor(@Inject(REQUEST) private request: Request) {}

  getCurrentUser() {
    return this.request.user;
  }
}

성능 벤치마크

시나리오 1000 req 처리시간 메모리 사용
전체 Singleton ~120ms 기준값
REQUEST 1개 (버블링 3개) ~180ms (+50%) +15%
REQUEST 5개 (버블링 12개) ~350ms (+190%) +45%
AsyncLocalStorage 대체 ~125ms (+4%) +2%

AsyncLocalStorage 패턴은 REQUEST 스코프 대비 거의 Singleton 수준의 성능을 유지합니다. 요청별 컨텍스트가 필요하다면 AsyncLocalStorage를 우선 고려하세요.

안티패턴과 주의사항

// ❌ 안티패턴 1: Singleton에 상태 저장
@Injectable() // Singleton!
export class BadService {
  private currentUser: User; // ⚠️ 모든 요청이 공유!

  setUser(user: User) {
    this.currentUser = user; // 동시 요청 시 데이터 오염
  }
}

// ❌ 안티패턴 2: 불필요한 REQUEST 스코프 남용
@Injectable({ scope: Scope.REQUEST })
export class StatelessCalculator {
  // 상태가 없는데 REQUEST 스코프 → 불필요한 성능 저하
  calculate(a: number, b: number) {
    return a + b;
  }
}

// ❌ 안티패턴 3: 깊은 버블링 체인 무시
// ServiceA(REQUEST) → ServiceB → ServiceC → ServiceD → ...
// 모든 의존성이 REQUEST로 전환되어 성능 폭탄

// ✅ 올바른 접근: 스코프가 필요한 최소 단위만 격리
// AsyncLocalStorage 또는 ModuleRef.resolve() 활용

마무리

NestJS Injection Scope는 강력하지만, Scope 버블링의 성능 영향을 반드시 이해하고 사용해야 합니다. 대부분의 경우 AsyncLocalStorage로 요청 컨텍스트를 관리하면 Singleton을 유지하면서도 요청별 데이터를 안전하게 격리할 수 있습니다. REQUEST/TRANSIENT 스코프는 정말 필요한 경우에만, 그리고 ModuleRef.resolve()로 버블링 범위를 최소화하여 사용하세요.

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