의존성 주입과 IoC 컨테이너
안녕하세요! 1장에서는 NestJS의 기본적인 구성 요소들과 첫 애플리케이션의 동작 방식을 살펴보았습니다. 이제 2장에서는 NestJS를 더욱 효과적으로 활용하기 위한 핵심 개념들을 심도 있게 탐구할 시간입니다. 그 첫 번째 주제는 바로 의존성 주입(Dependency Injection, DI) 과 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.
이 두 개념은 NestJS의 근간을 이루며, 견고하고 유지보수하기 쉬운 애플리케이션을 만드는 데 필수적인 역할을 합니다. 처음 들으면 어렵게 느껴질 수 있지만, 차근차근 살펴보면 그 중요성과 편리함을 분명히 이해하실 수 있을 겁니다.
의존성 주입이란 무엇인가?
'의존성'이라는 단어는 한 클래스가 다른 클래스의 기능이나 데이터를 사용해야 할 때 발생합니다. 예를 들어, 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()
를 통해 인스턴스를 생성해야 합니다.
해결책: 의존성 주입(Dependency Injection)
의존성 주입은 이러한 문제점을 해결하기 위한 디자인 패턴입니다. 이는 객체가 자신이 필요로 하는 의존성을 직접 생성하는 대신, 외부(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) 해 줍니다.
의존성 주입의 장점
- 느슨한 결합(Loose Coupling): 클래스들이 서로의 구체적인 구현에 묶이지 않고, 인터페이스(TypeScript 타입)에만 의존하게 되어 코드 변경에 유연하게 대응할 수 있습니다.
- 테스트 용이성:
UserController
를 테스트할 때, 실제UserService
대신 가짜(mock
또는stub
)UserService
를 주입하여 컨트롤러의 로직만을 순수하게 테스트할 수 있습니다. - 코드 재사용성 및 관리 용이성: 하나의
UserService
인스턴스를 여러 곳에 재사용할 수 있으며, 의존성 관리가 중앙집중적으로 이루어져 효율적입니다.
제어의 역전 컨테이너
이제 '의존성 주입'이라는 개념을 이해했다면, 이를 실제로 구현하고 관리하는 주체에 대해 알아볼 차례입니다. 그것이 바로 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.
일반적인 프로그래밍 흐름에서는 개발자가 객체를 직접 생성하고, 객체 간의 관계를 직접 제어합니다. 하지만 NestJS와 같은 프레임워크에서는 이 '제어'의 권한이 프레임워크에게 넘어갑니다. 이것을 **제어의 역전(IoC)**이라고 부릅니다.
IoC 컨테이너는 이 제어의 역전을 수행하는 핵심 메커니즘입니다. NestJS의 IoC 컨테이너는 다음과 같은 역할을 수행합니다.
- 프로바이더 관리:
@Injectable()
데코레이터가 붙은 클래스(프로바이더)들을 등록하고 관리합니다. - 의존성 해결: 어떤 클래스가 특정 프로바이더를 필요로 할 때, 컨테이너가 해당 프로바이더의 인스턴스를 찾아 주입해 줍니다. 이때 필요하다면 해당 프로바이더의 의존성까지 재귀적으로 해결합니다.
- 인스턴스 생명주기 관리: 프로바이더의 인스턴스를 언제 생성하고, 언제 파괴할지(싱글톤, 스코프 등) 결정합니다. NestJS에서 대부분의 프로바이더는 기본적으로 싱글톤(Singleton) 스코프를 가집니다. 이는 애플리케이션 전체에서 해당 프로바이더의 인스턴스가 단 하나만 생성되어 공유된다는 의미입니다.
간단히 비유하자면, IoC 컨테이너는 레고 블록 조립에 필요한 부품(프로바이더)들을 모두 가지고 있는 창고 관리자이자 조립 전문가입니다. 여러분이 어떤 완성된 레고 작품(클래스)을 만들겠다고 요청하면, 이 관리자가 필요한 부품들을 찾아서 적절히 조립하여 건네주는 방식입니다. 여러분은 부품을 일일이 찾아 조립할 필요 없이, 필요한 작품을 요청하기만 하면 되는 것이죠.
NestJS에서 @Module()
데코레이터의 providers
배열에 클래스를 등록하는 행위, 그리고 @Injectable()
데코레이터를 사용하는 행위 자체가 NestJS IoC 컨테이너에 "이 클래스는 내가 주입 가능한 프로바이더로 사용하고 싶어!"라고 알려주는 과정이라고 생각할 수 있습니다. 컨테이너는 이 정보를 바탕으로 내부적으로 의존성 그래프를 구성하고, 필요에 따라 인스턴스를 생성하고 주입하는 역할을 수행합니다.
의존성 주입과 IoC 컨테이너는 NestJS가 지향하는 모듈성, 테스트 용이성, 유지보수성을 달성하는 데 있어 가장 중요한 원리입니다. 이 개념들을 잘 이해하고 활용하면 여러분의 NestJS 애플리케이션은 훨씬 견고하고 유연해질 것입니다.
다음 절에서는 NestJS 애플리케이션에서 요청을 처리하고 응답을 보내는 과정에서 핵심적인 역할을 하는 요청 파이프라인에 대해 자세히 알아보겠습니다.