Back to all posts

Dependency Injection & Inversion of Control

Dependency Injection & Inversion of Control을 공부하며 이해한 흐름을 정리한 글입니다.

2026년 05월 26일8

1. 개요

**Dependency Injection(DI, 의존성 주입)**은 어떤 클래스가 필요한 객체를 직접 생성하지 않고, 외부에서 전달받아 사용하는 설계 방식이다.

**Inversion of Control(IoC, 제어의 역전)**은 객체 생성, 의존성 연결, 실행 흐름 같은 제어권을 개발자가 작성한 클래스 내부가 아니라 외부 컨테이너나 프레임워크가 가져가는 설계 원칙이다.

둘의 관계를 짧게 정리하면 다음과 같다.

  • IoC는 더 큰 설계 원칙이다.
  • DI는 IoC를 구현하는 대표적인 방법이다.
  • 생성자 주입은 DI를 구현하는 가장 흔한 방식이다.

예를 들어 A 클래스가 B 클래스의 기능을 필요로 한다면, A 내부에서 직접 new B()를 만드는 대신 생성자나 메서드를 통해 B 인스턴스를 전달받는다.

class A {
  constructor(private readonly instance: B) {}
}

class B {}

이 구조에서 AB를 사용하지만, B를 직접 만들지는 않는다. 즉, 객체 생성 책임이 A 밖으로 이동한다.

기존 방식:
A가 직접 B를 생성한다.

IoC + DI 방식:
A는 B가 필요하다고 선언한다.
외부 컨테이너가 B를 생성해서 A에 넣어준다.

2. 의존성이란?

의존성은 어떤 코드가 동작하기 위해 필요한 다른 객체, 함수, 모듈, 설정값 등을 의미한다.

class UserService {
  private readonly userRepository = new UserRepository();

  findUser(id: number) {
    return this.userRepository.findById(id);
  }
}

위 코드에서 UserServiceUserRepository에 의존한다. 문제는 UserServiceUserRepository를 직접 생성하고 있다는 점이다.

이렇게 작성하면 두 클래스가 강하게 묶인다.

  • UserRepository 구현이 바뀌면 UserService도 영향을 받기 쉽다.
  • 테스트할 때 가짜 Repository(Mock)를 넣기 어렵다.
  • 데이터 저장소를 DB, 메모리, 외부 API 등으로 교체하기 어렵다.

3. DI를 적용한 구조

의존성 주입을 적용하면 필요한 객체를 외부에서 전달받는다.

class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  findUser(id: number) {
    return this.userRepository.findById(id);
  }
}

이제 UserServiceUserRepository를 직접 만들지 않는다. 대신 생성자를 통해 이미 만들어진 객체를 전달받는다.

const userRepository = new UserRepository();
const userService = new UserService(userRepository);

핵심은 다음과 같다.

  • 사용자는 UserService
  • 의존성은 UserRepository
  • 주입 방식은 constructor(...)
  • 생성 책임은 외부 코드가 담당

4. DI가 필요한 이유

4.1. 결합도 감소

DI를 사용하면 클래스가 구체적인 생성 방식에 덜 의존한다. UserServiceUserRepository가 어떻게 생성되는지 알 필요 없이, 전달받은 객체를 사용하기만 하면 된다.

4.2. 테스트 용이성

테스트에서는 실제 DB에 접근하는 Repository 대신 가짜 객체를 넣을 수 있다.

const mockRepository = {
  findById: (id: number) => ({ id, name: 'test user' }),
};

const userService = new UserService(mockRepository as UserRepository);

이렇게 하면 DB 없이도 UserService의 비즈니스 로직만 테스트할 수 있다.

4.3. 구현 교체가 쉬움

Repository 구현을 바꾸더라도 UserService의 코드를 크게 수정하지 않아도 된다.

class MemoryUserRepository {}
class DatabaseUserRepository {}
class ApiUserRepository {}

서비스는 Repository의 구체 구현보다 "사용할 수 있는 기능"에 의존하게 만들 수 있다.

5. Inversion of Control

DI를 이해할 때 자주 함께 나오는 개념이 **IoC(Inversion of Control, 제어의 역전)**이다.

일반적인 코드에서는 객체가 필요한 의존성을 직접 만든다.

class A {
  private readonly b = new B();
}

DI 구조에서는 객체 생성과 연결을 외부가 담당한다.

class A {
  constructor(private readonly b: B) {}
}

