문득 @Injectable
데코레이터에 있던 Scope
옵션에 대해 궁금증이 생겨 찾아보다가 대부분 문서 번역한 내용 밖에 없는 것 같아서 따로 확인해보고 정리해본 아티클입니다.
문서상의 내용은 아래와 같습니다.
Injection scopes
For people coming from different programming language backgrounds, it might be unexpected to learn that in Nest, almost everything is shared across incoming requests. We have a connection pool to the database, singleton services with global state, etc. Remember that Node.js doesn't follow the request/response Multi-Threaded Stateless Model in which every request is processed by a separate thread. Hence, using singleton instances is fully safe for our applications.
However, there are edge cases when request-based lifetime may be the desired behavior, for instance, per-request caching in GraphQL applications, request tracking, and multi-tenancy. Injection scopes provide a mechanism to obtain the desired provider lifetime behavior.
Injection scopes
다른 프로그래밍 언어 배경을 가진 사람들에게는 Nest에서 거의 모든 것이 들어오는 요청에서 공유된다는 사실이 의외로 느껴질 수 있습니다. 데이터베이스에 대한 연결 풀, 전역 상태를 가진 싱글톤 서비스 등이 있습니다. Node.js는 모든 요청이 별도의 스레드에서 처리되는 요청/응답 다중 스레드 무상태 모델을 따르지 않는다는 점을 기억하세요. 따라서 싱글톤 인스턴스를 사용하는 것은 애플리케이션에 완전히 안전합니다.
그러나 GraphQL 애플리케이션의 요청별 캐싱, 요청 추적, 멀티테넌시 등 요청 기반 수명이 바람직한 동작일 수 있는 엣지 케이스가 있습니다. 인젝션 범위는 원하는 공급자 수명 동작을 얻기 위한 메커니즘을 제공합니다.
Provider scope
A provider can have any of the following scopes:
DEFAULT A single instance of the provider is shared across the entire application. The instance lifetime is tied directly to the application lifecycle. Once the application has bootstrapped, all singleton providers have been instantiated. Singleton scope is used by default.
REQUEST A new instance of the provider is created exclusively for each incoming request. The instance is garbage-collected after the request has completed processing.
TRANSIENT Transient providers are not shared across consumers. Each consumer that injects a transient provider will receive a new, dedicated instance.
Provider scope
Provider는 다음 범위 중 하나를 가질 수 있습니다.:
DEFAULT - 공급자의 단일 인스턴스가 전체 애플리케이션에서 공유됩니다. 인스턴스 수명은 애플리케이션 수명 주기에 직접 연결됩니다. 애플리케이션이 부트스트랩되면 모든 싱글톤 공급자가 인스턴스화됩니다. 기본적으로 싱글톤 범위가 사용됩니다.
REQUEST - 들어오는 각 요청에 대해 공급자의 새 인스턴스가 독점적으로 생성됩니다. 인스턴스는 요청 처리가 완료된 후 가비지 수집됩니다.
TRANSIENT - 임시 공급자는 소비자 간에 공유되지 않습니다. 임시 공급자를 삽입하는 각 소비자는 새로운 전용 인스턴스를 받게 됩니다.
내용만으로는 잘 이해 되지 않아서 코드로 확인해보았습니다.
간단하게 위 구조로 테스트 했습니다.
서비스별 역할은 아래와 같습니다.
AppService
show
함수 호출SubService
UtilityService
@Injectable()
데코레이터에서 기본 적용되는 옵션입니다.
첫번째, 두번째 호출에도 Provider 모두 처음 출력되었던 uuid와 동일합니다. 이는 싱글톤 패턴이 잘 지켜지고 있다고 정리할 수 있을 것 같습니다.
UtilityService
에만 Scope.TRANSIENT 옵션을 적용하고 호출 해보았습니다.
UtilityService
의 uuid의 경우 주입된 서비스(Sub1, Sub2)에 따라 동일한 uuid를 가지고 있습니다.
Sub1Service - d478b050-c820-4ec5-9ddc-7e0918d69b02
Sub2Service - eb01f23e-5d22-4bdf-af92-053b43ca6b5c
SubService
들은 호출과 상관없이 동일한 uuid를 출력하고 있는 것을 보아 싱글톤 패턴을 유지하고 있는 것을 확인할 수 있습니다.
UtilityService
에만 Scope.REQUEST 옵션을 적용하고 호출 해보았습니다.
UtilityService
의 uuid의 경우 주입된 서비스와 무관하게 하나의 호출에서는 동일한 uuid를 보여주지만 다른 호출끼리는 다른 uuid를 보이고 있습니다. (bdcb31c0-7a3d-4b91-adbe-6b74af17f9a2
,e810b8df-6d19-458d-96f4-499162ce73a5
)
이로써 호출마다 새로운 인스턴스가 생성된다는 것이 증명되었습니다.
근데 혹시 놓친 게 있지 않나요? 따로 REQUEST 옵션을 적용하지 않았던 SubService
의 uuid도 마찬가지로 바뀌고 있습니다. 이는 SubService
에 주입된 UtilityService
에 의해 Scope.REQUEST가 지정된 것입니다.
공식 문서에서는 이렇게 설명하고 있습니다.
The REQUEST scope bubbles up the injection chain. A controller that depends on a request-scoped provider will, itself, be request-scoped.
Imagine the following dependency graph: CatsController <- CatsService <- CatsRepository. If CatsService is request-scoped (and the others are default singletons), the CatsController will become request-scoped as it is dependent on the injected service. The CatsRepository, which is not dependent, would remain singleton-scoped.
Transient-scoped dependencies don't follow that pattern. If a singleton-scoped DogsService injects a transient LoggerService provider, it will receive a fresh instance of it. However, DogsService will stay singleton-scoped, so injecting it anywhere would not resolve to a new instance of DogsService. In case it's desired behavior, DogsService must be explicitly marked as TRANSIENT as well.
요청 범위는 인젝션 체인에 버블을 일으킵니다. 요청 범위가 지정된 공급자에 의존하는 컨트롤러는 그 자체로 요청 범위가 지정됩니다.
다음 의존성 그래프를 상상해 보세요: CatsController <- CatsService <- CatsRepository. CatsService가 요청 범위가 지정된 경우(그리고 나머지는 기본 싱글톤인 경우), CatsController는 주입된 서비스에 종속되므로 요청 범위가 지정됩니다. 종속적이지 않은 CatsRepository는 싱글톤 범위로 유지됩니다.
일시적인 범위의 종속성은 이 패턴을 따르지 않습니다. 싱글톤 범위의 DogsService가 일시적인 LoggerService 공급자를 주입하면 새로운 인스턴스를 받게 됩니다. 그러나 DogsService는 싱글톤 범위로 유지되므로 아무 곳에나 주입해도 DogsService의 새 인스턴스로 해결되지 않습니다. 이러한 동작을 원할 경우 DogsService도 명시적으로 TRANSIENT로 표시해야 합니다.
이렇게 되면 어떤 문제가 생길까요?
매요청마다 새로운 인스턴스가 생성되었다가 GC 처리가 될 것입니다. 즉 병렬로 3만개의 요청이 들어올 경우 생성되는 인스턴스는 3만개라는 뜻이겠죠. 만약에 이 요청 중에 구분자가 있다면 어떨까요? A의 요청 5000개, B의 요청 3000개 등등..
그리고 해당 구분자로 인스턴스를 생성하여 관리를 한다면 어떨까요? 3만개가 생성될 인스턴스가 1개로 줄어들 수도 있지 않을까요?
NestJS에서는 이를 DI-sub-trees 구조를 이용하여 풀어내었습니다.
이를 위해 durable
이라는 scope.REQUEST 전용 옵션이 존재합니다.
먼저 각 요청마다 구분자를 구분할 수 있어야 합니다.
문서에서는 ContextIdStrategy
를 이용하여 이를 해결합니다.
자세한 코드는 문서에서 확인할 수 있습니다.
이를 통해서 Request Header 에서 x-tenant-id를 1-2-1-2 순으로 요청해보겠습니다.
이처럼 동일한 x-tenant-id에는 모든 Provider가 uuid를 유지하고 있는 모습을 보여주고 있습니다.
해당 전략을 사용할 경우 무분별한 인스턴스 생성을 방지할 수 있고 각각의 요청에는 지속성이 있는 독립된 인스턴스를 가지게 됩니다.
해당 전략 마찬가지로 역시 너무 많은 수의 tenant에는 적합하지 않다고 안내하고 있습니다.
별 생각없이 Dataloader에서 사용했던 Scope가 생각보다 IoC 컨테이너에 인접해있는 개념임을 알게됐습니다.
추가로 durable 옵션을 이용해 보다 효과적인 인스턴스 관리가 가능하다는 것을 알게 됐습니다.