본문으로 건너뛰기
zoe

NestJS의 의존성 주입(DI), 왜 필요하고 실제로는 어떻게 쓸까

NestJS를 공부하면서 처음으로 @Injectable()이라는 데코레이터를 사용하게 되었다.

서비스에서@Injectable() 을 사용하면, Controller에 알아서 인스턴스가 들어와 바로 사용할 수 있었다.

이러한 과정이 왜 필요하며, 어떻게 동작하는 지 정리해보려고한다.


new를 안 했는데 어떻게 사용할 수 있었을까?

this.boardsService를 자연스럽게 쓰고 있지만, 우리는 어디에서도 new BoardsService()를 하지 않았다. 그럼 누가 이 인스턴스를 만들어서 넣어준 걸까?

답은 NestJS의 DI 컨테이너다. 왜 필요한 지 느껴보기 위해 nest의 도움이 없다는 가정하에 생각해보았다.

만약 직접 해야 했다면

NestJS의 도움 없이, 순수 TypeScript로 같은 구조를 만든다고 해보자.

서비스가 늘어날수록 조립 코드도 늘어난다. 여기서 세 가지 문제가 생긴다.

1. 생성 순서를 직접 관리해야 한다

AuthServiceUsersService가 먼저 만들어져야 한다. UsersServiceConfigService가 먼저 만들어져야 한다. 서비스가 늘어날수록 "누가 누구보다 먼저 만들어져야 하는지"를 개발자가 직접 파악해야 한다. 순서 하나 틀리면 런타임 에러가 발생한다.

NestJS DI는 이 순서를 자동으로 해석해준다. providers에 등록만 하면 된다.

2. 같은 인스턴스를 공유할 수 없다

세 곳 다 같은 설정을 읽는데, ConfigService를 3개나 만들 이유가 없다. 하나만 만들어서 돌려쓰면 되는데, 직접 new하면 하나만 만들고 공유하는 것을 개발자가 직접 관리해야 한다.

NestJS가 해주는 것

직접 new하고, 순서 맞추고, 공유 관리하던 것을 NestJS가 전부 대신 해주는 것. 이것이 DI(의존성 주입)다.

그럼 이 의존성 주입이 정확히 어떤 원리로 동작하는지 알아보자.


DI란 무엇일까 ?

위의 문제를 한 문장으로 요약하면 이렇다.

필요한 것을 직접 만들지 말고, 밖에서 넣어줘.

이것이 의존성 주입(Dependency Injection)의 전부다.

제어의 역전(IoC)

DI를 이해하려면, 먼저 IoC(Inversion of Control)라는 상위 개념을 알아야 한다.

일반적인 코드에서는 내가 흐름을 제어한다.

IoC에서는 프레임워크가 흐름을 제어한다.

레스토랑 주방에 비유하면, 셰프가 매번 시장에 가서 재료를 사오는 게 아니라, 식자재 업체가 주방으로 배달해주는 것이다. 셰프는 요리에만 집중할 수 있다.

DI는 이 IoC를 구현하는 구체적인 방법 중 하나다. "필요한 의존성을 외부에서 주입한다"는 뜻이다.

DI의 세 가지 구성요소

구성요소역할NestJS에서
Provider주입될 수 있는 것@Injectable() 클래스
Consumer주입받는 것Controller, 다른 Service 등
Container둘을 연결해주는 것NestJS IoC 컨테이너

이 세 가지가 어떻게 맞물리는지, NestJS의 코드를 살펴보자.


NestJS는 DI를 어떻게 구현하는가

Step 1: @Injectable()

@Injectable()은 TypeScript의 데코레이터(Decorator)다. 이 데코레이터가 붙으면, NestJS에게 **"이 클래스는 의존성으로 관리해줘"**라고 알리는 것이다.

내부적으로는 reflect-metadata 라이브러리를 통해 클래스의 constructor 파라미터 타입 정보를 메타데이터로 저장한다. NestJS는 나중에 이 메타데이터를 읽어서, 어떤 의존성이 필요한지 파악한다.