즉, 객체가 직접 제어하던 생성 흐름이 외부 컨테이너나 조립 코드로 넘어간다. 이 흐름의 역전을 IoC라고 한다.

5.1. 무엇이 역전되는가?

역전되는 것은 "의존 객체를 누가 만들고 연결하는가"에 대한 제어권이다.

// 제어권이 A 내부에 있음
class A {
  private readonly b = new B();
}

위 코드에서는 AB를 직접 생성한다. 따라서 AB의 생성 방식까지 알고 있어야 한다.

// 제어권이 A 밖으로 이동함
class A {
  constructor(private readonly b: B) {}
}

이 코드에서는 AB를 직접 만들지 않는다. A는 단지 "나는 B가 필요하다"고 생성자에 선언할 뿐이다. 실제 B 생성과 전달은 외부 코드나 프레임워크가 담당한다.

5.2. 스크린샷 구조로 이해하기

스크린샷의 구조는 다음처럼 볼 수 있다.

class B {}

class A {
  constructor(private readonly instance: B) {}
}

class C {
  constructor(private readonly instance: B) {}
}

AC는 둘 다 B를 필요로 한다. 하지만 AC 내부에서 new B()를 호출하지 않는다.

외부 조립 코드는 다음과 같은 역할을 한다.

const b = new B();

const a = new A(b);
const c = new C(b);

이 구조에서 핵심은 다음과 같다.

  • B는 공통 의존성이다.
  • ACB를 사용하지만 생성하지 않는다.
  • B를 만들고 연결하는 제어권은 A, C 밖에 있다.
  • 이것이 IoC이고, 생성자로 B를 전달하는 방식이 DI이다.

5.3. 프레임워크에서의 IoC

프레임워크를 사용하면 개발자가 직접 객체를 조립하지 않아도 된다. NestJS 같은 프레임워크는 IoC 컨테이너를 가지고 있고, 이 컨테이너가 객체 생성과 연결을 대신 처리한다.

개발자는 다음처럼 선언만 한다.

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}
}

NestJS 컨테이너는 다음 일을 대신한다.

  • PostsControllerPostsService를 필요로 한다는 것을 파악한다.
  • PostsService 인스턴스를 생성하거나 기존 인스턴스를 찾는다.
  • PostsController를 생성할 때 PostsService를 생성자에 넣어준다.

즉, 개발자는 객체 생성 순서와 연결 코드를 직접 작성하지 않고, 프레임워크가 전체 흐름을 제어한다.

6. DI와 IoC의 관계

IoC와 DI는 같은 말처럼 쓰일 때가 있지만, 정확히는 범위가 다르다.

구분 의미 예시
IoC 제어권이 객체 내부에서 외부로 이동하는 설계 원칙 프레임워크가 객체 생성과 실행 흐름을 관리
DI 필요한 의존성을 외부에서 넣어주는 구현 방식 생성자로 PostsService를 주입
Constructor Injection 생성자를 통해 의존성을 주입하는 DI 방식 constructor(private service: Service)

정리하면, DI는 IoC를 실현하는 구체적인 기술이다.

IoC
└── DI
    └── Constructor Injection

DI를 사용하면 "내가 필요한 객체를 내가 직접 만든다"에서 "나는 필요한 객체를 선언하고, 외부가 넣어준다"로 사고방식이 바뀐다.

7. NestJS에서의 DI와 IoC

NestJS는 DI를 프레임워크 핵심 구조로 사용한다. @Injectable()이 붙은 클래스는 NestJS IoC 컨테이너가 관리할 수 있는 Provider가 된다.

7.1. Service 등록

import { Injectable } from '@nestjs/common';

@Injectable()
export class PostsService {
  getPosts() {
    return [];
  }
}

7.2. Module에 Provider 등록

import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';

@Module({
  controllers: [PostsController],
  providers: [PostsService],
})
export class PostsModule {}

providers에 등록된 PostsService는 NestJS 컨테이너가 생성하고 관리한다.

7.3. Controller에서 주입받기

import { Controller, Get } from '@nestjs/common';
import { PostsService } from './posts.service';

@Controller('posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Get()
  getPosts() {
    return this.postsService.getPosts();
  }
}

PostsControllernew PostsService()를 호출하지 않는다. 생성자의 타입 정보를 보고 NestJS가 PostsService 인스턴스를 자동으로 넣어준다.

7.4. NestJS 흐름

NestJS에서 객체가 연결되는 흐름은 다음과 같다.

