← Notes

Nest.js 의존성 주입 개념과 적용 방법

Nest.js 백엔드 개발· 3 / 8
  1. 1 — Nest.js 프로젝트 생성과 구조
  2. 2 — Nest.js Decorator 동작원리와 종류, Custom Decorator
  3. 3 — Nest.js 의존성 주입 개념과 적용 방법
  4. 4 — Nest.js HTTP exceptions 에러 처리 모음
  5. 5 — Nest.js CORS 설정
  6. 6 — Nest.js 유효성 검사 — Validation Pipe와 class-validator
  7. 7 — Nest.js API 문서화 — Swagger Module
  8. 8 — Nest.js 환경 변수 관리 — Config Module

Nest.js를 처음 접했을 때, 의존성 주입은 너무 생소한 개념이었고 현재도 감을 잡았을 뿐 명확하게 이해하고 있는 정도는 아니다. ‘의존성’과 ‘주입’이라는 단어도 자주 사용하지 않는데, 소프트웨어의 기술적 비유로 사용하니 더욱 그렇다. 이런 난해한 개념이 등장할 때마다 프로그래밍의 벽을 느끼곤 한다.

그럼에도 객체 지향 프로그래밍을 이해하는 데에 있어 중요한 개념인 의존성 주입을 Nest.js를 공부하면서 덤으로 이해할 수 있는 기회인 것 같아 한편으로는 다행이다.

의존성 주입의 개념을 먼저 알아보고 Nest.js에서는 어떻게 적용하는지 살펴보자.

의존성 주입(Dependency Injection)이란?

의존성 주입이라는 것을 이해하기 위해서는 의존성 주입이라는 방식을 사용하는 이유와 밀접한 관련이 있는 객체 지향 프로그래밍을 먼저 알아볼 필요가 있다.

객체 지향 프로그래밍

여기서 객체란 데이터를 저장 할 변수와 그 데이터를 이용해 작업을 수행 할 메소드를 서로 연관된 것들 끼리 묶어서 만든 하나의 개체라고 할 수 있다. 결국, 이 객체라는 것을 어떻게 생성하고 어떻게 사용하는지가 객체 지향 프로그래밍에서의 중요한 관점이다.

