주입 스코프 (Injection Scopes) — 프로바이더의 생명주기를 조절하는 방법
주입 스코프 (Injection Scopes) — 프로바이더의 생명주기를 조절하는 방법
다른 프로그래밍 언어 배경을 가진 분들은 Nest에서 거의 모든 것이 들어오는 요청들 사이에서 공유된다는 사실이 조금 의외일 수 있어요. 데이터베이스에 대한 연결 풀이 있고, 전역 상태를 가진 싱글톤 서비스도 있죠. Node.js는 각 요청을 별도 스레드가 처리하는 요청/응답 멀티스레드 무상태 모델을 따르지 않는다는 점을 기억하세요. 그래서 싱글톤 인스턴스를 사용하는 것은 우리 애플리케이션에 완전히 안전해요.
하지만 요청 기반 수명이 오히려 원하는 동작인 경우도 있어요 — GraphQL 애플리케이션의 요청별 캐싱, 요청 추적, 멀티 테넌시 같은 경우죠. 주입 스코프(injection scopes)는 원하는 프로바이더 수명 동작을 얻을 수 있는 메커니즘을 제공해요. 이번엔 프로바이더의 수명을 어떻게 조절하는지 자세히 살펴볼게요. ⏱️
본문
프로바이더 스코프(Provider scope)
프로바이더는 다음 중 하나의 스코프를 가질 수 있어요.
DEFAULT— 프로바이더의 단일 인스턴스가 애플리케이션 전체에서 공유돼요. 인스턴스 수명은 애플리케이션 수명 주기에 직접 연결돼요. 애플리케이션이 부트스트랩되면 모든 싱글톤 프로바이더가 인스턴스화됐어요. 싱글톤 스코프가 기본으로 사용돼요.REQUEST— 들어오는 각 요청마다 프로바이더의 새 인스턴스가 전용으로 생성돼요. 요청 처리가 완료된 후 인스턴스는 가비지 컬렉션돼요.TRANSIENT— 일시적(transient) 프로바이더는 소비자 간에 공유되지 않아요. 일시적 프로바이더를 주입하는 각 소비자는 새롭고 전용 인스턴스를 받아요.
💡 대부분의 사용 사례에서 싱글톤 스코프의 사용이 권장돼요. 소비자와 요청 전반에 걸쳐 프로바이더를 공유한다는 것은 인스턴스를 캐시할 수 있고 초기화가 애플리케이션 시작 중에 단 한 번만 발생한다는 뜻이에요.
사용법(Usage)
@Injectable() 데코레이터 옵션 객체에 scope 속성을 넘겨 주입 스코프를 지정해요.
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {}
마찬가지로 커스텀 프로바이더의 경우, 프로바이더 등록의 긴 형식(long-hand form)에서 scope 속성을 설정해요.
{
provide: 'CACHE_MANAGER',
useClass: CacheManager,
scope: Scope.TRANSIENT,
}
💡
Scopeenum은@nestjs/common에서 import 해요.
싱글톤 스코프는 기본으로 사용되므로 선언할 필요가 없어요. 프로바이더를 싱글톤 스코프로 선언하고 싶다면 scope 속성에 Scope.DEFAULT 값을 사용해요.
⚠️ WebSocket 게이트웨이는 싱글톤으로 동작해야 하므로 요청 스코프 프로바이더를 사용하면 안 돼요. 각 게이트웨이는 실제 소켓을 캡슐화하며 여러 번 인스턴스화될 수 없어요. 이 제한은 Passport 전략이나 Cron 컨트롤러 같은 일부 다른 프로바이더에도 적용돼요.
컨트롤러 스코프(Controller scope)
컨트롤러도 스코프를 가질 수 있는데, 그 컨트롤러에 선언된 모든 요청 메서드 핸들러에 적용돼요. 프로바이더 스코프와 마찬가지로 컨트롤러의 스코프는 그 수명을 선언해요. 요청 스코프 컨트롤러의 경우 들어오는 각 요청마다 새 인스턴스가 생성되고, 요청 처리가 완료되면 가비지 컬렉션돼요.
ControllerOptions 객체의 scope 속성으로 컨트롤러 스코프를 선언해요.
@Controller({
path: 'cats',
scope: Scope.REQUEST,
})
export class CatsController {}
스코프 계층(Scope hierarchy)
REQUEST 스코프는 주입 체인을 따라 위로 전파돼요. 요청 스코프 프로바이더에 의존하는 컨트롤러는 그 자체도 요청 스코프가 돼요.
다음 의존성 그래프를 상상해 보세요: CatsController <- CatsService <- CatsRepository. CatsService가 요청 스코프라면(나머지는 기본 싱글톤) CatsController는 주입된 서비스에 의존하므로 요청 스코프가 돼요. 의존하지 않는 CatsRepository는 싱글톤 스코프로 남아요.
일시적(transient) 스코프 의존성은 그 패턴을 따르지 않아요. 싱글톤 스코프 DogsService가 일시적 LoggerService 프로바이더를 주입하면 그 프로바이더의 새 인스턴스를 받아요. 그러나 DogsService는 싱글톤 스코프로 남아서, 어디에 주입해도 새 DogsService 인스턴스로 해결되지는 않아요. 그런 동작이 원하는 경우라면 DogsService도 명시적으로 TRANSIENT로 표시해야 해요.
요청 프로바이더(Request provider)
HTTP 서버 기반 애플리케이션(예: @nestjs/platform-express 또는 @nestjs/platform-fastify 사용)에서 요청 스코프 프로바이더를 사용할 때 원래 요청 객체에 대한 참조에 접근하고 싶을 수 있어요. 이때 REQUEST 객체를 주입하면 돼요.
REQUEST 프로바이더는 본질적으로 요청 스코프예요. 즉 사용할 때 REQUEST 스코프를 명시적으로 지정할 필요가 없어요. 게다가 시도하더라도 무시돼요. 요청 스코프 프로바이더에 의존하는 어떤 프로바이더든 자동으로 요청 스코프를 채택하며, 이 동작은 변경할 수 없어요.
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(REQUEST) private request: Request) {}
}
기반 플랫폼/프로토콜 차이 때문에 마이크로서비스나 GraphQL 애플리케이션에서는 들어오는 요청에 약간 다르게 접근해요. GraphQL 애플리케이션에서는 REQUEST 대신 CONTEXT를 주입해요.
import { Injectable, Scope, Inject } from '@nestjs/common';
import { CONTEXT } from '@nestjs/graphql';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(CONTEXT) private context) {}
}
그런 다음 GraphQLModule에서 context 값이 request를 속성으로 포함하도록 구성해요.
인콰이어러 프로바이더(Inquirer provider)
로깅이나 메트릭 프로바이더처럼, 프로바이더가 구성된 클래스를 알고 싶을 때 INQUIRER 토큰을 주입할 수 있어요.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { INQUIRER } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class HelloService {
constructor(@Inject(INQUIRER) private parentClass: object) {}
sayHello(message: string) {
console.log(`${this.parentClass?.constructor?.name}: ${message}`);
}
}
그런 다음 다음과 같이 사용해요.
import { Injectable } from '@nestjs/common';
import { HelloService } from './hello.service.js';
@Injectable()
export class AppService {
constructor(private helloService: HelloService) {}
getRoot(): string {
this.helloService.sayHello('My name is getRoot');
return 'Hello world!';
}
}
위 예시에서 AppService#getRoot가 호출되면 콘솔에 "AppService: My name is getRoot"가 기록돼요.
성능(Performance)
요청 스코프 프로바이더를 사용하면 애플리케이션 성능에 영향이 있어요. Nest는 가능한 한 많은 메타데이터를 캐시하려 하지만, 여전히 매 요청마다 클래스의 인스턴스를 생성해야 해요. 따라서 평균 응답 시간과 전반적인 벤치마크 결과가 느려져요. 프로바이더가 반드시 요청 스코프여야 하는 경우가 아니라면 기본 싱글톤 스코프를 사용하는 것을 강력히 권장해요.
💡 다소 위협적으로 들릴 수 있지만, 요청 스코프 프로바이더를 잘 활용하도록 설계된 애플리케이션은 지연 시간 측면에서 ~5% 이상 느려지지 않아야 해요.
지속(Durable) 프로바이더
요청 스코프 프로바이더는 지연 시간 증가로 이어질 수 있어요. 컨트롤러 인스턴스에 주입되거나, 더 깊이 — 그 프로바이더 중 하나에 주입되는 — 요청 스코프 프로바이더가 하나 이상 있으면 컨트롤러도 요청 스코프가 되기 때문이에요. 즉 매 요청마다 (인스턴스화 후 가비지 컬렉션되는) 컨트롤러를 재생성해야 한다는 뜻이에요. 또한 예를 들어 병렬로 3만 개의 요청이 있다면 컨트롤러(및 그 요청 스코프 프로바이더)의 임시 인스턴스 3만 개가 있을 것이라는 뜻이기도 해요.
대부분의 프로바이더가 의존하는 공통 프로바이더(데이터베이스 연결이나 로거 서비스 같은)가 있으면, 그 모든 프로바이더도 자동으로 요청 스코프 프로바이더로 전환돼요. 이는 멀티 테넌트 애플리케이션에서 특히 어려울 수 있어요. 특히 요청 객체에서 헤더/토큰을 가져와 그 값에 기반해 (그 테넌트에 특화된) 해당 데이터베이스 연결/스키마를 가져오는 중앙 요청 스코프 '데이터 소스' 프로바이더가 있는 경우죠.
예를 들어 10명의 서로 다른 고객이 번갈아 사용하는 애플리케이션이 있다고 해볼게요. 각 고객은 자체 전용 데이터 소스를 갖고 있고, 고객 A가 고객 B의 데이터베이스에 절대 닿을 수 없게 하려고 해요. 이를 달성하는 한 가지 방법은 요청 객체에 기반해 '현재 고객'을 결정하고 해당 데이터베이스를 가져오는 요청 스코프 '데이터 소스' 프로바이더를 선언하는 거예요. 이 접근법으로 몇 분 만에 애플리케이션을 멀티 테넌트 애플리케이션으로 바꿀 수 있어요. 하지만 큰 단점은 애플리케이션 구성 요소의 상당 부분이 '데이터 소스' 프로바이더에 의존할 가능성이 높아 그것들이 암시적으로 '요청 스코프'가 되고, 그래서 성능 영향이 확실히 드러난다는 점이에요.
더 나은 해결책은 없을까요? 고객이 10명뿐이니, 요청마다 각 트리를 재생성하는 대신 고객당 10개의 개별 DI 하위 트리를 가질 수 있지 않을까요? 프로바이더가 매 연속 요청에 진정으로 고유한 속성(예: 요청 UUID)에 의존하지 않고, 대신 속성을 묶어(분류할) 수 있는 특정 속성들이 있다면, 들어오는 요청마다 _DI 하위 트리를 재생성_할 이유가 없어요.
그리고 바로 그럴 때 지속(durable) 프로바이더가 유용해져요.
프로바이더를 지속으로 표시하기 전에 먼저 Nest에 그 '공통 요청 속성'이 무엇인지 가르치고, 요청을 그룹화 — 각각 해당 DI 하위 트리에 연결 — 하는 로직을 제공하는 전략(strategy) 을 등록해야 해요.
import {
HostComponentInfo,
ContextId,
ContextIdFactory,
ContextIdStrategy,
} from '@nestjs/core';
import { Request } from 'express';
const tenants = new Map<string, ContextId>();
export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
const tenantId = request.headers['x-tenant-id'] as string;
let tenantSubTreeId: ContextId;
if (tenants.has(tenantId)) {
tenantSubTreeId = tenants.get(tenantId);
} else {
tenantSubTreeId = ContextIdFactory.create();
tenants.set(tenantId, tenantSubTreeId);
}
// If tree is not durable, return the original "contextId" object
return (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId;
}
}
💡 요청 스코프와 마찬가지로 지속성(durability)도 주입 체인을 따라 위로 전파돼요. 즉 A가
durable로 표시된 B에 의존하면 A도 암시적으로 지속이 돼요(A의 프로바이더에durable이 명시적으로false로 설정되지 않는 한).
⚠️ 이 전략은 많은 수의 테넌트로 운영되는 애플리케이션에는 이상적이지 않아요.
attach 메서드가 반환하는 값은 주어진 호스트에 어떤 컨텍스트 식별자를 사용해야 하는지 Nest에 지시해요. 이 경우 호스트 구성 요소(예: 요청 스코프 컨트롤러)가 지속으로 표시되면, 원래 자동 생성된 contextId 객체 대신 tenantSubTreeId가 사용되도록 지정했어요. 또한 위 예시에서는 어떤 payload도 등록되지 않아요 (payload = 하위 트리의 '루트' — 부모 — 를 나타내는 REQUEST/CONTEXT 프로바이더).
지속 트리에 payload를 등록하고 싶다면 대신 다음 구성을 사용해요.
// The return of `AggregateByTenantContextIdStrategy#attach` method:
return {
resolve: (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId,
payload: { tenantId },
};
이제 @Inject(REQUEST)/@Inject(CONTEXT)를 사용해 REQUEST 프로바이더(또는 GraphQL 애플리케이션의 CONTEXT)를 주입할 때마다 payload 객체(이 경우 tenantId라는 단일 속성으로 구성)가 주입돼요.
이 전략이 있으면 (어차피 전역으로 적용되므로) 코드 어딘가에 등록할 수 있어요. 예를 들어 main.ts 파일에 둘 수 있어요.
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());
💡
ContextIdFactory클래스는@nestjs/core패키지에서 import 해요.
등록이 어떤 요청이든 애플리케이션에 닿기 전에 발생하기만 하면 모든 것이 의도한 대로 동작해요.
마지막으로, 일반 프로바이더를 지속 프로바이더로 바꾸려면 durable 플래그를 true로 설정하고 스코프를 Scope.REQUEST로 변경하면 돼요(REQUEST 스코프가 이미 주입 체인에 있으면 필요 없어요).
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}
마찬가지로 커스텀 프로바이더의 경우, 프로바이더 등록의 긴 형식에서 durable 속성을 설정해요.
{
provide: 'foobar',
useFactory: () => { ... },
scope: Scope.REQUEST,
durable: true,
}
더 알아보기
- NestJS 공식 문서 - Injection scopes
- 커스텀 프로바이더(Custom providers) — 프로바이더 등록의 다양한 형태
- 모듈 참조(Module reference) — 스코프 프로바이더 해결하기
- NestJS 공식 문서 - Providers — 프로바이더와 DI 기초