본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

Dependency Injection: 객체가 의존성을 직접 만들지 않고 외부에서 주입받는 설계

의존성 주입(Dependency Injection, DI)은 객체가 필요로 하는 협력 객체를 스스로 생성하지 않고 외부에서 전달받는 설계 기법이다. 결합도를 낮추고 테스트 용이성을 높이며, 구성(composition) 지점을 한곳으로 모을 수 있다. 생성자 주입을 중심으로 원리와 TypeScript 예제, 컨테이너 사용 시 주의점을 정리한다.

By 송민성

1. 개념

의존성 주입(Dependency Injection, DI)은 어떤 객체가 사용하는 의존 대상(dependency)을 그 객체 내부에서 생성하지 않고, 외부에서 만들어 전달하는 설계 기법이다. 제어의 역전(Inversion of Control, IoC)을 구현하는 대표적인 방법이다.

  • 의존성(dependency): 객체가 동작하기 위해 사용하는 다른 객체나 모듈. 예: DB 클라이언트, 메일 발송기, 로거, 시계(Clock)
  • 주입(injection): 의존성을 생성자 인자, 세터(setter), 함수 인자 등으로 전달하는 행위
  • 조립 지점(composition root): 객체 그래프를 실제로 만들고 연결하는 단일 위치. 보통 애플리케이션 진입점이다.

DI는 프레임워크 기능이 아니라 설계 방식이다. 프레임워크 없이 인자 전달만으로도 구현할 수 있다.

2. 왜 사용하는가

의존성을 내부에서 new로 생성하면 다음 문제가 생긴다.

  1. 강한 결합(tight coupling): 구체 클래스에 직접 묶여서 구현을 바꾸려면 사용하는 쪽 코드를 수정해야 한다.
  2. 테스트 어려움: 실제 DB나 외부 API 호출을 대체(mock, fake)하기 어렵다.
  3. 숨은 의존성: 클래스 시그니처만 보고는 무엇이 필요한지 알 수 없다.
  4. 설정 분산: 환경별 구성(운영, 개발, 테스트)이 코드 곳곳에 흩어진다.

DI를 쓰면 사용하는 쪽은 추상(인터페이스)에만 의존하고, 구체 구현은 조립 지점에서 한 번에 결정한다. 이는 의존성 역전 원칙(Dependency Inversion Principle, DIP)과 잘 맞는다. 다만 DI와 DIP는 같은 개념이 아니다. DIP는 "상위 모듈이 하위 모듈의 구현이 아닌 추상에 의존하라"는 원칙이고, DI는 그 의존 대상을 전달하는 실행 기법이다.

3. 동작 원리

3.1 주입 방식

방식설명특징
생성자 주입(constructor injection)생성 시점에 인자로 전달필수 의존성 보장, 불변 유지에 유리. 가장 일반적
세터 주입(setter injection)생성 후 메서드/프로퍼티로 설정선택적 의존성에 사용. 미설정 상태가 생길 수 있음
인자 주입(parameter injection)메서드 호출 시 인자로 전달호출마다 달라지는 의존성에 적합

3.2 객체 그래프 조립

  1. 의존성 계층의 가장 아래(리프)부터 객체를 생성한다.
  2. 상위 객체의 생성자에 하위 객체를 전달한다.
  3. 최상위 객체를 애플리케이션 진입점에서 사용한다.

수동으로 조립하는 방식을 순수 DI(Pure DI)라 하고, 컨테이너(IoC container)가 타입이나 토큰을 보고 자동으로 조립하는 방식을 컨테이너 기반 DI라 한다. 컨테이너는 등록(register) → 해석(resolve) → 수명 관리(lifetime)의 흐름으로 동작한다.

4. 코드 예제

4.1 DI 없는 코드와 문제점

typescript
class SmtpMailer { send(to: string, body: string): void { console.log(`SMTP로 발송: ${to}`); } } class SignupService { private mailer = new SmtpMailer(); // 구체 클래스에 직접 결합 register(email: string): void { // ...사용자 저장 로직 this.mailer.send(email, "가입을 환영합니다"); } }

SignupService를 테스트하면 항상 SmtpMailer가 실행되고, 발송 수단을 바꾸려면 서비스 코드를 수정해야 한다.

4.2 생성자 주입 적용

typescript
interface Mailer { send(to: string, body: string): void; } class SmtpMailer implements Mailer { send(to: string, body: string): void { console.log(`SMTP로 발송: ${to} / ${body}`); } } class SignupService { constructor(private readonly mailer: Mailer) {} register(email: string): void { // ...사용자 저장 로직 this.mailer.send(email, "가입을 환영합니다"); } } // 조립 지점(composition root) const service = new SignupService(new SmtpMailer()); service.register("user@example.com");

4.3 테스트에서 대체 구현 주입

typescript
class FakeMailer implements Mailer { public sent: Array<{ to: string; body: string }> = []; send(to: string, body: string): void { this.sent.push({ to, body }); } } function testRegisterSendsWelcomeMail(): void { const fake = new FakeMailer(); const service = new SignupService(fake); service.register("a@test.com"); if (fake.sent.length !== 1 || fake.sent[0].to !== "a@test.com") { throw new Error("테스트 실패"); } console.log("테스트 통과"); } testRegisterSendsWelcomeMail();

네트워크나 외부 서버 없이 동작을 검증할 수 있다.

4.4 함수형 스타일의 주입

typescript
type Clock = () => Date; function isExpired(expiresAt: Date, now: Clock = () => new Date()): boolean { return now().getTime() > expiresAt.getTime(); } // 테스트: 시간을 고정해 결정적으로 검증 const fixed: Clock = () => new Date("2026-01-01T00:00:00Z"); console.log(isExpired(new Date("2025-12-31T00:00:00Z"), fixed)); // true

클래스가 없어도 함수 인자로 의존성을 전달하면 같은 효과를 얻는다.

4.5 간단한 컨테이너

typescript
type Factory<T> = (c: Container) => T; class Container { private factories = new Map<string, Factory<unknown>>(); private singletons = new Map<string, unknown>(); register<T>(token: string, factory: Factory<T>): void { this.factories.set(token, factory); } resolve<T>(token: string): T { if (this.singletons.has(token)) { return this.singletons.get(token) as T; } const factory = this.factories.get(token); if (!factory) throw new Error(`등록되지 않은 토큰: ${token}`); const instance = factory(this) as T; this.singletons.set(token, instance); return instance; } } const container = new Container(); container.register<Mailer>("mailer", () => new SmtpMailer()); container.register<SignupService>( "signup", (c) => new SignupService(c.resolve<Mailer>("mailer")) ); container.resolve<SignupService>("signup").register("b@example.com");

학습용 최소 구현이며, 순환 의존 탐지나 비동기 팩토리, 수명 구분(singleton/transient/scoped)은 포함하지 않았다.

5. 시간 복잡도 또는 성능 특성

DI 자체에는 알고리즘적 복잡도가 없다. 성능은 구현 방식에 따라 달라진다.