객체 지향 프로그래밍에는 다양한 패러다임 및 방식이 존재하는데, 대표적으로 두가지 방식이 있다.

  1. 클래스 기반 객체 지향 프로그래밍(Class-based OOP)

    클래스를 기반으로 하는 객체 지향 프로그래밍의 전통적인 방식이다. 객체 생성을 위해 클래스(Class)라는 설계도을 사용하여 객체의 상태와 행위(변수와 메소드)를 정의한다. 객체는 이러한 클래스의 인스턴스다. (프로그래밍 언어: Java, C#, Python 등)

  2. 프로토타입 기반 객체 지향 프로그래밍(Prototype-based OOP)

    객체를 직접적으로 다른 객체로부터 생성하는 방식이다. 이때 프로토타입(prototype)이라는 DNA를 통해 생성된 객체는 다른 객체의 원형이 된다. 프로토타입 체인을 통해 상속을 구현하여 객체 간의 관계를 형성되고 새로운 객체가 원본 객체의 기능과 특성을 공유하고 확장한다. (프로그래밍 언어: JavaScript)

여기서 주목할 점은 의존성 주입은 클래스 기반 객체 지향 프로그래밍에서 사용하는 주요한 디자인 패턴이라는 점이다. 즉, 클래스 단위로 구성되어 있는 구조에서 코드의 재사용성과 유연성을 높이면서 다른 클래스의 기능을 사용하기 위해 활용되는 방식이 바로 의존성 주입이다. (주된 경향 o, 무조건 x)

의존성 주입

의존성 주입(Dependency Injection) 은 클래스 간의 결합도를 낮추는 디자인 패턴이다. 클래스가 필요로 하는 객체를 직접 생성하는 것이 아니라, 외부에서 제공받아 새로운 기능을 쉽게 추가(유연성 및 확장성)하거나 클래스들 간의 관계를 명확하게 정의(유지보수성)하고 단위 테스트를 쉽게 작성(테스트 용이성)할 수 있는 이점이 있다.

의존성 주입을 이해하기 위해서 먼저 “의존성” 이라는 개념을 해석할 필요가 있다.

코드 재사용성과 유지보수성 등의 이유로 기능적으로 서로 다른 코드는 클래스로 분리한다. 그 중 특정 클래스B 가 필요에 의해 클래스A의 기능을 가져와 사용하는데, 그때 클래스B클래스A의존성이 있다고 말할 수 있다.

클래스A를 가져다 사용한다는 것 즉, 클래스B클래스A라는 의존성을 갖는 것.

의존성을 갖게 하는 방식으로 두가지 방식이 있다.

하나는 외부(클래스C)에서 클래스B 를 사용하는 시점에 그의 의존성인 클래스A를 집어 넣는 의존성 주입과 다른 하나는 클래스B 에서 의존성인 클래스A를 미리 직접 생성하여 사용하는 의존성 생성(인스턴스 생성) 이다.

의존성 주입과 인스턴스 생성의 차이

주로 인스턴스 생성이라고 불리는 의존성 생성과 의존성 주입의 로직 차이를 코드로 살펴보자.

아래 코드에서 클래스들은 각각 클래스A: Engine / 클래스B: Car / 클래스C: Drive 를 의미한다.

  • 인스턴스 생성

    class Engine {
      start() {
        console.log('엔진에 시동을 걸었습니다.');
      }
    }
    
    class Car {
      private engine: Engine;
    
      constructor() {
        this.engine = new Engine(); // 클래스를 직접 생성
      }
    
      start() {
        this.engine.start();
      }
    }
    
    class Drive {
      start() {
        const car = new Car();
        car.start();
      }
    }

    ⇒ 위 코드에서 Car클래스는 Engine클래스를 가져다가 직접 생성해 사용한다. 그렇게 되면 Drive클래스에서는 별도의 처리 없이 Car를 생성해서 사용하면 된다.

  • 의존성 주입

    class Engine {
      start() {
        console.log('엔진에 시동을 걸었습니다.');
      }
    }
    
    class Car {
      private engine: Engine;
    
      constructor(engine: Engine) {
        this.engine = engine; // 클래스를 주입받음
      }
    
      start() {
        this.engine.start();
      }
    }
    
    class Drive {
      start() {
        const engine = new Engine();
        const car = new Car(engine); // 외부에서 클래스를 주입함
    
        car.start();
      }
    }

    ⇒ 위 코드에서 Car클래스는 아직 Engine클래스를 생성하지 않고 외부에서 주입받을 준비를 해놓았다. 그렇기에 Drive클래스에서 Car를 생성하기 위해선 Engine을 주입해 주어야 한다.

위 두 예시로 의존성 주입과 인스턴스 생성의 장단점을 알 수 있다.

의존성 주입은 외부에서 의존성을 주입받기 때문에 의존성이 변경(EngineMotor)되더라도 유연하게 대처가 가능하고 가짜 객체를 주입해볼 수 있어 테스트가 용이하다. 하지만 설정할 부분이 비교적 많아 코드의 복잡성이 증가하고 가독성을 해칠 우려가 있다.

인스턴스 생성은 미리 의존성을 생성해놓기 때문에 코드가 단순하고 직관적이지만, 의존성이 변경될 경우 직접 내부의 설정을 찾아 수정해주어야 하기 때문에 유지보수와 테스트 어려우며 이미 클래스 간에 결합도가 높아 또 다른 클래스에서의 재사용성이 떨어진다.

이러한 상반된 특징으로 인해 상황에 따라 적절한 방식을 선택해 사용하는 것이 중요하다.

가령, 단순한 계산 로직 혹은 해당 클래스에서 필수로 사용해야하는 경우에는 인스턴스를 직접 생성해 사용하고, 변동성이 있거나 큼지막한 모듈 간의 의존성을 필요로 할 경우에는 의존성 주입을 사용하는 것이 효율적일 수 있다.

Nest.js의 의존성 주입

Nest.js를 처음 접하면서 가장 어색했던 점은 다른 파일에 있는 메소드를 가져다 쓸 때, import 후 바로 메소드를 사용하는 것이 아니라 마치 가져온 기능을 장착하듯 constructor에 먼저 설정을 해주어야 한다는 점이었다. 그건 내가 가져온 기능이 Class였기 때문이다.

Nest.js의 기본 구조: Controller-Service

Nest.js에서 의존성 주입의 가장 대표적인 예시는 기본 구조인 Controller-Service 구조다.

먼저 Service를 주입 가능한 클래스로 등록하고, 이를 Controller에 주입함으로써 라우팅과 비즈니스 로직을 구분할 수 있다.

아래 코드로 방식을 간략히 살펴보자.

  • 의존성 주입이 가능한 클래스: Service

    import { Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {
    
      login(username: string, password: string) {
        const database = {
          username: 'apple',
          password: 'good taste'
        }
        return username === database.username && password === database.password 
          ? true : false
      }
    }

    의존성 주입에 사용되게 하려면 먼저 클래스에 @Injectable() 데코레이터를 지정해야 한다. 지정된 클래스는 DI 컨테이너에 등록되고, 주입 가능한 클래스가 된다.

  • 의존성을 주입하는 클래스: Controller

    import { Controller, Post, Body } from '@nestjs/common';
    import { UserService } from './user.service';
    
    @Controller('user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
      
      @Post('login')
      login(@Body('username') username: string, @Body('password') password: string) {
        return this.userService.login(username, password);
      }
    }

    클래스 생성자(constructor)를 통해 다른 클래스를 의존성으로 주입받아 기능을 사용할 수 있다. 주입된 클래스를 사용할 땐, this를 이용한다.

참고 자료

  • [1] 객체지향 프로그래밍이란?
  • [2] 개발자 면접 단골질문 객체지향 프로그래밍에 대해 알아보자 | oop 한번에 설명해드림
목차