AppModule
  -> providers에 PostsService 등록
  -> controllers에 PostsController 등록
  -> IoC 컨테이너가 의존성 관계 분석
  -> PostsService 생성
  -> PostsController 생성 시 PostsService 주입

개발자는 new PostsService()를 직접 작성하지 않는다. 대신 providers에 등록하고 생성자에 타입을 적는다. 이후 연결은 NestJS 컨테이너가 처리한다.

8. 생성자 주입 코드 해석

NestJS 코드에서 자주 보는 아래 문법은 DI와 TypeScript 문법이 함께 사용된 것이다.

constructor(private readonly postsService: PostsService) {}

이 코드는 다음 코드와 거의 같은 의미이다.

export class PostsController {
  private readonly postsService: PostsService;

  constructor(postsService: PostsService) {
    this.postsService = postsService;
  }
}

정리하면 다음과 같다.

  • PostsService: 주입받을 의존성의 타입
  • private: 클래스 내부에서만 사용
  • readonly: 생성 이후 다른 값으로 재할당하지 않음
  • constructor(...): 객체가 생성될 때 의존성을 전달받는 통로

9. DI의 주요 방식

9.1. 생성자 주입

가장 일반적이고 권장되는 방식이다.

class A {
  constructor(private readonly b: B) {}
}

장점:

  • 필요한 의존성이 명확히 드러난다.
  • 객체 생성 시점에 의존성이 준비된다.
  • 테스트 코드에서 Mock을 넣기 쉽다.

9.2. Setter 주입

객체를 만든 뒤 메서드로 의존성을 넣는다.

class A {
  private b?: B;

  setB(b: B) {
    this.b = b;
  }
}

선택적인 의존성에는 사용할 수 있지만, 필수 의존성에는 생성자 주입이 더 안전하다.

9.3. Property 주입

속성에 직접 의존성을 넣는 방식이다.

class A {
  private b!: B;
}

코드는 짧아질 수 있지만, 어떤 의존성이 필요한지 생성자만 보고 알기 어렵다. NestJS에서도 특별한 이유가 없다면 생성자 주입을 우선 사용한다.

10. DI를 사용하지 않는 코드와 비교

DI를 사용하지 않는 코드

class PostsController {
  private readonly postsService = new PostsService();

  getPosts() {
    return this.postsService.getPosts();
  }
}

문제점:

  • Controller가 Service 생성 방식까지 알고 있다.
  • Service에 필요한 의존성이 늘어나면 Controller도 수정될 수 있다.
  • 테스트에서 Mock Service로 교체하기 어렵다.

DI를 사용하는 코드

class PostsController {
  constructor(private readonly postsService: PostsService) {}

  getPosts() {
    return this.postsService.getPosts();
  }
}

장점:

  • Controller는 Service를 사용하기만 한다.
  • 생성과 연결은 외부 컨테이너가 담당한다.
  • 테스트와 유지보수가 쉬워진다.

11. 주의할 점

DI를 사용한다고 무조건 좋은 구조가 되는 것은 아니다.

  • 의존성이 너무 많으면 클래스의 책임이 커졌다는 신호일 수 있다.
  • 순환 의존성(A -> B -> A)이 생기면 설계를 다시 점검해야 한다.
  • 모든 것을 추상화하려고 하면 코드가 오히려 복잡해질 수 있다.
  • 단순한 값이나 작은 유틸 함수까지 무리하게 주입할 필요는 없다.

DI는 "객체를 외부에서 넣는다"가 핵심이지만, 목적은 더 좋은 분리와 테스트 가능한 구조를 만드는 것이다.

12. 한 줄 정리

Dependency Injection은 클래스가 필요한 의존성을 직접 생성하지 않고 외부에서 주입받게 해서, 결합도를 낮추고 테스트와 유지보수를 쉽게 만드는 설계 방식이다.

Inversion of Control은 객체 생성과 연결의 제어권을 클래스 내부가 아니라 외부 컨테이너나 프레임워크로 넘기는 설계 원칙이다.

직접 생성:
A가 B를 직접 만든다.

의존성 주입:
A는 B를 필요로 한다고 선언하고, 외부에서 B를 넣어준다.

제어의 역전:
A가 B를 만드는 흐름에서, 외부 컨테이너가 B를 만들어 A에 넣어주는 흐름으로 바뀐다.

NestJS에서는 이 과정을 IoC 컨테이너가 자동으로 처리해 주기 때문에, Controller와 Service를 깔끔하게 분리할 수 있다.