  • 순수 DI: 인자를 전달할 뿐이므로 일반 객체 생성과 비용 차이가 사실상 없다.
  • 컨테이너 기반 DI: 의존성 해석 시 등록 정보 조회와 객체 생성이 추가된다. 위 예제처럼 해시 맵을 쓰면 토큰 조회는 평균 O(1)이고, 그래프 전체를 해석하는 비용은 의존성 개수에 비례한다.
  • 수명 관리: 싱글턴으로 재사용하면 반복 생성 비용을 피할 수 있다. 요청마다 새로 만드는 수명(scoped, transient)은 요청 수만큼 생성 비용이 든다.
  • 리플렉션이나 데코레이터 메타데이터를 쓰는 컨테이너는 기동 시점 비용이 늘 수 있다. 실제 영향은 프레임워크와 규모에 따라 다르므로 측정해서 판단한다.

6. 실무 사용 사례

  • 테스트 대역(test double) 주입: 외부 API, 결제, 메일, 시간, 난수 생성기를 Fake나 Stub으로 교체
  • 환경별 구현 교체: 로컬은 인메모리 저장소, 운영은 PostgreSQL 저장소를 주입
  • 프레임워크 내장 DI: NestJS, Angular, Spring 등은 컨테이너 기반 DI를 기본 제공한다.
  • 횡단 관심사 적용: 로깅, 캐싱 데코레이터로 기존 구현을 감싸서 주입 (예: CachedUserRepository가 UserRepository를 감쌈)
  • Express 계열 서버: 별도 컨테이너 없이 createApp(deps) 형태의 팩토리 함수로 의존성을 받아 라우터를 구성
typescript
interface UserRepository { findName(id: string): Promise<string | null>; } class CachedUserRepository implements UserRepository { private cache = new Map<string, string | null>(); constructor(private readonly inner: UserRepository) {} async findName(id: string): Promise<string | null> { if (this.cache.has(id)) return this.cache.get(id)!; const value = await this.inner.findName(id); this.cache.set(id, value); return value; } }

7. 주의할 점

  1. 서비스 로케이터(Service Locator)와 혼동하지 말 것: 클래스 내부에서 container.resolve(...)를 호출하면 의존성이 다시 숨겨진다. 컨테이너 접근은 조립 지점에 한정한다.
  2. 생성자 인자 과다: 인자가 너무 많다면 클래스가 여러 책임을 가진다는 신호일 수 있다(단일 책임 원칙 위반 가능성). DI 문제가 아니라 설계 문제로 보고 분리를 검토한다.
  3. 불필요한 추상화: 구현이 하나뿐이고 교체나 대체 가능성이 없는 모든 곳에 인터페이스를 만들면 코드만 늘어난다. 경계(I/O, 외부 시스템, 시간 등)에 우선 적용한다.
  4. 순환 의존(circular dependency): A가 B를, B가 A를 요구하면 생성 자체가 불가능하다. 책임을 재분배하거나 공통 의존을 분리한다.
  5. 수명 불일치(captive dependency): 싱글턴이 요청 단위(scoped) 객체를 주입받아 보관하면 요청 간 상태가 섞인다. 수명이 긴 객체가 짧은 객체를 보유하지 않도록 한다.
  6. 런타임 오류 지연: 문자열 토큰 기반 컨테이너는 등록 누락을 실행 시점에야 발견한다. 기동 시 전체 그래프를 미리 해석해 검증하는 방식이 안전하다.
  7. 컨테이너가 필수는 아니다: 규모가 작다면 순수 DI로 충분하다.

8. 핵심 정리

  • DI는 객체가 필요한 협력 객체를 스스로 만들지 않고 외부에서 전달받는 설계 기법이며, IoC의 한 구현 방식이다.
  • 생성자 주입이 기본 선택지다. 필수 의존성을 명시하고 객체를 불변으로 유지하기 쉽다.
  • 효과는 낮은 결합도, 쉬운 테스트, 구현 교체의 용이성이다.
  • 객체 그래프는 조립 지점 한 곳에서 구성하고, 비즈니스 코드에서는 컨테이너를 직접 호출하지 않는다.
  • DI 자체의 성능 비용은 작다. 컨테이너를 쓸 때는 기동 시 해석 비용과 수명 설정을 점검한다.
  • 인터페이스는 교체 가능성이 있는 경계에 두고, 생성자 인자가 많아지면 책임 분리를 먼저 의심한다.