광고
~ / posts / developer
💻 개발자

NestJS 프로바이더 스코프 고르기, 요청 스코프 비용과 DI 에러 점검 순서

NestJS 로 API 를 만들다 보면 두 가지 결정이 계속 돌아와요. "로그인한 사용자나 테넌트를 서비스에서 읽어야 하는데 Scope.REQUEST 를 써도 되나", 그리고 "이 서비스를 다른 모듈에서도 쓰려면 exports·@Global()·forRoot() 중 무엇으로 열어 주나"예요. 결론부터 말하면 요청 값만 읽을 거라면 스코프를 바꾸지 말고 AsyncLocalStorage 로 넘기는 편이 거의 공짜이고, 공유는 exports + imports 가 기본이에요. 요청 스코프는 의존 체인 위쪽을 전부 요청마다 다시 만들게 해서, 체인이 길수록 비용이 커져요.

이 글은 컨테이너 내부 구조보다 고를 때 필요한 숫자와 순서를 다뤄요. @nestjs/core 12.3.2 와 Express 어댑터로 작은 서버를 띄워 스코프별 처리량과 프로바이더 수별 부팅 시간을 직접 쟀고(Apple M1, Node.js 25.3, autocannon 동시 연결 50·10초 2회 평균), 규칙은 NestJS 공식 문서 Injection scopes와 Common errors 페이지로 다시 확인했어요.

광고

NestJS 프로바이더 스코프 판단표 — 기본값은 싱글턴

Nest 의 스코프는 세 가지예요. DEFAULT(앱 전체에 인스턴스 하나), REQUEST(요청마다 새 인스턴스), TRANSIENT(주입받는 쪽마다 새 인스턴스)예요. 공식 문서는 대부분의 경우 싱글턴을 권하고, Node.js 는 요청마다 스레드를 따로 두지 않으니 싱글턴 공유가 안전하다고 설명해요. 그래서 질문은 "요청 스코프가 꼭 필요한가"로 바뀌어요.

요청에 딸린 값을 읽기만 하는지, 요청 묶음이 적고 고정인지, 요청마다 상태를 쌓는지, 주입받는 쪽마다 따로 있어야 하는지를 차례로 물어 AsyncLocalStorage·durable·REQUEST·TRANSIENT·DEFAULT 중 하나를 고르는 순서도
위에서부터 '예'가 나오는 첫 칸이 시작점이에요
하려는 일고를 것이유
DB 풀·HTTP 클라이언트·캐시 같은 공용 객체DEFAULT부팅 때 한 번 만들고 모든 요청이 공유
로그인 사용자·요청 ID·로케일을 깊은 서비스에서 읽기DEFAULT + AsyncLocalStorage스코프가 번지지 않아요. 큐 작업·마이크로서비스 핸들러에서도 같은 방식
테넌트 10곳이 각자 DB 를 쓰는 멀티테넌시REQUEST + durable: true테넌트별 하위 트리를 한 번 만들고 재사용
GraphQL 요청 단위 DataLoader 캐시처럼 요청마다 상태를 쌓기REQUEST요청이 끝나면 버려야 하는 상태라서
주입받는 클래스 이름을 붙이는 로거TRANSIENT소비자마다 따로 만들되 요청마다 만들지는 않아요

공식 문서가 요청 스코프를 쓰지 말라는 자리도 적어 둬요. Passport 전략과 Cron 처럼 싱글턴이어야 하는 것은 요청 스코프 프로바이더에 기대면 안 되고, 라이프사이클 이벤트 문서에 따르면 onModuleInit 같은 훅은 요청 스코프 클래스에서 불리지 않아요. 초기화 코드를 훅에 넣어 두었다면 스코프를 바꾸는 순간 조용히 빠져요.

요청 스코프가 번지는 범위 — REQUEST 한 줄의 효과

요청 스코프는 주입 체인을 따라 위로 올라가요. 컨트롤러 ← 서비스 ← 리포지토리 에서 리포지토리가 요청 스코프면 서비스와 컨트롤러도 요청 스코프가 되고, 리포지토리에 의존하지 않는 다른 프로바이더는 싱글턴으로 남아요. 반대로 TRANSIENT 는 번지지 않아요. 싱글턴 서비스가 트랜지언트 로거를 받으면 로거만 새로 만들어지고 서비스는 그대로예요.

