Nest DI & IoC

·8 min readHo Choi

Nest DI & IoC

1. 왜 DI가 필요할까?

NestJS로 영화를 다루는 서비스를 만든다고 해보자. MovieLister는 특정 감독이 연출한 영화 목록을 반환하고, 영화 데이터는 MovieFinder가 가져온다.

    interface MovieFinder {
      findAll(): Movie[];
    }

    MovieLister가 MovieFinder를 사용하게 하면, 영화 목록을 가져오는 방식과 감독별로 영화를 고르는 로직을 나눌 수 있다. 하지만 역할을 나눴다고 해서 연결 문제가 저절로 해결되지는 않는다. MovieLister가 실제로 사용할 MovieFinder 구현체를 누군가는 선택하고 전달해야 한다.

    1.1 그냥 new로 만들면 되는 거 아닌가?

    직접 만들어도 된다.

      class MovieLister {
        private readonly finder = new FileMovieFinder();
      
        moviesDirectedBy(director: string): Movie[] {
          return this.finder
            .findAll()
            .filter((movie) => movie.director === director);
        }
      }

      FileMovieFinder가 영화 목록을 파일에서 읽는 구현체라고 하자. 작은 프로그램에서 이 구현만 계속 쓴다면 간단하고 잘 작동한다. 모든 객체를 DI로 연결해야 하는 것은 아니다. 문제는 MovieLister를 사용하는 환경마다 영화 데이터의 출처가 달라질 때 생긴다. 어떤 환경에서는 파일을 쓰고, 다른 곳에서는 데이터베이스나 외부 API를 사용할 수 있다. 그런데 MovieLister가 직접 new FileMovieFinder()를 호출하면 구현을 바꾸기 위해 MovieLister의 코드를 수정해야 한다.

      1.2 객체를 직접 만들면 뭐가 불편할까?

      new로 객체를 만든다는 사실 자체가 문제는 아니다. 불편한 점은 MovieLister가 영화를 찾는 일뿐 아니라 어떤 finder를 사용할지 결정하고 만드는 일까지 맡게 된다는 것이다.

        MovieLister
         ├─ 감독별 영화 선택
         └─ FileMovieFinder 선택 및 생성

        이렇게 되면 MovieLister는 구체적인 구현인 FileMovieFinder에 묶인다. 영화 데이터를 데이터베이스에서 읽도록 바꾸고 싶으면 클래스 안의 생성 코드를 바꿔야 한다. 앞에서 MovieFinder 인터페이스를 만들었더라도, 다음처럼 클래스 안에서 구체 구현을 생성한다면 결합은 남아 있다.

          class MovieLister {
            private readonly finder: MovieFinder =
              new FileMovieFinder();
          }

          타입 선언은 추상화되어 있지만, 어떤 구현을 쓸지 결정하는 코드가 MovieLister 안에 있기 때문이다.

          1.3 그렇다면 객체 생성은 누가 맡아야 할까?

          MovieLister 바깥의 조립 담당자가 맡을 수 있다. 조립 담당자는 어떤 구현체를 사용할지 정하고, 만들어진 객체를 MovieLister에 전달한다.

            class MovieLister {
              constructor(private readonly finder: MovieFinder) {}
            
              moviesDirectedBy(director: string): Movie[] {
                return this.finder
                  .findAll()
                  .filter((movie) => movie.director === director);
              }
            }

            NestJS에서는 모듈의 provider 설정이 이 연결을 맡는다. 단, MovieFinder는 인터페이스이므로 생성자 타입만으로는 Nest가 런타임 토큰을 찾을 수 없다. 별도 토큰을 선언하고 @Inject()로 같은 토큰을 요청해야 한다.

              import { Inject, Injectable } from '@nestjs/common';
              
              export const MOVIE_FINDER = Symbol('MOVIE_FINDER');
              
              @Injectable()
              class MovieLister {
                constructor(
                  @Inject(MOVIE_FINDER)
                  private readonly finder: MovieFinder,
                ) {}
              }
                @Module({
                  providers: [
                    MovieLister,
                    {
                      provide: MOVIE_FINDER,
                      useClass: FileMovieFinder,
                    },
                  ],
                })
                export class MoviesModule {}

                MovieLister에는 MovieFinder 역할을 하는 객체가 전달되고, NestJS는 설정을 보고 FileMovieFinder를 연결한다. 나중에 데이터베이스 구현으로 바꾸더라도 MovieLister의 영화 선택 로직은 그대로 둘 수 있다.

                  {
                    provide: MOVIE_FINDER,
                    useClass: DatabaseMovieFinder,
                  }

                  즉, 객체를 만드는 책임과 사용하는 책임을 분리한 것이다. NestJS의 DI 컨테이너는 이 객체 생성과 연결을 관리한다.

                  1.4 왜 이걸 IoC, ‘제어의 역전’이라고 부를까?

                  MovieLister가 직접 new FileMovieFinder()를 호출할 때는 MovieLister가 필요한 객체의 생성과 선택을 제어한다. 두 방식을 나란히 보면 객체 생성의 책임이 누구에게 있는지 분명해진다.

                  MovieLister와 Nest 컨테이너의 의존성 연결 비교

                  MovieLister가 하던 구현체 선택과 생성을 Nest 컨테이너가 맡도록 제어가 옮겨갔다. 이처럼 제어 주체가 바뀌는 넓은 원칙을 IoC라고 한다. 이 예제에서는 의존성을 외부에서 전달하는 DI로 IoC를 구현한다. IoC와 DI의 차이는 4장에서 다시 요약한다.

                  2. Nest는 객체를 어떻게 관리할까?

                  앞에서는 MovieLister가 필요한 MovieFinder를 Nest가 바깥에서 전달하도록 만들었다. 그렇다면 Nest는 어떤 객체를 만들고, 어느 구현체를 연결해야 하는지 어떻게 알까? 핵심은 모듈에 객체와 연결 정보를 등록하는 것이다. Nest는 모듈에 적힌 정보를 읽어 애플리케이션의 구조를 만들고, 각 객체의 의존성을 연결한다.

                  2.1 Nest는 어떤 객체들이 존재하는지 어떻게 알까?

                  Nest가 프로젝트의 모든 클래스를 찾아 자동으로 사용하는 것은 아니다. 앱을 시작할 때 루트 모듈부터 읽고, 루트 모듈이 가져오는 다른 모듈과 각 모듈에 등록된 Controller 및 Provider를 확인한다. 예를 들어 AppModule이 MoviesModule을 가져오면 Nest는 MoviesModule도 앱의 일부로 처리한다.

                    @Module({
                      imports: [MoviesModule],
                    })
                    export class AppModule {}

                    AppModule에서 시작해 연결된 모듈을 따라가며 구성 요소와 의존 관계를 파악한 결과가 Nest의 애플리케이션 그래프다. 앱이 커지면 이 그래프에 모듈과 Provider가 더 많이 들어간다. 중요한 점은 파일을 어느 폴더에 두었느냐가 아니라, 모듈의 메타데이터를 통해 Nest가 그 파일의 클래스를 앱 구성 요소로 알게 했느냐다. Nest CLI가 만들어주는 폴더와 파일 구조는 코드를 정리하기 위한 관례다.

                    2.2 Module 파일은 무슨 역할을 할까?

                    Nest에서 모듈은 단순히 *.module.ts 파일을 뜻하지 않는다. @Module() 데코레이터가 붙은 클래스와 그 클래스에 선언한 메타데이터가 모듈을 이룬다. 모듈은 관련된 Controller와 Provider를 한 기능 단위로 묶고, 그 기능이 앱의 다른 부분과 어떤 관계를 맺을지 정한다. MoviesModule에는 영화 기능에 필요한 구성 요소를 모을 수 있다.

                      @Module({
                        controllers: [MoviesController],
                        providers: [MovieLister, FileMovieFinder],
                      })
                      export class MoviesModule {}

                      모듈은 구성 요소의 경계도 정한다. 한 모듈의 Provider는 기본적으로 그 모듈 안에서 사용할 수 있다. 다른 모듈에서도 쓰게 하려면 내보내고(exports), 사용하는 모듈에서 가져와야 한다(imports).

                        @Module({
                          providers: [MovieLister],
                          exports: [MovieLister],
                        })
                        export class MoviesModule {}

                        따라서 모듈은 “이 기능에 어떤 구성 요소가 있고, 그중 무엇을 다른 기능에 공개할지”를 설명하는 설계도라고 볼 수 있다. (NestJS Modules 공식 문서)

                        2.3 controllers와 providers에는 왜 등록해야 할까?

                        Nest는 모듈의 controllers와 providers를 보고 각 클래스를 어떤 역할로 다룰지 파악한다.

                        • controllers에는 HTTP 요청과 라우트를 처리할 Controller를 등록한다.
                        • providers에는 Controller 등에 주입할 서비스나 다른 Provider를 등록한다.
                          @Module({
                            controllers: [MoviesController],
                            providers: [
                              MovieLister,
                              {
                                provide: MOVIE_FINDER,
                                useClass: FileMovieFinder,
                              },
                            ],
                          })
                          export class MoviesModule {}

                          이 배열에 등록해야 Nest가 MoviesController를 라우트 처리자로 만들고, MovieLister와 MOVIE_FINDER Provider를 찾아 주입할 수 있다. 즉, 모듈 등록은 클래스를 앱에 알리고 사용할 범위를 정하는 단계다. 인터페이스를 토큰으로 연결하는 이유와 provide/useClass의 역할은 3.2에서 자세히 살펴본다.

                          2.4 @Injectable()은 왜 붙이는 걸까?

                          @Injectable()은 클래스에 Nest가 사용할 메타데이터를 붙인다. Nest가 그 클래스를 Provider로 다룰 수 있도록 표시하고, 옵션을 지정할 때는 스코프 같은 정보도 전달한다. 하지만 @Injectable()만 붙인다고 모듈에 등록되는 것은 아니다. Nest가 해당 클래스를 앱에서 사용하도록 하려면 모듈의 providers에 등록해야 한다.

                            @Injectable()
                            export class MovieLister {
                              constructor(
                                @Inject(MOVIE_FINDER)
                                private readonly finder: MovieFinder,
                              ) {}
                            }
                            
                            @Module({
                              providers: [
                                MovieLister,
                                {
                                  provide: MOVIE_FINDER,
                                  useClass: FileMovieFinder,
                                },
                              ],
                            })
                            export class MoviesModule {}

                            여기서는 서로 다른 두 가지 일이 일어난다.

                            1. @Injectable()은 MovieLister에 Nest가 참고할 메타데이터를 붙인다.
                            1. providers는 MovieLister와 MOVIE_FINDER 연결을 MoviesModule에 등록한다.

                            Nest는 이 등록 정보를 바탕으로 Provider를 만들고 생성자에 필요한 의존성을 전달한다. 데코레이터는 클래스에 정보를 표시하고, 모듈 등록은 그 클래스를 앱 구성에 포함한다. 둘의 역할을 구분하면 Nest가 객체를 관리하는 방식을 이해하기 쉽다.

                            3. Nest는 의존성을 어떻게 찾아서 연결할까?

                            2장에서 Nest에 객체와 연결 정보를 등록하는 방법을 살펴봤다. 이제 Nest가 그 정보로 의존성을 어떻게 찾고, 필요한 객체를 준비하는지 알아보자. 이 장에서는 MovieLister가 MOVIE_FINDER 토큰을 요청하고, Nest가 이를 실제 구현체인 FileMovieFinder와 연결한다고 가정한다.

                            3.1 Nest는 내가 어떤 객체를 필요로 하는지 어떻게 알까?

                            2장에서는 모듈이 앱에 어떤 구성 요소를 등록하는지 살펴봤다. 여기서는 등록된 구성 요소 중 소비자가 무엇을 요구하는지 살펴보자. Nest는 생성자 매개변수의 클래스 타입 메타데이터를 읽어 필요한 Provider 토큰을 알아낸다. 별도 토큰을 사용하는 경우에는 @Inject(MOVIE_FINDER)가 조회할 토큰을 지정한다. TypeScript 인터페이스는 런타임에 사라지므로 타입 표기만으로는 토큰이 되지 않는다. 따라서 Nest는 “모듈에 무엇이 등록되어 있는가”와 “생성자가 무엇을 요구하는가”를 맞춰 의존성을 해결한다. (Providers 공식 문서)

                            3.2 Provider와 Token은 왜 따로 존재할까?

                            토큰은 어떤 의존성을 요청하는지 나타내는 이름표이고, Provider는 그 토큰에 무엇을 연결할지 정하는 등록 정보다.

                              {
                                provide: MOVIE_FINDER,
                                useClass: FileMovieFinder,
                              }

                              이 등록은 MOVIE_FINDER라는 토큰에 FileMovieFinder 클래스를 연결한다는 뜻이다. MovieLister는 토큰으로 필요한 기능을 요청하고, Nest는 모듈에 등록된 Provider를 찾아 구현체를 결정한다. 토큰과 구현체를 분리하면 MovieLister를 수정하지 않고도 연결 대상을 바꿀 수 있다.

                                {
                                  provide: MOVIE_FINDER,
                                  useClass: DatabaseMovieFinder,
                                }

                                여기서 토큰은 그대로 두고 Provider만 바꿨다. 그러면 MOVIE_FINDER를 요청하는 코드는 데이터베이스 구현체를 받는다. Nest에서는 클래스, 문자열, 심벌 등을 토큰으로 사용할 수 있다. (Custom providers 공식 문서)

                                3.3 의존성이 또 다른 의존성을 필요로 하면 어떻게 될까?

                                MovieLister가 필요로 하는 FileMovieFinder도 설정 서비스를 필요로 할 수 있다. 아래 코드는 ConfigService가 현재 모듈에서 사용할 수 있도록 등록되어 있다고 가정한다. 실제 앱에서는 ConfigModule을 해당 모듈에 가져오고, 그 모듈이 ConfigService를 공개하도록 구성해야 한다.

                                  @Injectable()
                                  class FileMovieFinder implements MovieFinder {
                                    constructor(private readonly configService: ConfigService) {}
                                  
                                    findAll(): Movie[] {
                                      const filePath = this.configService.get('MOVIES_FILE');
                                      // filePath에서 영화 목록을 읽는다.
                                      return [];
                                    }
                                  }

                                  이 경우 의존성은 다음처럼 이어진다.

                                    MovieLister
                                    └─ MOVIE_FINDER → FileMovieFinder
                                       └─ ConfigService

                                    Nest는 MovieLister가 요청한 MOVIE_FINDER만 찾고 끝내지 않는다. FileMovieFinder의 생성자도 살펴 ConfigService가 필요하다는 것을 확인하고, 그 의존성까지 해결한다. 이렇게 다른 의존성에 연결된 의존성을 따라가는 것을 의존성 그래프의 전이적 해결이라고 볼 수 있다. (Custom providers 공식 문서)

                                    3.4 Nest는 어떤 순서로 객체를 생성할까?

                                    소비자가 필요로 하는 객체를 바로 만들기 전에, 그 객체가 필요로 하는 의존성부터 준비한다. 앞의 예에서는 대략 다음 순서로 해결된다.

                                      1. MovieLister에 MOVIE_FINDER가 필요하다는 것을 확인
                                      2. MOVIE_FINDER에 연결된 FileMovieFinder를 확인
                                      3. FileMovieFinder에 필요한 ConfigService를 확인하고 준비
                                      4. FileMovieFinder를 준비
                                      5. 완성된 finder를 MovieLister에 전달

                                      따라서 보통 의존성 그래프의 아래쪽, 즉 더 이상 다른 Provider에 의존하지 않는 객체부터 준비한 뒤 이를 사용하는 객체로 올라온다. 이 과정을 Nest가 맡으므로 개발자가 복잡한 객체 생성 순서를 직접 관리할 필요가 없다. 이 목록은 의존성 관계를 이해하기 위한 단순화된 순서이며, 독립적인 Provider 사이의 고정된 전역 생성 순서를 뜻하지는 않는다. 다만 서로가 서로를 필요로 하는 순환 의존성이 있으면 단순한 생성 순서로 해결할 수 없다. 그런 구조는 되도록 의존 관계를 다시 나누는 편이 좋다. (Custom providers 공식 문서)

                                      3.5 이미 만든 객체는 매번 다시 만들까?

                                      기본 스코프는 싱글턴이다. 같은 Provider의 인스턴스를 앱에서 공유하고, Nest는 이를 캐시해 이후에도 재사용한다. 예를 들어 여러 Controller와 서비스가 같은 MovieLister Provider를 사용한다면 기본 설정에서는 매번 새로 만들기보다 기존 인스턴스를 사용한다. 모듈 간에 같은 인스턴스를 공유하려면 Provider를 등록한 모듈이 이를 exports에 공개하고, 사용하는 모듈이 그 모듈을 imports에 추가해야 한다. 반대로 같은 클래스를 여러 모듈에 각각 등록하면 모듈마다 별도 인스턴스가 만들어질 수 있다. (Modules 공식 문서) 싱글턴 외에 다른 수명이 필요하면 스코프를 지정할 수 있다.

                                      • DEFAULT: 앱에서 인스턴스를 공유한다. 기본값이다.
                                      • REQUEST: 요청마다 새 인스턴스를 만든다.
                                      • TRANSIENT: Provider를 주입받는 소비자마다 전용 인스턴스를 만든다.

                                      대부분의 경우에는 기본 싱글턴 스코프를 사용하면 된다. 요청마다 다른 상태가 필요한 경우에만 다른 스코프를 고려한다. (Injection scopes 공식 문서)

                                      4. 결국 DI와 IoC는 무엇일까?

                                      앞 장에서는 Nest가 토큰을 이용해 Provider를 찾고, 필요한 객체를 만들어 생성자에 전달하는 과정을 살펴봤다. 이 과정을 DI와 IoC라는 말로 정리해 보자.

                                      4.1 그래서 DI는 정확히 어디에서 일어나는 걸까?

                                      3장에서 본 것처럼, Nest가 Provider를 찾아 MovieLister 생성자에 전달하는 순간이 DI다. MovieLister는 의존성을 선언하고, 실제 구현체의 선택과 연결은 Nest가 맡는다.

                                      4.2 IoC와 DI는 결국 뭐가 다른 걸까?

                                      • IoC는 객체 생성과 연결을 누가 제어하는지에 관한 원칙이다. Nest에서는 그 제어를 컨테이너가 맡는다.
                                      • DI는 필요한 객체를 외부에서 전달해 의존성을 연결하는 방식이다. Nest는 DI를 사용해 IoC를 구현한다.

                                      4.3 Nest의 DI 구조를 한 장으로 정리하면?

                                      Nest DI 컨테이너가 Provider와 토큰을 해결하는 흐름

                                      그림은 모듈에 등록한 Provider 정보와 생성자가 요구하는 토큰을 Nest가 맞춰, 의존성을 해결하고 소비자에게 전달하는 흐름을 요약한다. 기본 스코프에서는 준비한 인스턴스를 재사용한다.