Step 2: @Module()

providers 배열에 등록된 클래스들이 이 모듈의 IoC 컨테이너에 등록된다. NestJS는 앱이 시작될 때 모든 모듈을 순회하면서, 등록된 provider들의 인스턴스를 생성하고 관리한다.

Step 3: Constructor Injection

Controller의 constructor에 BoardsService 타입을 선언하면, NestJS가 자동으로 해당 인스턴스를 찾아서 주입한다.

이 과정을 풀어보면:

그래서 우리는 new를 한 번도 쓰지 않고도 boardsService를 사용할 수 있었던 것이다.

의존성 해석 과정 시각화

만약 BoardsService가 다른 서비스에 의존한다면?

NestJS는 **의존성 그래프를 만들어서, 가장 아래부터 순서대로 생성한다.

NestJS의 의존성 그래프

직접 new할 때 우리가 수동으로 했던 일을, NestJS 컨테이너가 자동으로 해주는 것이다.


기본 DI 외에 알아야 할 것들

기본 DI 패턴으로 대부분의 상황을 해결할 수 있지만, 더 복잡한 시나리오들도 있다.

Custom Provider — 구현체를 교체해야 할 때

같은 토큰(MAIL_SERVICE)으로 요청하지만, 환경에 따라 다른 구현체를 넣을 수 있다. 프로덕션에서는 AWS SES로, 개발 환경에서는 콘솔에 출력하는 Mock으로 바뀐다.

또는 설정값처럼 이미 만들어진 값을 주입할 수도 있다.

또는 팩토리 함수로 동적 생성이 필요한 경우:

팩토리 안에서 비동기 초기화, 조건부 로직, 다른 Provider를 조합을 할 수 있다.

Scope — 언제 새로운 인스턴스가 필요할까

기본적으로 NestJS의 Provider는 싱글톤이다. 한 번 만들고 앱이 종료될 때까지 같은 인스턴스를 공유한다.

Scope사용 사례
DEFAULT대부분의 서비스
REQUEST요청별 사용자 컨텍스트, 멀티테넌시
TRANSIENT각자 독립된 상태가 필요한 로거

싱글톤이 기본인 이유는 성능이다. 한 번 만든 인스턴스를 계속 쓰는 게 더 효율적이기 때문이다. 꼭 필요할 때만 REQUESTTRANSIENT를 사용해야한다.


테스트에서 빛나는 DI

DI의 가장 실용적인 이점은 테스트에서 나온다.

DI 없이 테스트

네트워크가 느리면 테스트가 느려지고, DB가 꺼져있으면 테스트가 실패하는 등 외부 영향을 많이 받게 된다.

DI로 테스트

Test.createTestingModule()은 실제 NestJS 모듈과 동일한 DI 컨테이너를 만들되, 원하는 Provider만 mock으로 교체할 수 있게 해준다. 이것이 가능한 이유는, Service가 BoardsRepository를 직접 new하지 않고 주입받기 때문이다.

"밖에서 넣어달라"는 DI의 원칙 덕분에, 주입하는 것만 바꾸면 코드 한 줄 안 고치고 환경을 전환할 수 있다.


마무리

nestjs의 DI는 아래와 같은 명확한 과정이 있었다.

  1. @Injectable()이 메타데이터로 타입 정보를 저장하고
  2. @Module()providers가 컨테이너에 클래스를 등록하고
  3. 컨테이너가 의존성 그래프를 만들어 아래부터 순서대로 인스턴스를 생성하고
  4. constructor 파라미터에 맞는 인스턴스를 찾아서 주입하는

결국 DI의 본질은 간단하다. "필요한 것을 직접 만들지 말고, 밖에서 넣어줘" 이 한 문장을 코드로 구현한 것이 @Injectable()이고, 시스템으로 구현한 것이 IoC 컨테이너다.

간단하게 @Injectable()하나만으로 이루어졌던 과정을 정리해 볼 수 있어 의미있었던 것 같다 😎