자주 놓치는 지점은 @Inject(REQUEST) 예요. 문서는 REQUEST 프로바이더가 원래 요청 스코프라서 그걸 주입받는 클래스는 스코프를 적지 않아도 요청 스코프가 된다고 적어요. 실제로 맨 아래 서비스에 @Injectable() 만 두고 생성자에서 REQUEST 만 받게 해 봤더니, 컨트롤러 포함 다섯 개 클래스의 생성자가 요청마다 다섯 번씩 다시 불렸어요. 코드에는 Scope.REQUEST 가 한 줄도 없는데도요. "스코프를 바꾼 적이 없는데 느려졌다"면 REQUEST·CONTEXT 주입부터 찾아보세요.

숫자로 보는 요청 스코프 비용 — 처리량 직접 측정

맨 아래 서비스 하나만 요청을 읽고, 그 위로 서비스가 줄줄이 이어진 서버를 네 가지로 만들었어요. 싱글턴(요청을 안 읽음), AsyncLocalStorage(미들웨어에서 als.run() 으로 테넌트를 넣고 서비스가 getStore() 로 읽음), durable(테넌트 하나로 고정), 요청 스코프예요. 체인 길이는 컨트롤러 포함 5개와 21개 두 가지예요.

NestJS 스코프별 초당 처리 요청 수 (Express, M1, 동시 연결 50)
체인 5개체인 21개
02,0004,0006,0008,000싱글턴 · 체인 5개 6,387요청/초싱글턴 · 체인 21개 6,460요청/초싱글턴AsyncLocalStorage · 체인 5개 6,429요청/초AsyncLocalStorage · 체인 21개 6,213요청/초AsyncLocalStoragedurable · 체인 5개 4,988요청/초durable · 체인 21개 4,970요청/초durable요청 스코프 · 체인 5개 3,062요청/초요청 스코프 · 체인 21개 1,613요청/초요청 스코프

핸들러가 DB 를 안 부르는 빈 응답이라 DI 비용이 크게 보여요. 실제 앱에서는 비율이 줄어요

단위: 요청/초 · 자료: @nestjs/core 12.3.2·Express 5.3·autocannon 10초 2회 평균으로 직접 측정
표로 보기
구분체인 5개체인 21개
싱글턴6,3876,460
AsyncLocalStorage6,4296,213
durable4,9884,970
요청 스코프3,0621,613

싱글턴 대비 비율로 보면 요청 스코프는 체인 5개에서 48%, 21개에서 25%예요. durable 은 체인 길이와 상관없이 77~78%, AsyncLocalStorage 는 96~101%로 측정 오차 안이었어요. durable 은 같은 테넌트라면 생성자를 다시 부르지 않았는데도 22% 줄었어요. 컨트롤러는 여전히 요청 스코프라서 요청마다 ContextIdStrategy.attach() 로 컨텍스트를 고르고 그 하위 트리에서 인스턴스를 꺼내는 단계를 거치기 때문이에요.

절대량으로 바꾸면 판단이 쉬워요. 처리량의 역수가 요청 하나에 드는 CPU 시간이에요.

요청 하나당 CPU 시간 (1 ÷ 초당 처리량)
싱글턴 체인 5개싱글턴 체인 5개 · 0.157ms0.157msdurable 체인 5개durable 체인 5개 · 0.2ms0.2ms요청 스코프 체인 5개요청 스코프 체인 5개 · 0.327ms0.327ms싱글턴 체인 21개싱글턴 체인 21개 · 0.155ms0.155ms요청 스코프 체인 21개요청 스코프 체인 21개 · 0.62ms0.62ms
단위: ms · 자료: 위 측정의 평균 처리량으로 직접 계산
표로 보기
구분CPU 시간
싱글턴 체인 5개0.157
durable 체인 5개0.2
요청 스코프 체인 5개0.327
싱글턴 체인 21개0.155
요청 스코프 체인 21개0.62

요청 스코프 비용은 체인 5개에서 요청당 0.17ms, 21개에서 0.47ms 가 더해진 셈이에요. 문서는 잘 설계된 앱이라면 지연 증가가 약 5%를 넘지 않는다고 하는데, 그건 핸들러가 DB·외부 API 를 기다리느라 수십 ms 를 쓰는 경우예요. 핸들러 처리 시간이 20ms 라면 0.47ms 는 2.4% 예요. 그런데 CPU 는 다르게 계산해야 해요. Node.js 는 한 프로세스가 코어 하나로 돌기 때문에 초당 1,000건이면 0.47ms × 1,000 = 코어 하나의 47%를 객체 생성에만 써요. 지연은 괜찮아 보여도 파드 수가 늘어나는 이유가 여기 있어요. 테스트 환경이 M1 이라 서버 CPU 에서는 숫자가 달라지니, 같은 방식으로 우리 체인 길이를 재 보세요.

