의존성 주입과 IoC 컨테이너
객체를 직접 생성할 때 생기는 결합을 살펴보고 Nest IoC 컨테이너의 프로바이더 등록과 생성자 주입으로 해결합니다.
1장에서는 NestJS의 기본 구성 요소와 첫 애플리케이션이 동작하는 방식을 살펴봤습니다. 2장에서는 NestJS를 제대로 쓰기 위한 핵심 개념을 더 깊이 다루며, 첫 주제는 의존성 주입(Dependency Injection, DI)과 제어의 역전(Inversion of Control, IoC) 컨테이너입니다. 두 개념은 NestJS의 바탕이며, 이 글에서는 프로바이더 등록, 주입의 범위, 수명 주기, 테스트 대역이라는 네 관점에서 왜 필요한지를 확인합니다.
Nest DI · 등록과 해결
모듈이 런타임 토큰을 등록하면 컨테이너가 의존성을 해결해 주입합니다
본문은 class token의 생성자 주입 예제입니다. string·Symbol token도 사용할 수 있으며, 아래 useClass/useValue는 같은 소비자를 운영 구현이나 테스트 대역에 연결하는 선택 예시입니다.
- 등록
모듈이 런타임 토큰과 프로바이더를 연결하고 필요한 토큰을 내보냅니다.
- 해결
IoC 컨테이너가 의존성 그래프와 스코프를 따라 인스턴스를 만듭니다.
- 구현 선택
운영 구현을
useClass로, 테스트 대역을 같은 토큰의useValue로 등록할 수 있습니다. 두 방식이 환경별로 강제되는 것은 아닙니다. - 주입
소비자는 구현 생성법을 몰라도 같은 생성자 계약으로 협력자를 받습니다.
기본 singleton은 해당 provider 등록의 인스턴스를 공유합니다. REQUEST는 이를 주입받는 소비자까지 요청 수명으로 바뀝니다. TRANSIENT는 소비자마다 전용 인스턴스를 제공하며 소비자의 scope를 자동으로 바꾸지 않습니다.
그림은 모듈이 런타임 토큰과 프로바이더를 등록하면, IoC 컨테이너가 의존성과 수명을 따라 인스턴스를 만들어 소비자의 생성자에 넣어 주는 흐름입니다. 토큰으로는 클래스, 문자열, Symbol을 쓸 수 있으며, TypeScript의 인터페이스 타입은 실행할 때 사라지므로 토큰이 될 수 없습니다. 아래쪽의 useClass와 useValue는 같은 토큰에 운영 구현이나 테스트 대역을 연결하는 대안이며, 둘을 동시에 주입한다는 뜻은 아닙니다. 맨 아래 줄은 토큰, 프로바이더, 범위가 서로 다른 선택이라는 점을 짚습니다.
의존성 주입이란
의존성(dependency)은 한 클래스가 다른 클래스의 기능이나 데이터를 써야 하는 관계를 말합니다. 예를 들어 UserController가 사용자 정보를 처리하려고 UserService의 메서드를 호출해야 한다면, UserController는 UserService에 의존합니다.
UserController가 UserService를 직접 만들어 쓰면 어떻게 될까요?
// 직접 UserService를 생성하는 방식 (DI를 사용하지 않음)
class UserService {
getUsers() { /* ... */ }
}
class UserController {
private userService: UserService;
constructor() {
this.userService = new UserService(); // 직접 의존성을 생성
}
getUsers() {
return this.userService.getUsers();
}
}간단해 보이지만 문제가 있습니다. UserController가 UserService의 구체적인 구현에 묶여 있으므로, UserService를 만드는 방식이 바뀌거나 다른 구현으로 바꿔야 할 때 UserController도 함께 고쳐야 합니다. UserController를 테스트할 때 UserService까지 함께 만들어지므로, UserService가 데이터베이스 연결 같은 복잡한 외부 의존성을 가지면 컨트롤러만 따로 테스트하기 어렵습니다. 또 UserService를 쓰는 다른 클래스도 각자 new UserService()로 인스턴스를 만들어야 합니다.
의존성 주입은 이 문제를 푸는 설계 방식입니다. 객체가 필요한 의존성을 직접 만들지 않고, 바깥(NestJS에서는 IoC 컨테이너)에서 넣어 주는 것을 받아 씁니다. 필요한 부품을 직접 만들지 않고, 밖에서 조립된 부품을 받아 쓰는 셈입니다. NestJS는 주로 생성자 주입(constructor injection)을 씁니다.
// 의존성 주입 방식
import { Controller, Injectable } from '@nestjs/common';
@Injectable() // UserService는 주입할 수 있는 프로바이더임을 나타냅니다.
class UserService {
getUsers() { /* ... */ }
}
@Controller()
class UserController {
// NestJS가 UserService 인스턴스를 만들어 넣어 줍니다.
constructor(private readonly userService: UserService) {}
getUsers() {
return this.userService.getUsers();
}
}UserController는 생성자로 UserService 타입의 인스턴스를 요청할 뿐, new UserService()로 직접 만들지 않습니다. NestJS가 이 의존성을 알아차리고 알맞은 UserService 인스턴스를 찾아 생성자로 주입합니다. 이 조각을 실제 파일로 옮길 때는 해당 모듈에 controllers: [UserController]와 providers: [UserService]를 등록해야 합니다. 생성자 선언은 필요한 객체를 알리고, 모듈 등록은 컨테이너가 그 객체를 제공할 수 있게 합니다. getUsers() { /* ... */ }는 구현을 생략한 부분이므로, 아직 사용자 목록을 돌려주는 완성된 앱은 아닙니다.
의존성 주입의 장점은 세 가지입니다. 의존 객체를 만드는 일이 분리되어 구현을 바꾸거나 테스트 대역을 넣기 쉬워지고, 컨트롤러를 테스트할 때 실제 UserService 대신 그런 대역을 넣어 컨트롤러의 로직만 시험할 수 있으며, 하나의 UserService 인스턴스를 여러 곳에서 함께 쓰고 의존성을 한곳에서 관리할 수 있습니다. 위 예제는 UserService 클래스 자체를 런타임 토큰으로 씁니다. 인터페이스를 기준으로 설계하고 싶다면, 인터페이스만으로는 토큰이 생기지 않으므로 문자열이나 Symbol 같은 별도의 토큰이 필요합니다.
IoC 컨테이너
의존성 주입을 실제로 수행하고 관리하는 주체가 IoC 컨테이너입니다. 일반적인 프로그램에서는 개발자가 객체를 직접 만들고 객체 사이의 관계를 직접 제어합니다. NestJS 같은 프레임워크에서는 이 제어의 권한이 프레임워크로 넘어가며, 이것을 제어의 역전이라고 합니다.
NestJS의 IoC 컨테이너는 세 가지 일을 합니다. 첫째, 모듈의 providers에 등록된 클래스나 값 같은 프로바이더를 관리합니다. @Injectable()은 클래스의 주입 관련 메타데이터를 제공할 뿐, 데코레이터를 붙인 클래스가 모든 모듈에 저절로 등록되지는 않습니다. 둘째, 어떤 클래스가 특정 프로바이더를 필요로 하면 그 인스턴스를 찾아 넣어 주며, 필요하면 그 프로바이더의 의존성까지 재귀적으로 해결합니다. 셋째, 프로바이더 인스턴스를 언제 만들고 언제 없앨지를 범위(scope)에 따라 관리합니다.
NestJS의 프로바이더 대부분은 기본적으로 싱글턴 범위입니다. 같은 프로바이더 등록을 공유하는 소비자들이 하나의 인스턴스를 다시 쓴다는 뜻이며, 서로 다른 모듈에 같은 클래스를 중복 등록하면 인스턴스가 따로 생길 수 있습니다. @Module()의 providers에 클래스를 등록하고 @Injectable()을 붙이는 것은 컨테이너에 이 클래스가 주입할 수 있는 프로바이더라고 알리는 과정이며, 컨테이너는 이 정보로 의존성 그래프를 만들고 필요할 때 인스턴스를 만들어 넣어 줍니다.
토큰, 공개 범위, 인스턴스 범위
토큰은 컨테이너에서 무엇을 찾을지 정하는 식별자이고, 모듈의 imports와 exports는 그 등록을 어디에서 쓸 수 있는지를 정합니다. 예를 들어 UserModule이 providers: [UserService]와 exports: [UserService]를 가지면, ReportsModule은 imports: [UserModule]로 그 서비스를 쓸 수 있습니다. 이때 같은 기본 범위의 등록을 공유하려면 ReportsModule의 providers에 UserService를 또 등록하지 않습니다. 등록이 여러 개이면 클래스 이름이 같아도 상태가 따로 생길 수 있습니다.
TypeScript 인터페이스는 실행할 때 사라지므로 인터페이스 이름으로 조회할 수 없습니다. Symbol 토큰을 쓴다면 하나의 상수를 내보내고, 등록하는 쪽과 @Inject(TOKEN)으로 주입받는 쪽에서 같은 상수를 가져와야 합니다. 다음은 Nest의 등록 코드가 아니라, Symbol의 동일성을 확인하는 자바스크립트 예제입니다.
const USER_REPOSITORY = Symbol('USER_REPOSITORY');
const sameDescription = Symbol('USER_REPOSITORY');
console.log(USER_REPOSITORY === sameDescription); // false
const registrations = new Map([[USER_REPOSITORY, { name: 'memory' }]]);
console.log(registrations.get(USER_REPOSITORY).name); // memory
console.log(registrations.has(sameDescription)); // false설명 문자열이 같아도 Symbol()을 두 번 호출하면 서로 다른 값이 만들어지므로, 등록한 키로만 조회할 수 있습니다.
인스턴스 범위는 공개 범위와 별개의 결정입니다. 세 범위는 다음과 같이 다릅니다.
| 범위 | 인스턴스를 구분하는 기준 | 쓰는 경우 |
|---|---|---|
DEFAULT | 해당 프로바이더 등록을 공유하는 소비자가 다시 씀 | 공통 설정이나 상태 없는 계산 서비스 |
REQUEST | 요청마다 새 인스턴스이며, 이를 주입받는 소비자에게도 요청 범위가 번짐 | 한 요청에서만 필요한 처리 상태 |
TRANSIENT | 주입받는 소비자마다 별도의 인스턴스 | 소비자별 독립 도우미이며, 메서드 호출마다 새로 만든다는 뜻은 아님 |
기본 범위 서비스의 currentUser 필드에 요청한 사용자를 저장하면, 비동기 작업 도중 다른 요청이 그 값을 덮어쓸 수 있습니다. 사용자별 값은 메서드 인자로 넘기는 식으로 요청마다 따로 다뤄야 합니다. DI로 객체를 공유할 수 있다고 해서 모든 가변 상태를 공유해도 되는 것은 아닙니다. 커스텀 프로바이더 문서와 주입 범위 문서는 등록과 수명 주기를 따로 설명합니다.
컨테이너 없이 주입의 효과 확인하기
DI라는 설계 방식과 Nest 컨테이너의 자동 조립은 구분할 수 있습니다. 다음 코드는 서버나 데이터베이스 없이, 자바스크립트로 생성자 주입만 확인하는 작은 예제입니다.
class UserController {
constructor(userService) {
this.userService = userService;
}
getUsers() {
return this.userService.getUsers();
}
}
const productionService = { getUsers: () => ['Alice', 'Bob'] };
const testService = { getUsers: () => ['Test User'] };
console.log(new UserController(productionService).getUsers());
console.log(new UserController(testService).getUsers());두 결과는 각각 ['Alice', 'Bob']과 ['Test User']입니다. 컨트롤러의 구현은 그대로 두고, 밖에서 넘기는 협력 객체만 바꿨습니다. 이 예제에서는 개발자가 직접 조립했고, Nest에서는 모듈의 프로바이더 등록과 컨테이너가 그 조립을 맡습니다. 주입이 모든 결합을 없애 주지는 않으며, 소비자는 여전히 getUsers()라는 메서드의 규칙을 알아야 합니다.
더 깊이: 컨테이너가 생성자의 타입을 읽는 방법
TypeScript의 타입 표기는 컴파일하면 사라지는데도, Nest는 constructor(private readonly userService: UserService)만 보고 무엇을 넣을지 압니다. CLI가 만든 tsconfig.json에 켜져 있는 experimentalDecorators와 emitDecoratorMetadata 옵션 덕분입니다. emitDecoratorMetadata를 켜면 컴파일러는 데코레이터가 붙은 선언에 대해 설계 시점의 타입 정보를 런타임 메타데이터로 남기고, 매개변수의 타입 목록은 design:paramtypes라는 키로 기록합니다. Nest의 인젝터는 클래스에서 이 목록을 읽은 뒤, @Inject(TOKEN)으로 직접 지정한 자리만 그 토큰으로 바꿔 최종 토큰 목록을 만듭니다.
그래서 생성자의 타입 표기는 런타임에 존재하는 값, 즉 클래스를 가리켜야 합니다. 인터페이스와 타입 별칭은 컴파일할 때 지워지고, import type으로 가져온 클래스도 가져오기 자체가 지워지므로 메타데이터에 쓸 클래스 참조가 남지 않습니다. 이때 Nest는 시작 단계에서 Nest can't resolve dependencies of the ...로 시작하는 오류를 내고, 풀지 못한 자리를 ?로 표시한 뒤 index [0]처럼 위치를 알려 줍니다. 공통 오류 문서에 따르면 그 자리의 값이 런타임에 undefined나 Object일 때는 메시지가 토큰 이름을 생략하며, 이 경우에는 import type, 인터페이스 주입, 두 파일이 서로를 가져오는 순환 import를 먼저 의심하면 됩니다.
TypeScript 5.0부터는 ECMAScript 표준을 따르는 데코레이터도 쓸 수 있지만, 이 방식은 emitDecoratorMetadata와 함께 쓸 수 없고 매개변수에 데코레이터를 붙이는 것도 허용하지 않습니다. Nest는 생성자 타입 메타데이터와 @Inject(), @Body() 같은 매개변수 데코레이터에 기대고 있으므로, 프로젝트의 두 옵션은 켠 채로 두어야 합니다.
정리
- 의존성 주입은 객체가 의존성을 직접 만들지 않고 바깥에서 받는 방식이며, NestJS는 주로 생성자 주입을 씁니다.
- IoC 컨테이너는 모듈에 등록된 프로바이더를 관리하고, 의존성을 재귀적으로 해결하며, 범위에 따라 인스턴스의 수명을 정합니다.
- 토큰은 무엇을 찾을지,
imports·exports는 어디서 쓸 수 있는지, 범위는 인스턴스를 얼마나 공유할지를 정하는 서로 다른 결정입니다. - 기본 범위 서비스에 요청별 가변 상태를 두지 않고, 인터페이스 대신 쓸 토큰은 하나의 상수로 공유합니다.
연습 문제
1. 등록과 주입
UserController의 생성자는 그대로인데, UserService를 모듈의 providers에서 제거했고 가져온 모듈에서도 제공하지 않습니다. 앱이 정상적으로 부팅한 뒤 첫 요청에서만 실패하나요? @Injectable()을 다시 붙이면 해결되나요?
해설 보기
이 기본 범위 예제에서는 부트스트랩 중에 컨트롤러의 의존성을 해결할 수 없어 시작 단계에서 실패합니다.
@Injectable() 메타데이터만으로는 모듈에 등록이 생기지 않습니다. 프로바이더를 다시 등록하거나, 그 프로바이더를 내보내는 모듈을 가져와 현재 모듈에서 쓸 수 있게 해야 합니다.
2. 같은 서비스를 공유하는가
UserModule만 UserService를 기본 범위로 등록하고 내보냅니다. ReportsModule은 이 모듈을 가져오고 서비스를 다시 등록하지 않습니다. 두 모듈의 소비자는 같은 등록을 공유하나요? UserModule의 exports만 지우면 어떻게 되나요? 이 두 모듈 말고 이 서비스를 제공하는 곳은 없습니다.
해설 보기
처음 조건에서는 하나의 기본 범위 등록을 공유하므로, 두 모듈의 소비자가 같은 인스턴스를 받습니다.
exports를 지우면 ReportsModule에서 이 프로바이더를 볼 수 없어 의존성 해결에 실패합니다. 가져온 모듈 안에 등록이 있다는 사실과, 그것을 바깥 모듈에 공개했다는 사실은 별개입니다.
3. 설명이 같은 두 Symbol
등록하는 쪽과 주입받는 쪽에서 각각 Symbol('USER_REPOSITORY')를 새로 만들었습니다. 설명 문자열이 같으니 의존성을 찾을 수 있나요? 가장 작은 수정 방법을 쓰세요.
해설 보기
두 Symbol은 서로 다른 값이므로 조회할 토큰이 일치하지 않아 의존성을 찾지 못합니다.
하나의 토큰 상수를 내보내고, 등록하는 쪽과 @Inject()로 주입받는 쪽 모두 같은 상수를 가져오게 고칩니다. 설명 문자열을 바꾸는 것만으로는 해결되지 않습니다.
4. 범위가 번지는 방향
DEFAULT 범위의 컨트롤러가 REQUEST 범위의 서비스를 주입받으면, 컨트롤러의 실제 범위는 어떻게 되나요? 별도로 DEFAULT 범위의 서비스 A와 B가 각각 TRANSIENT 범위의 도우미를 주입받으면, 도우미와 A·B의 범위는 각각 어떻게 되나요?
해설 보기
요청 범위의 의존성 때문에 컨트롤러도 요청 범위로 동작합니다. 요청 범위는 그것을 주입받는 쪽으로 번지기 때문입니다.
반면 A와 B는 각각 별도의 일시(transient) 도우미를 받지만, 그 사실만으로 A와 B가 요청 범위가 되지는 않습니다. 또 A가 살아 있는 동안 같은 도우미의 메서드를 두 번 호출한다고 도우미가 둘 생기는 것도 아닙니다. 일시 범위는 주입받는 쪽마다 인스턴스를 따로 준다는 뜻입니다.
다음 글 미들웨어, 인터셉터, 파이프, 가드에서는 요청이 컨트롤러에 닿기 전후에 끼어드는 구성 요소들이 각각 무엇을 맡고 어떤 순서로 실행되는지 다룹니다.