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 는 요청마다 스레드를 따로 두지 않으니 싱글턴 공유가 안전하다고 설명해요. 그래서 질문은 "요청 스코프가 꼭 필요한가"로 바뀌어요.
| 하려는 일 | 고를 것 | 이유 |
|---|---|---|
| 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개 두 가지예요.
싱글턴 대비 비율로 보면 요청 스코프는 체인 5개에서 48%, 21개에서 25%예요. durable 은 체인 길이와 상관없이 77~78%, AsyncLocalStorage 는 96~101%로 측정 오차 안이었어요. durable 은 같은 테넌트라면 생성자를 다시 부르지 않았는데도 22% 줄었어요. 컨트롤러는 여전히 요청 스코프라서 요청마다 ContextIdStrategy.attach() 로 컨텍스트를 고르고 그 하위 트리에서 인스턴스를 꺼내는 단계를 거치기 때문이에요.
절대량으로 바꾸면 판단이 쉬워요. 처리량의 역수가 요청 하나에 드는 CPU 시간이에요.
요청 스코프 비용은 체인 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 고르는 기준
둘 다 "요청마다 다시 만들기"를 피하는 방법이지만 쓰임이 달라요.
- AsyncLocalStorage — 값만 넘겨요. 모든 프로바이더가 싱글턴으로 남고, HTTP 핸들러·마이크로서비스 핸들러·큐 작업에서 같은 코드로 동작해요. 공식 문서도 사용자·테넌트·로케일만 읽으려는 경우 이 방식을 먼저 권해요. 사용법은 NestJS Async local storage 레시피와 Node.js AsyncLocalStorage API 문서에 있어요. 주의할 점은
als.run()바깥에서 시작된 콜백(앱 시작 때 만든 타이머 등)에는 값이 없다는 거예요. - durable — 테넌트마다 다른 인스턴스가 정말 필요할 때 써요. 테넌트별 DB 연결을 생성자에서 붙잡는 경우가 대표적이에요.
ContextIdStrategy로 요청을 테넌트별로 묶고, 프로바이더에scope: Scope.REQUEST, durable: true를 줘요. 테넌트 수만큼 하위 트리가 메모리에 남으니 문서도 테넌트가 많은 앱에는 맞지 않는다고 경고해요. 이번 실험에서도 테넌트a로 두 번 요청하면 생성자가 다시 안 불리고,b로 바꾸면 체인 5개가 한 번 더 만들어졌어요.
테넌트가 수백 곳이 넘거나 계속 늘어난다면 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번씩 쟀어요(중앙값).
생성자에서 DB 연결·파일 읽기를 하면 그 시간이 그대로 더해져요
표로 보기
| 구분 | 부팅 시간 |
|---|---|
| 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에 넣어야 해요. - import 한 모듈이 그 토큰을
exports에 올렸나요? 등록만 하고 export 를 빠뜨리는 경우가 가장 흔해요. - A → B → C 로 이어진다면 B 가 C 를 다시 export 하나요? B 가 C 를 import 만 했다면 A 에서는 C 의 프로바이더가 안 보여요.
@Injectable()클래스를providers가 아니라imports에 넣지 않았나요? 이 경우 Nest 가 부팅 때 따로 알려 줘요.- 토큰이 에러 대상 클래스 자신이면 자기 주입이에요. 허용되지 않아요.
(?) 자리에 토큰 이름 없이 "at index [0] is available in the current module" 이 나올 때 — 그 자리 값이 실행 중에 undefined 나 Object 였다는 뜻이에요. 공식 문서가 꼽는 원인은 세 가지예요.
import type { UsersService }처럼 타입만 가져온 경우. 클래스 자체를 import 해야 해요.- 인터페이스나 타입으로 주입한 경우. 실행 중에는 없으니 클래스나
@Inject('토큰')을 써야 해요. - 파일끼리 서로 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 에도 그대로 통해요.
도입 전 체크리스트
- 요청 값 목록: 서비스가 요청에서 읽는 값(사용자·테넌트·요청 ID)을 적고, 읽기만 하는지 상태를 쌓는지 나눠요. 읽기만 하면 AsyncLocalStorage 로 시작해요.
- REQUEST 주입 검색:
@Inject(REQUEST)·@Inject(CONTEXT)를 검색해 그 위로 몇 개 클래스가 끌려 올라가는지 세요. 그 수가 위 측정의 체인 길이예요. - CPU 예산: 요청 스코프를 남긴다면 (요청당 추가 CPU 시간 × 초당 요청 수)를 코어 하나 기준으로 계산해요.
- 훅 위치: 요청 스코프로 바꾸는 클래스에
onModuleInit같은 훅이 있는지 확인해요. - 공유 방식: 같은 클래스가 두 모듈
providers에 있는지 검색해요. 있으면 한쪽만 남기고 export 해요. - 전역 모듈 수:
@Global()이 설정·로거 수준의 한두 개인지 세요. - 부팅 시간: 서버리스라면 프로바이더 수와 비동기 팩토리 시간을 부팅 로그로 재요.
자주 묻는 질문
Scope.REQUEST 를 쓰면 무조건 느려지나요?
객체 생성 비용이 요청마다 붙는 건 확실해요. 이번 측정에서 체인 5개면 요청당 약 0.17ms, 21개면 0.47ms 였어요. 핸들러가 DB 를 기다리느라 수십 ms 를 쓰면 지연 증가는 몇 % 지만, CPU 사용량은 초당 요청 수에 비례해 늘어나니 트래픽이 큰 서비스일수록 따져 봐야 해요.
로그인한 사용자 정보를 서비스에서 쓰려면 어떻게 하나요?
컨트롤러에서 인자로 넘기거나, 미들웨어·가드에서 AsyncLocalStorage 에 넣고 서비스에서 꺼내 쓰는 방법이 있어요. 둘 다 프로바이더가 싱글턴으로 남아요. @Inject(REQUEST) 는 가장 쉽지만 의존 체인 전체를 요청 스코프로 만들어요.
durable 프로바이더는 언제 쓰나요?
테넌트가 열 곳 안팎으로 적고 고정돼 있으며, 테넌트마다 다른 인스턴스(예: 전용 DB 연결)가 필요할 때예요. 테넌트 수만큼 하위 트리가 메모리에 남아서, 테넌트가 계속 늘어나는 서비스에는 맞지 않아요.
같은 서비스를 두 모듈 providers 에 적으면 어떻게 되나요?
에러 없이 인스턴스가 두 개 생겨요. 직접 확인했을 때도 생성자가 두 번 불렸어요. 한 모듈에만 등록하고 exports 에 올린 뒤, 쓰는 모듈에서 그 모듈을 import 하세요.
@Global() 로 만들면 exports 를 안 써도 되나요?
아니요. 전역 모듈은 모든 모듈의 imports 에 자동으로 붙을 뿐이라 exports 에 올린 것만 보여요. 전역 모듈은 루트나 코어 모듈에 한 번만 두라고 공식 문서가 권해요.
정리
- 기본은 싱글턴이에요. 요청 값을 읽기만 한다면 AsyncLocalStorage 로 넘기면 처리량이 싱글턴과 거의 같았어요(96~101%).
@Inject(REQUEST)한 줄이 의존 체인 위쪽을 모두 요청 스코프로 만들어요. 처리량은 체인 5개에서 48%, 21개에서 25%로 떨어졌어요. 테넌트가 적고 고정이면 durable(77~78%)이 중간값이에요.- 공유는
exports+imports가 기본이고,@Global()은 한두 개만, 설정 한 벌은forRoot(), 모듈별 설정은register()로 해요. - DI 에러는 에러 문장의 모듈·인덱스·토큰을 먼저 읽고, 토큰 이름이 있으면 등록·export 를, 없으면
import type·파일 순환·데코레이터 메타데이터를 확인해요. - 관련 글: 람다 콜드 스타트 줄이는 법, 타입스크립트 strict 옵션 고르기
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 NestJS 의존성 주입(DI) 원리 — 모듈·프로바이더가 엮이는 순서에서 도식과 코드로 단계별로 풀었어요.