Nest.js를 이해하는 DI와 IoC의 기본 구조
DI와 IoC는 Nest.js의 클래스, 모듈, 테스트 구조를 이해하는 핵심 개념이다. 객체를 누가 만들고 어떻게 주입하는지의 제어권을 기준으로, 동작 원리와 실패 포인트를 함께 짚는다.
Script Companion
오디오와 함께 스크립트 보기
- 01
DI와 IoC는 Nest.js 코드를 읽기 위한 기본 문법에 가깝다. Service, Controller, 생성자 파라미터로 의존성을 받는 구조, 그리고 Injectable 표시가 모두 이 원리 위에서 움직인다. 이 개념을 모르면 Nest.js 코드가 왜 클래스 중심으로 생겼는지 이해하기 어렵고, 서버 시작 단계에서 의존성 에러가 났을 때 어디를 봐야 하는지도 흐려진다. 핵심은 객체를 직접 만드는 코드에서 벗어나, 필요한 객체를 외부에서 받도록 구조를 바꾸는 것이다.
- 02
DI와 IoC는 갑자기 나온 개념이 아니라, 객체를 어떻게 얻을 것인가에 대한 답이 진화한 결과다. 처음에는 필요한 구현체를 직접 new로 만들었고, 그다음에는 Factory가 생성을 대신했다. 이후 Service Locator가 등장했지만, Martin Fowler가 지적한 것처럼 모든 사용자가 Locator에 의존하는 새 결합점이 생겼다. DI에서는 클라이언트가 의존성의 존재만 선언하고, 조달 방법은 Container가 맡는다. 그래서 구현체 이름, Factory 이름, Locator 자체가 차례로 클라이언트 밖으로 밀려난다.
- 03
비유로 보면, 요리사가 재료를 직접 시장에 가서 사 오지 않고 주방장이 미리 준비해 건네주는 구조와 비슷하다. 요리사는 자신에게 채소가 필요하다고 말할 뿐이고, 재료를 어디서 어떻게 가져오는지는 주방장이 처리한다. 여기서 필요한 재료를 건네받는 방식이 DI이고, 재료 조달의 제어권이 주방장에게 넘어간 것이 IoC다. React의 Provider가 하위 컴포넌트 트리에 값을 전달하는 것과 비슷한 면이 있지만, NestJS Container는 앱 시작 시점에 의존성 그래프를 분석해 인스턴스를 연결한다는 차이가 있다.
- 04
IoC는 NestJS와 Angular에만 있는 개념이 아니다. Martin Fowler가 Hollywood Principle, 즉 Don't call us, we'll call you라고 표현한 것처럼, 개발자가 프레임워크를 계속 호출하는 대신 프레임워크가 적절한 시점에 개발자의 코드를 호출하는 구조다. Spring IoC에서는 ApplicationContext가 빈의 생성과 주입, 생명주기를 관리한다. Express 미들웨어, DOM 이벤트 핸들러, React Hooks에서도 개발자는 무엇을 할지만 정의하고, 언제 어떤 순서로 실행할지는 프레임워크나 런타임이 결정한다.
- 05
DI와 IoC가 설계 원칙으로 쓰이는 배경에는 SOLID의 의존성 역전 원칙과 단일 책임 원칙이 있다. 비즈니스 로직이 DB 연결이나 HTTP 클라이언트 같은 저수준 구현체에 직접 묶이면 교체와 테스트가 어려워진다. DI는 고수준 모듈과 저수준 모듈이 추상에 의존하게 만드는 실용적인 방법이다. 또 객체가 자기 의존성을 조달하는 책임까지 갖지 않게 하므로, 객체는 본래 역할에 집중하고 의존성 조달은 Container가 맡는다.
- 06
Nest.js가 클래스 기반 DI를 택한 이유도 동작 원리와 연결된다. TypeScript는 클래스 생성자 파라미터 타입을 emitDecoratorMetadata로 런타임 메타데이터에 남길 수 있지만, 일반 함수의 파라미터 타입은 그렇게 보존하지 않는다. Nest가 어떤 의존성을 주입해야 하는지 자동으로 파악하려면 클래스 구조가 필요하다. Angular가 같은 이유로 클래스 기반인 것도 같은 설계 철학에서 나온다. Nest의 자동 주입은 TypeScript, 데코레이터, reflect-metadata가 함께 작동한 결과다.
- 07
Nest Container는 앱 시작 시 모듈을 스캔하고, 등록된 Provider 목록을 모은 뒤, 생성자 파라미터의 design:paramtypes 메타데이터를 읽어 의존성 그래프를 만든다. 그리고 의존성이 없는 것부터 역방향으로 인스턴스를 생성해 필요한 곳에 주입한다. 여기서 중요한 초보자 함정은 Injectable을 붙이는 것만으로 충분하지 않다는 점이다. 해당 모듈의 providers에 등록되어야 Container가 인스턴스를 만들 수 있고, 다른 모듈에서 쓰려면 exports로 명시적으로 공개해야 한다.
- 08
Scope는 인스턴스 생명주기를 정하는 기준이다. 기본값은 DEFAULT, 즉 앱 전체에서 하나의 인스턴스를 공유하는 싱글턴이고, 대부분의 경우 이 기본값을 쓴다. REQUEST는 HTTP 요청마다 새 인스턴스를 만들고, TRANSIENT는 주입될 때마다 새 인스턴스를 만든다. REQUEST Scope는 편리해 보이지만 Scope Bubbling이 있다. REQUEST Provider를 의존하는 상위 Provider도 REQUEST로 승격될 수 있고, NestJS 문서의 약 5% 지연 언급은 잘 설계된 앱의 상한선이지 보장값이 아니다.
- 09
클래스가 아닌 값이나 동적으로 만든 객체를 주입할 때는 useValue나 useFactory 같은 커스텀 Provider를 쓴다. 이때 Container가 자동으로 타입을 알 수 없으므로 문자열이나 Symbol 같은 명시적 토큰이 필요하다. 문자열 토큰은 오타와 충돌 위험이 있으므로, 문서에서는 Symbol을 더 안전한 방식으로 설명한다. 다만 DI가 항상 정답은 아니다. 일회성 스크립트, 상태 없는 순수 함수 유틸리티, 런타임 교체가 없는 경우, 프로토타입 단계에서는 직접 생성이 더 단순할 수 있다.
- 10
테스트에서 DI의 장점은 특히 분명하다. 같은 토큰에 Mock을 등록하면 서비스 코드를 바꾸지 않고 실제 DB, 슬랙 API, 외부 HTTP 클라이언트 같은 의존성을 대체할 수 있다. BackOps 슬랙봇 모듈의 WebClient 판단처럼, 테스트나 런타임에서 구현체를 한 번이라도 교체해야 한다면 DI의 이점이 커진다. 반대로 교체할 일이 없다면 Container 설정 비용이 오히려 부담이 될 수 있다. 판단 질문은 단순하다. 이 의존성을 다른 구현으로 바꿀 일이 있는가를 먼저 본다.
- 11
대표적인 실패는 Nest can't resolve dependencies 에러다. 이 메시지는 Container가 자기 시점에서 어떤 의존성을 어디서 찾아야 할지 모른다고 말하는 것이다. 그래서 먼저 생성자 파라미터 위치를 보고, 같은 모듈의 providers 등록 여부와 다른 모듈의 imports, exports 연결을 확인한다. 순환 의존성은 설계 문제일 가능성이 높으므로 공통 로직을 별도 서비스로 빼는 것을 먼저 검토하고, forwardRef는 최후의 수단으로 본다. import type이나 emitDecoratorMetadata 누락도 런타임 타입 정보가 사라져 DI를 깨뜨릴 수 있다.
- 12
정리하면 IoC는 객체 생성과 관리의 제어권을 프레임워크에 넘기는 원칙이고, DI는 필요한 객체를 직접 만들지 않고 외부에서 주입받는 구현 방법이다. Nest.js에서는 Injectable, providers 등록, 모듈의 imports와 exports, 그리고 emitDecoratorMetadata가 이 구조를 떠받친다. 기본 Scope는 싱글턴이며, REQUEST Scope는 Scope Bubbling을 함께 봐야 한다. 의존성 에러가 나면 Container의 시야, 즉 모듈 단위 그래프에서 빠진 Provider와 공개 경계를 확인하는 것이 출발점이다.
같은 레이어
L0에서 이어 듣기
- 오디오 파일
- /podcasts/l0-di-ioc.mp3