durable 프로바이더와 AsyncLocalStorage 고르는 기준

둘 다 "요청마다 다시 만들기"를 피하는 방법이지만 쓰임이 달라요.

테넌트가 수백 곳이 넘거나 계속 늘어난다면 durable 대신 싱글턴 연결 관리자가 테넌트 ID 로 풀을 꺼내 주고, 그 ID 는 AsyncLocalStorage 로 넘기는 구조가 메모리를 예측하기 쉬워요.

모듈 공유 방식 고르기 — exports·@Global·forRoot

스코프 다음으로 많이 고민하는 게 "다른 모듈에서 어떻게 쓰게 하나"예요. 기본 규칙은 하나예요. 프로바이더는 선언한 모듈 안에서만 보이고, 밖에서 쓰려면 선언한 쪽 exports 와 쓰는 쪽 imports 가 둘 다 있어야 해요.

상황고를 것피할 것
기능 모듈 둘이 같은 서비스를 씀한 모듈에 등록 + exports, 쓰는 쪽에서 imports두 모듈 providers 에 각각 적기
공통 모듈 여러 개를 한 번에 열기CoreModule 이 하위 모듈을 import 하고 다시 export하위 모듈을 기능 모듈마다 일일이 import
설정·로거처럼 거의 모든 곳에서 씀@Global() 을 루트나 코어 모듈에 한 번기능 모듈마다 @Global()
앱 전체에 설정 한 벌(DB 연결 등)forRoot() / forRootAsync()모듈마다 연결을 새로 여는 register()
모듈마다 다른 설정(HTTP 클라이언트 baseUrl 등)register()forRoot() 를 여러 번
forRoot() 설정 위에 모듈별 일부만 추가forFeature()설정 전체를 복사

이름 규칙은 Dynamic modules 문서의 커뮤니티 가이드라인에 있어요. 강제 규칙은 아니지만 @nestjs/ 패키지들이 이 뜻으로 써서, 남이 만든 모듈을 볼 때도 forRoot() 면 "앱에 한 번"으로 읽으면 돼요. 비동기 버전까지 직접 만들기 번거롭다면 ConfigurableModuleBuilder 가 register()·registerAsync() 를 만들어 줘요.

@Global() 은 export 를 생략해 주는 기능이 아니에요. 모든 모듈의 imports 에 자동으로 붙을 뿐이라 exports 에 없는 프로바이더는 여전히 안 보여요. Modules 문서도 전부 전역으로 만드는 설계는 권하지 않아요. imports 만 보고 의존이 어디서 오는지 알 수 없게 되기 때문이에요.

중복 등록도 직접 확인했어요. CacheModule 이 CacheService 를 export 하고 AppModule 이 그 모듈을 import 하면서 자기 providers 에도 CacheService 를 또 적으면, 생성자가 두 번 불려 인스턴스가 두 개가 돼요. 에러는 안 나요. 캐시나 연결 풀이라면 상태가 갈라지고, 커넥션 수도 두 배가 돼요.

부팅 시간과 프로바이더 수 — 서버리스라면 따져 볼 것

싱글턴은 부팅 때 전부 만들어져요. 그래서 프로바이더 수가 부팅 시간에 그대로 더해져요. 공통 모듈 하나를 기능 모듈 여러 개가 import 하고, 모듈마다 서비스 10개가 사슬로 이어진 앱을 만들어 createApplicationContext() + init() 시간을 5번씩 쟀어요(중앙값).

프로바이더 수별 NestJS DI 부팅 시간 (생성자는 빈 함수)
010020030040050060010개100개500개1000개2000개10개 · 부팅 시간 36.6ms100개 · 부팅 시간 64.7ms500개 · 부팅 시간 180ms1000개 · 부팅 시간 313ms2000개 · 부팅 시간 560ms560

생성자에서 DB 연결·파일 읽기를 하면 그 시간이 그대로 더해져요

