의존성 주입과 IoC 컨테이너
객체를 직접 생성할 때 생기는 결합을 살펴보고 Nest IoC 컨테이너의 프로바이더 등록과 생성자 주입으로 해결합니다.
1장에서는 NestJS 기본 구성 요소와 첫 애플리케이션 동작 방식을 살펴봤습니다.
이제 2장에서는 NestJS를 더 효과적으로 활용하기 위한 핵심 개념을 깊이 있게 다룹니다.
첫 번째 주제는 의존성 주입(Dependency Injection, DI)과 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.
이 두 개념은 NestJS의 근간을 이루며, 견고하고 유지보수하기 쉬운 애플리케이션을 만드는 데 필수적인 역할을 합니다.
핵심 원리를 단계적으로 정리하면 실제 설계와 구현에서 왜 필요한지 명확하게 확인할 수 있습니다.
컨트롤러가 new UserService() 를 직접 호출하면 생성 방식과 구현체에 묶입니다. NestJS는 provider 를 컨테이너에 등록하고 생성자에 필요한 인스턴스를 주입합니다.
- ordered stepsDI 컨테이너가 하는 일
ordered steps 1 Register @Injectable() 과 providers 배열로 주입 가능한 클래스를 등록한다. 2 Resolve 생성자 타입을 보고 필요한 provider 와 하위 의존성을 찾는다. 3 Create 수명 주기 규칙에 맞춰 인스턴스를 만들거나 기존 인스턴스를 재사용한다. 4 Inject 컨트롤러는 생성 방식을 몰라도 완성된 서비스를 받는다.
- Register
@Injectable() 과 providers 배열로 주입 가능한 클래스를 등록한다.
- Resolve
생성자 타입을 보고 필요한 provider 와 하위 의존성을 찾는다.
- Create
수명 주기 규칙에 맞춰 인스턴스를 만들거나 기존 인스턴스를 재사용한다.
- Inject
컨트롤러는 생성 방식을 몰라도 완성된 서비스를 받는다.
- checkpointsIoC의 핵심 감각
checkpoints 제어권 누가 만들지 결정하는 권한이 클래스 내부에서 Nest 컨테이너로 넘어간다. 그래프 컨테이너는 provider 사이의 의존 관계를 그래프로 보고 순서대로 해결한다. 수명 싱글톤, 요청 스코프 같은 공유 범위를 한 곳에서 관리한다.
- 제어권
누가 만들지 결정하는 권한이 클래스 내부에서 Nest 컨테이너로 넘어간다.
- 그래프
컨테이너는 provider 사이의 의존 관계를 그래프로 보고 순서대로 해결한다.
- 수명
싱글톤, 요청 스코프 같은 공유 범위를 한 곳에서 관리한다.
| 기준 | 직접 생성 | 생성자 주입 |
|---|---|---|
| 결합도 | 사용 클래스가 구현체와 생성 방식을 안다 | 필요 타입만 선언하고 컨테이너가 연결한다 |
| 테스트 | 실제 구현을 떼어내기 어렵다 | mock provider로 테스트 범위를 줄인다 |
| 변경 | 생성자 변경이 호출자에게 퍼진다 | 등록과 토큰 변경으로 흡수한다 |
의존성 주입이란 무엇인가?
의존성 주입과 IoC 컨테이너를 다룰 때 provider 등록, 주입 범위, 생명주기, 테스트 대역 기준을 확인합니다.
NestJS DI의 핵심은 객체 생성 권한을 클래스에서 프레임워크로 옮겨, 결합도와 테스트 부담을 낮추는 것입니다.
- 주입 가능한 대상 표시
등록 @Injectable() 과 providers 배열로 컨테이너가 관리할 클래스를 알립니다.
- 필요한 타입을 찾음
해석 컨테이너가 생성자 타입을 읽고 필요한 provider와 하위 의존성을 재귀적으로 찾습니다.
- 같은 토큰에 mock 연결
교체 테스트에서는 같은 provider 토큰에 fake 객체를 연결해 컨트롤러만 검증할 수 있습니다.
- Provider 선언
서비스를 주입 가능한 클래스로 표시합니다.
- Module 등록
providers 배열에 포함해 컨테이너 범위에 넣습니다.
- Graph 구성
생성자 의존성을 따라 객체 관계를 정리합니다.
- Constructor 주입
필요한 인스턴스를 만들어 클래스에 전달합니다.
- new UserService()
직접 생성 생성 위치가 클래스 내부에 묶여 구현 교체와 단위 테스트가 어려워집니다.
- constructor(service)
DI 사용 클래스는 필요한 타입만 선언하고, 생성 정책과 인스턴스 수명은 컨테이너가 관리합니다.
의존성이라는 단어는 한 클래스가 다른 클래스의 기능이나 데이터를 사용해야 할 때 발생합니다.
예를 들어, 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();
}
}이 방식은 간단해 보이지만 몇 가지 문제점을 야기합니다.
- 강한 결합(Tight Coupling):
UserController가UserService의 구체적인 구현에 직접적으로 묶여 있습니다.UserService의 생성 방식이 변경되거나 다른UserService구현으로 교체해야 할 때UserController코드도 함께 수정해야 합니다. - 테스트의 어려움:
UserController를 테스트할 때UserService까지 함께 인스턴스화됩니다. 이는UserService가 데이터베이스 연결 등 복잡한 외부 의존성을 가지고 있을 경우,UserController의 단위 테스트를 어렵게 만듭니다. 우리는 컨트롤러 자체의 로직만 테스트하고 싶을 때가 많습니다. - 코드 재사용성 저하:
UserService를 사용하는 다른 클래스들도 각각new UserService()를 통해 인스턴스를 생성해야 합니다.
의존성 주입은 이러한 문제점을 해결하기 위한 디자인 패턴입니다.
이는 객체가 자신이 필요로 하는 의존성을 직접 생성하는 대신, 외부(NestJS의 IoC 컨테이너)에서 해당 의존성을 주입받는 방식을 의미합니다.
쉽게 말해, 필요한 부품을 직접 만드는 대신, 외부에서 조립된 부품을 받아서 사용하는 것이죠.
NestJS에서는 주로 생성자 주입(Constructor Injection) 방식을 사용합니다.
// 의존성 주입 방식
import { 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 타입의 userService 인스턴스를 요청합니다.
new UserService()처럼 직접 인스턴스를 생성하지 않습니다.
대신 NestJS가 이 의존성을 감지하고 적절한 UserService 인스턴스를 찾아 UserController 생성자로 주입(Inject)해 줍니다.
NestJS의 DI는 객체 생성 책임을 컨테이너로 옮겨 테스트, 교체, 수명 주기 관리를 단순하게 만듭니다.
- compare두 방식의 차이
compare 직접 생성 const service = new UserService(); 사용자가 구현체와 생성 인자를 모두 알아야 하므로 변경 영향이 커진다. 생성자 주입 constructor(private service: UserService) {} 필요 타입만 선언하고 실제 인스턴스는 컨테이너가 찾아 넣는다.
- const service = new UserService();
직접 생성 const service = new UserService(); 사용자가 구현체와 생성 인자를 모두 알아야 하므로 변경 영향이 커진다.
- constructor(private service: UserService) {}
생성자 주입 constructor(private service: UserService) {} 필요 타입만 선언하고 실제 인스턴스는 컨테이너가 찾아 넣는다.
- checkpoints기억할 기준
checkpoints 작게 값 객체처럼 의존성이 없고 변화가 적으면 직접 생성도 가능하다. 주입 외부 자원, 정책, 테스트 대역이 필요한 객체는 provider로 둔다. 공개 다른 모듈에서 써야 하면 exports로 공개 범위를 명확히 한다.
- 작게
값 객체처럼 의존성이 없고 변화가 적으면 직접 생성도 가능하다.
- 주입
외부 자원, 정책, 테스트 대역이 필요한 객체는 provider로 둔다.
- 공개
다른 모듈에서 써야 하면 exports로 공개 범위를 명확히 한다.
| 신호 | DI가 해결하는 것 | 결과 |
|---|---|---|
| DB나 외부 API를 테스트에서 빼고 싶다 | 대역 provider를 주입한다 | 컨트롤러와 서비스 로직만 검증한다 |
| 캐시, 저장소, 메시지 클라이언트를 바꿀 수 있다 | 토큰 또는 모듈 등록을 교체한다 | 사용하는 클래스 변경을 줄인다 |
| 인스턴스 공유 범위가 중요하다 | 컨테이너가 수명 주기를 관리한다 | 싱글톤과 요청 스코프를 일관되게 쓴다 |
- 느슨한 결합(Loose Coupling): 클래스들이 서로의 구체적인 구현에 묶이지 않고, 인터페이스(TypeScript 타입)에만 의존하게 되어 코드 변경에 유연하게 대응할 수 있습니다.
- 테스트 용이성:
UserController를 테스트할 때, 실제UserService대신 가짜(mock또는stub)UserService를 주입하여 컨트롤러의 로직만을 순수하게 테스트할 수 있습니다. - 코드 재사용성 및 관리 용이성: 하나의
UserService인스턴스를 여러 곳에 재사용할 수 있으며, 의존성 관리가 중앙집중적으로 이루어져 효율적입니다.
Controller는 Service 생성 방법을 모르고 토큰만 요청하므로 TestingModule에서 연결만 바꿀 수 있다.
- 운영 Module
UserService token → Real UserService → DB / API 실제 저장소와 외부 의존을 사용하는 provider를 연결한다.
- TestingModule
UserService token → useValue: mockUsers → No DB 같은 토큰에 fake 객체를 연결해 라우팅·반환·실패만 검증한다.
제어의 역전 컨테이너
이제 의존성 주입이라는 개념을 이해했다면, 이를 실제로 구현하고 관리하는 주체에 대해 알아볼 차례입니다.
그것이 바로 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.
일반적인 프로그래밍 흐름에서는 개발자가 객체를 직접 생성하고, 객체 간의 관계를 직접 제어합니다.
하지만 NestJS와 같은 프레임워크에서는 이 제어의 권한이 프레임워크에게 넘어갑니다.
이것을 제어의 역전(IoC)이라고 부릅니다.
IoC 컨테이너는 이 제어의 역전을 수행하는 핵심 메커니즘입니다.
NestJS의 IoC 컨테이너는 다음과 같은 역할을 수행합니다.
- 프로바이더 관리:
@Injectable()데코레이터가 붙은 클래스(프로바이더)들을 등록하고 관리합니다. - 의존성 해결: 어떤 클래스가 특정 프로바이더를 필요로 할 때, 컨테이너가 해당 프로바이더의 인스턴스를 찾아 주입해 줍니다. 이때 필요하다면 해당 프로바이더의 의존성까지 재귀적으로 해결합니다.
- 인스턴스 생명주기 관리: 프로바이더의 인스턴스를 언제 생성하고, 언제 파괴할지(싱글톤, 스코프 등) 결정합니다. NestJS에서 대부분의 프로바이더는 기본적으로 싱글톤(Singleton) 스코프를 가집니다. 이는 애플리케이션 전체에서 해당 프로바이더의 인스턴스가 단 하나만 생성되어 공유된다는 의미입니다.
IoC 컨테이너는 등록된 프로바이더를 보관하고, 클래스 생성 시 필요한 의존성을 찾아 주입합니다.
클래스는 직접 의존 객체를 만들지 않고 필요한 타입을 선언하며, 컨테이너가 생성 순서와 인스턴스 전달을 관리합니다.
NestJS에서 @Module()의 providers 배열에 클래스를 등록하고 @Injectable()을 붙이는 행위는, IoC 컨테이너에 이 클래스는 주입 가능한 프로바이더라고 알리는 과정으로 볼 수 있습니다.
컨테이너는 이 정보를 바탕으로 내부 의존성 그래프를 구성하고, 필요할 때 인스턴스를 생성해 주입합니다.
아래 다이어그램은 providers 등록 정보가 컨테이너 내부 의존성 그래프로 정리되고, 생성자 주입으로 이어지는 흐름을 묶어 보여줍니다.
컨테이너는 생성자 타입 정보를 읽고 provider 저장소에서 필요한 인스턴스를 찾아 주입한다.
| 단계 | 컨테이너가 하는 일 | 효과 | 테스트에서 바꾸는 지점 |
|---|---|---|---|
| 01 Token 조회 | 생성자가 요구한 UserService 타입을 provider 저장소에서 찾음 | 컨트롤러는 구현 생성 책임을 모름 | 같은 토큰에 mock 연결 |
| 02 Resolve 재귀 해결 | 서비스가 필요한 다른 provider를 먼저 준비 | 의존성 그래프를 컨테이너가 관리 | 빠진 provider가 조립 단계에서 드러남 |
| 03 Inject 인스턴스 주입 | 기본 singleton provider를 만들어 재사용 | 수명 주기 정책을 코드 밖에서 조정 | request scope나 override 정책 확인 |
의존성 주입과 IoC 컨테이너는 NestJS의 모듈성, 테스트 용이성, 유지보수성을 구성하는 핵심 원리입니다.
서비스, 컨트롤러, 모듈의 책임을 분리할 때 이 구조를 기준으로 삼습니다.
다음 절에서는 NestJS 애플리케이션에서 요청을 처리하고 응답을 보내는 과정에서 핵심적인 역할을 하는 요청 파이프라인에 대해 자세히 알아보겠습니다.
서비스와 컨트롤러가 직접 new로 서로를 만들지 않고, 모듈의 providers 등록 정보를 바탕으로 컨테이너가 인스턴스를 조립해 주입한다.
- Module
providers 배열로 사용할 클래스를 등록한다.
- Token
클래스 또는 토큰을 기준으로 의존성 그래프를 만든다.
- Instance
필요한 시점에 생성하고 생명주기를 관리한다.
- Inject
컨트롤러와 서비스 생성자에 준비된 인스턴스를 넣는다.
- 교체 가능성
구현 클래스를 바꾸거나 mock을 넣어도 호출부 코드는 작게 흔들린다.
- 책임 위치
객체 생성은 컨테이너, 비즈니스 로직은 서비스에 남긴다.
- 테스트 기준
생성자 주입은 의존성을 밖에서 넣을 수 있어 단위 테스트가 단순해진다.
const service = new UsersService(repo)constructor(private users: UsersService) {}