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()로 버블링 범위를 최소화하여 사용하세요.