단위: ms · 자료: @nestjs/core 12.3.2, Apple M1, Node.js 25.3에서 5회 측정 중앙값
표로 보기
구분부팅 시간
10개36.6
100개64.7
500개180
1000개313
2000개560

100개에서 2,000개까지 프로바이더 하나당 약 0.26ms 가 늘었어요. 힙 사용량은 10개일 때 15.6MB, 2,000개일 때 34.3MB 였어요. 상시 떠 있는 서버라면 무시해도 되는 크기지만, 함수가 자주 새로 뜨는 서버리스에서는 콜드 스타트에 그대로 얹혀요. 실제로는 이 DI 시간보다 forRootAsync() 의 DB 연결 같은 비동기 팩토리가 훨씬 오래 걸리는 경우가 많으니, 부팅 로그에 단계별 시간을 찍어 어디서 오래 걸리는지부터 보세요.

줄이는 방법은 Lazy loading modules 문서의 LazyModuleLoader 예요. 특정 함수 호출에만 필요한 모듈을 처음 요청 때 불러오고, 두 번째부터는 캐시된 모듈을 돌려줘요. 대신 지연 로딩 모듈은 전역 모듈로 등록할 수 없고, 라이프사이클 훅이 불리지 않고, 전역 가드·인터셉터도 제대로 동작하지 않는다고 문서에 적혀 있어요. 서버리스 콜드 스타트 전반의 선택지는 람다 콜드 스타트 줄이는 법 글에 정리해 두었어요.

광고
광고

"Nest can't resolve dependencies" 확인 순서

이 에러 문장에는 확인할 곳이 다 들어 있어요. Nest can't resolve dependencies of the B (?). Please make sure that the argument CacheService at index [0] is available in the BadModule module. 이라면 B 의 첫 번째 생성자 인자 CacheService 를 BadModule 안에서 못 찾았다는 뜻이에요.

에러 문장에서 모듈 이름·인덱스·토큰 이름을 읽은 뒤, 토큰 이름이 보이면 providers·imports·exports·re-export·중복 등록 순으로, 토큰 이름이 없으면 import type·인터페이스 주입·파일 순환 import·데코레이터 메타데이터 순으로 확인하는 두 갈래 체크리스트
토큰 이름이 찍혔는지가 첫 갈림길이에요

토큰 이름이 찍혔을 때 — 등록·공개 문제예요.

  1. 에러에 적힌 모듈의 providers 에 그 토큰이 있나요? 없다면 그 토큰을 가진 모듈을 imports 에 넣어야 해요.
  2. import 한 모듈이 그 토큰을 exports 에 올렸나요? 등록만 하고 export 를 빠뜨리는 경우가 가장 흔해요.
  3. A → B → C 로 이어진다면 B 가 C 를 다시 export 하나요? B 가 C 를 import 만 했다면 A 에서는 C 의 프로바이더가 안 보여요.
  4. @Injectable() 클래스를 providers 가 아니라 imports 에 넣지 않았나요? 이 경우 Nest 가 부팅 때 따로 알려 줘요.
  5. 토큰이 에러 대상 클래스 자신이면 자기 주입이에요. 허용되지 않아요.

(?) 자리에 토큰 이름 없이 "at index [0] is available in the current module" 이 나올 때 — 그 자리 값이 실행 중에 undefined 나 Object 였다는 뜻이에요. 공식 문서가 꼽는 원인은 세 가지예요.

  1. import type { UsersService } 처럼 타입만 가져온 경우. 클래스 자체를 import 해야 해요.
  2. 인터페이스나 타입으로 주입한 경우. 실행 중에는 없으니 클래스나 @Inject('토큰') 을 써야 해요.
  3. 파일끼리 서로 import 하는 순환. 배럴 파일(index.ts)로 같은 폴더 파일을 묶어 가져올 때 자주 생겨요. 상수는 별도 파일로 빼세요.

여기에 하나를 더하면, 빌드 도구가 데코레이터 메타데이터를 내보내는지 보세요. TypeScript 의 emitDecoratorMetadata 옵션이 꺼지면 생성자 인자 타입 정보가 안 남아요. 이때는 에러 없이 undefined 가 주입되기도 해서 더 찾기 어려워요. esbuild·SWC 로 바꾼 직후라면 이 설정부터 확인하세요. tsconfig 옵션 전반은 타입스크립트 strict 옵션 고르기 글에서 다뤘어요.

그래도 안 보이면 NEST_DEBUG 환경 변수에 아무 값이나 넣고 띄우세요. 부팅 중 어느 클래스가 어느 토큰을 어느 모듈에서 찾는지 로그로 찍혀요. 모노레포에서 ModuleRef 를 못 찾는다는 에러가 나면 @nestjs/core 가 두 벌 설치됐을 가능성이 커요. npm ls @nestjs/core 로 중복을 확인하세요.

순환 의존과 forwardRef — 쓰기 전에 따질 것

모듈 imports 가 undefined 라는 에러나 forwardRef 를 쓰라는 안내가 나오면, 먼저 진짜 순환인지 파일 순환인지 구분하세요. 서비스 둘이 서로를 생성자에서 받는 게 진짜 순환이고, 파일만 서로 import 하는 건 배럴 파일·상수 위치 문제예요. 뒤쪽은 파일 구조만 고치면 돼요.

진짜 순환이라면 Circular dependency 문서대로 양쪽 모두 forwardRef() 로 감싸야 하고, 모듈끼리 순환이면 모듈 imports 도 양쪽 다 감싸요. 다만 문서는 생성 순서가 정해지지 않는다고 경고해요. 생성자 안에서 상대 서비스의 메서드를 부르면 아직 준비되지 않은 객체를 건드릴 수 있고, 요청 스코프 프로바이더가 순환에 끼면 undefined 가 될 수도 있어요. 그래서 forwardRef 는 임시 처방으로 보고, 두 서비스가 함께 쓰는 로직을 제3의 서비스로 빼거나 한쪽을 이벤트 발행으로 바꾸는 게 오래 가요. 이벤트로 결합을 끊는 방식은 스프링 도메인 이벤트 설정 글의 판단 기준이 Nest 에도 그대로 통해요.

도입 전 체크리스트

광고
광고

자주 묻는 질문

Scope.REQUEST 를 쓰면 무조건 느려지나요?

객체 생성 비용이 요청마다 붙는 건 확실해요. 이번 측정에서 체인 5개면 요청당 약 0.17ms, 21개면 0.47ms 였어요. 핸들러가 DB 를 기다리느라 수십 ms 를 쓰면 지연 증가는 몇 % 지만, CPU 사용량은 초당 요청 수에 비례해 늘어나니 트래픽이 큰 서비스일수록 따져 봐야 해요.

로그인한 사용자 정보를 서비스에서 쓰려면 어떻게 하나요?

컨트롤러에서 인자로 넘기거나, 미들웨어·가드에서 AsyncLocalStorage 에 넣고 서비스에서 꺼내 쓰는 방법이 있어요. 둘 다 프로바이더가 싱글턴으로 남아요. @Inject(REQUEST) 는 가장 쉽지만 의존 체인 전체를 요청 스코프로 만들어요.

durable 프로바이더는 언제 쓰나요?

테넌트가 열 곳 안팎으로 적고 고정돼 있으며, 테넌트마다 다른 인스턴스(예: 전용 DB 연결)가 필요할 때예요. 테넌트 수만큼 하위 트리가 메모리에 남아서, 테넌트가 계속 늘어나는 서비스에는 맞지 않아요.

같은 서비스를 두 모듈 providers 에 적으면 어떻게 되나요?

에러 없이 인스턴스가 두 개 생겨요. 직접 확인했을 때도 생성자가 두 번 불렸어요. 한 모듈에만 등록하고 exports 에 올린 뒤, 쓰는 모듈에서 그 모듈을 import 하세요.

@Global() 로 만들면 exports 를 안 써도 되나요?

아니요. 전역 모듈은 모든 모듈의 imports 에 자동으로 붙을 뿐이라 exports 에 올린 것만 보여요. 전역 모듈은 루트나 코어 모듈에 한 번만 두라고 공식 문서가 권해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 NestJS 의존성 주입(DI) 원리 — 모듈·프로바이더가 엮이는 순서에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#NestJS 요청 스코프#NestJS 프로바이더 스코프#Scope.REQUEST 성능#durable provider#AsyncLocalStorage NestJS#Nest can't resolve dependencies#NestJS 모듈 설계#forRoot forFeature
← 이전 글LLM 서빙 배칭 방식 고르기, 청크 프리필 토큰 예산과 GPU 비용 계산다음 글 →프롬프트 인젝션 방어 고르기, 분류기·LLM 검사·CaMeL 비용과 지연 비교
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →