어느 시점에서든 모든 개발자는 같은 벽에 부딪힙니다. 코드의 일부를 테스트하려는데, 그 코드가 내부에서 몰래 new Database()를 호출합니다. 무슨 짓을 해도 실제 데이터베이스 연결이 실행됩니다. 이메일 제공업체를 교체하고 싶은데, 이메일 SDK가 세 레이어 깊숙이 묻혀 있습니다. HTTP 요청과 예약 작업 모두에서 같은 로직을 실행하고 싶은데, 프레임워크가 너무 단단히 고정되어 있어 사실상 불가능합니다.
이것들은 하나의 근본 원인의 증상입니다: 자신의 의존성을 직접 생성하는 코드. 그 해결책에는 공식적인 이름이 있습니다 — Dependency Injection — 하지만 그 밑에 있는 아이디어는 의외로 단순합니다. 코드가 자신의 협력자를 직접 찾지 않도록 하고, 외부에서 전달받는 것입니다. 그것이 전부입니다. 나머지 모든 것 — IoC 컨테이너, DI 프레임워크, 주입 토큰 — 은 그 하나의 아이디어 위에 구축된 기계장치입니다.
이 가이드는 처음부터 풀어나갑니다: 문제, 원칙, 패턴, 그리고 테스트에서의 실질적인 보상. TypeScript는 웹이나 서버 배경을 가진 엔지니어들에게 명확하게 읽히기 때문에 전체적으로 사용합니다.
하드와이어드 의존성: 코드가 스스로 new를 호출할 때
다음은 주문을 처리하는 서비스입니다. 처음 보면 완전히 합리적으로 보입니다:
// order-service.ts — "이전" 버전
class OrderService {
async placeOrder(cart: Cart): Promise<Order> {
const db = new PostgresClient(process.env.DATABASE_URL!) // 하드와이어드 DB
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // 하드와이어드 vendor
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!) // 하드와이어드 vendor
const order = Order.fromCart(cart)
await stripe.charges.create({ amount: order.total, source: cart.token })
await db.query('INSERT INTO orders …', [order])
await mailer.send({ to: cart.email, template: 'order-confirmed' })
return order
}
}
이것으로 작업하려는 순간 세 가지 문제가 명확해집니다:
- 격리된 상태로 테스트 불가능. 실제 자격증명, 실제 데이터베이스, 실제 Stripe 샌드박스 없이는
placeOrder를 유닛 테스트로 실행할 수 없습니다. 기본적으로 모든 테스트가 느리고 불안정한 통합 테스트입니다. - 특정 구현에 단단히 결합되어 있음. 비즈니스가 Stripe에서 Adyen으로 이동하면, 이 파일을 열고 수술해야 합니다. 스테이징 환경에 더미 메일러가 필요하다면, 깔끔하게 연결할 방법이 없습니다.
- 숨겨진 의존성. 클래스 시그니처를 읽는 독자는
placeOrder(cart)를 보고 그 메서드가 세 개의 외부 서비스를 필요로 한다는 것을 전혀 알 수 없습니다. 모든 줄을 읽기 전까지 의존성이 보이지 않습니다.
컴포지션 루트가 아닌 메서드나 생성자 본문 안에서 new SomeExternalThing()을 볼 때마다 — 그것은 하드와이어드 의존성입니다. 클래스는 자신의 일을 하는 것과 그 일을 하는 데 필요한 도구를 찾고 구축하는 것, 두 가지 일을 담당하고 있습니다. 그 두 가지는 서로 반대 방향으로 당깁니다.
Inversion of Control: 누가 생성을 담당하는가?
코드로 문제를 해결하기 전에, 관련된 원칙에 이름을 붙이는 것이 도움이 됩니다. Inversion of Control(IoC)는 코드가 자신의 의존성을 얻는 책임을 지지 말아야 한다는 아이디어입니다. 대신, 외부의 무언가 — 프레임워크, 테스트 하네스, 컴포지션 루트 — 가 것들을 생성하고 전달하는 일을 담당합니다.
소위 할리우드 원칙(Hollywood Principle)을 들어보셨을 겁니다: "전화하지 마세요, 저희가 연락드리겠습니다." 전통적인 명령형 코드에서는 비즈니스 로직이 의존성을 직접 호출합니다 — 데이터베이스를 직접 가져옵니다. IoC에서는 의존성이 여러분에게 옵니다. 그 관계의 제어가 역전됩니다: 더 이상 협력자를 언제 어떻게 가져올지 결정하지 않습니다; 외부 세계가 결정하고 전달합니다.
IoC는 넓은 원칙이지, 특정 기술이 아닙니다. 프레임워크들은 다양한 방식으로 그것을 구현합니다:
- Dependency Injection (DI) — 생성자 인수나 메서드 매개변수로 협력자를 전달합니다. 가장 일반적이고 가장 명시적인 형태.
- Service Locator — 코드가 런타임에 이름으로 의존성을 검색하는 전역 레지스트리. (나중에 왜 이것이 안티패턴인지 더 설명합니다.)
- Template Method — 기본 클래스가 서브클래스가 오버라이드하는 훅 메서드를 호출하므로, 기반이 흐름을 제어하고 서브클래스가 내용을 채웁니다.
- 이벤트 / 옵저버 — 컴포넌트가 이벤트를 발행하고; 프레임워크가 구독자에게 라우팅합니다. 양쪽 모두 상대방의 존재를 알지 못합니다.
이 중에서 Dependency Injection이 가장 투명하고 가장 테스트하기 쉽습니다. 또한 IoC 자체와 가장 자주 혼동되는데, 그래서 두 용어를 분리할 가치가 있습니다: IoC는 원칙이고, DI는 그 원칙의 하나의 구현입니다.
Dependency Injection: 의존성을 전달하기
DI의 메커니즘은 일단 보고 나면 당혹스러울 만큼 단순합니다. 클래스 내부에서 협력자를 구성하는 대신, 그것들을 매개변수로 선언하고 호출자가 제공하도록 합니다.
세 가지 방식이 있습니다 — 하지만 대부분의 상황에서는 하나가 명확한 승자입니다:
생성자 주입 (권장)
의존성이 생성자에서 선언됩니다. 이것이 표준 선택입니다: 모든 의존성이 클래스 시그니처에 보이고, 구성 시점에 필요하며, readonly로 만들 수 있어 나중에 아무것도 재할당하지 않습니다.
// order-service.ts — 생성자 주입을 사용한 "이후" 버전
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer { send(to: string, template: string): Promise<void> }
class OrderService {
constructor(
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
private readonly mailer: Mailer,
) {}
async placeOrder(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // 순수한 비즈니스 규칙
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.mailer.send(cart.email, 'order-confirmed')
return order
}
}
그 생성자 시그니처를 소리 내어 읽어보세요: "OrderService를 만들려면 결제 게이트웨이, 주문 레포지토리, 그리고 메일러가 필요합니다." 이것은 계약입니다. 단 하나의 메서드 본문도 읽지 않고 평범한 코드로 작성되어 보입니다.
세터 / 메서드 주입 (가끔 사용)
의존성이 생성 후 메서드를 통해 설정됩니다. 선택적 협력자에 유용하거나 두 단계로 객체를 구성하는 프레임워크(예: 일부 테스트 프레임워크, 일부 IoC 컨테이너)에 유용합니다. 드물게 사용하세요 — 객체가 부분적으로 구성된 상태로 존재할 수 있어 미묘한 버그의 원천이 됩니다.
// 세터 주입 — 구성 시점 배선이 불가능할 때만 사용
class ReportGenerator {
private formatter: ReportFormatter = new DefaultFormatter()
setFormatter(f: ReportFormatter) { this.formatter = f } // 선택적 오버라이드
generate(data: ReportData): string {
return this.formatter.format(data)
}
}
기본적으로 생성자 주입을 사용하세요. 클래스가 항상 필요로 하는 모든 의존성은 생성자에 속합니다. 의존성이 진정으로 선택적이거나 프레임워크가 강제할 때만 세터 주입을 사용하세요. 의존성 그래프에 대해 생각하지 않으려는 탈출구로 세터 주입을 절대 사용하지 마세요.
컴포지션 루트: 모든 것을 연결하는 한 곳
생성자를 통해 의존성을 주입하면 새로운 질문이 생깁니다: 실제로 누가 new를 호출하고 조각들을 연결하는가? 답은 컴포지션 루트(composition root)입니다: 애플리케이션의 진입점에 있는 단일 장소로 모든 실제 객체가 구성되고 서로에게 주입됩니다.
그것을 전체 코드베이스에서 new ConcreteImplementation()이 자유롭게 나타날 수 있는 유일한 장소로 생각하세요. 다른 모든 것은 인터페이스 측면에서 이야기합니다. 컴포지션 루트는 "추상 시스템"과 "실제 세계" 사이의 이음새입니다.
// composition-root.ts — 실제 것들이 연결되는 단 하나의 장소
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway } from './adapters/stripe-payment-gateway'
import { SendGridMailer } from './adapters/sendgrid-mailer'
import { OrderService } from './domain/order-service'
import { OrderController } from './http/order-controller'
export function buildApp() {
// 1. 먼저 리프 레벨 의존성 구성 (자신의 의존성 없음)
const db = new PostgresOrderRepository(process.env.DATABASE_URL!)
const stripe = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)
// 2. 서비스 구성, 의존성 주입
const orderSvc = new OrderService(stripe, db, mailer)
// 3. 딜리버리 레이어 구성, 서비스 주입
const orderCtrl = new OrderController(orderSvc)
return orderCtrl
}
컴포지션 루트가 거의 레시피처럼 읽히는 것을 주목하세요: "이 재료들을 만들고, 서비스로 결합하고, 서비스를 컨트롤러에 넣어라." 전체 코드베이스에서 모든 것이 어떻게 연결되는지 이해하기 위해 가는 한 곳이 있습니다. 이것은 새 엔지니어를 온보딩할 때나 재료를 교체해야 할 때 엄청난 자산입니다.
테스트에서는 테스트 특화 컴포지션 루트를 작성합니다 — 실제 어댑터 대신 가짜를 연결하는 작은 동등물입니다:
// 테스트 파일에서 — 같은 서비스, 가짜 협력자들
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders = new InMemoryOrderRepository()
const fakeMailer = new NoOpMailer()
const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// 밀리초 안에 실행; 네트워크도 데이터베이스도 없이
수동 DI vs DI 컨테이너 — 무엇이 달라지고 언제 중요한가
위의 모든 것은 수동 DI, 또는 Pure DI라고도 합니다. 배선 코드를 직접 작성합니다. 많은 프로젝트, 특히 소규모의 경우 이것이 올바른 선택입니다: 평범한 코드이고, 마법이 없으며, 즉시 디버깅 가능하고, 새 개발자가 하나의 파일을 읽으면 전체 배선을 이해할 수 있습니다.
애플리케이션이 성장하면서, 수동 접근법이 불편해지기 시작합니다. DI 컨테이너(때로는 IoC 컨테이너라고도 불림)는 의존성의 등록과 해결을 자동화하고, 그 위에 수명 관리를 추가합니다.
OrderService는 자신의 의존성을 구성하기 위해 바깥쪽으로 향합니다 — 각 화살표는 클래스 본문에 구워진 결합입니다. 오른쪽에서, 컴포지션 루트가 구체적인 객체를 구성하고 그것들을 안으로 주입합니다. 화살표가 역전됩니다. 서비스는 인터페이스에 대해서만 알고; 구체적인 타입은 가장자리의 세부 사항입니다.| 질문 | 수동 / Pure DI | DI 컨테이너 (NestJS, InversifyJS, tsyringe…) |
|---|---|---|
| 타입은 어떻게 등록되는가? | 컴포지션 루트에서 배선을 명시적으로 작성합니다. | 데코레이터, 토큰, 또는 메타데이터 — 컨테이너가 스캔하고 자동으로 등록합니다. |
| 인스턴스는 어떻게 해결되는가? | 올바른 순서로 직접 new를 호출합니다. |
컨테이너에 타입을 요청하면; 재귀적으로 전체 그래프를 구성합니다. |
| 수명 / 스코프 관리 | 직접 결정: 모듈당 하나의 인스턴스, 요청당, 또는 트랜지언트 — 참조를 직접 유지합니다. | 선언적: @Singleton(), @RequestScoped() 등. 컨테이너가 폐기를 처리합니다. |
| 디버깅 가능성 | 모든 것이 평범한 코드 — 어디든 브레이크포인트를 설정하고 콜 스택을 따라갑니다. | 해결은 컨테이너 내부에서 일어납니다; "왜 이 인스턴스를 받았는가?"는 컨테이너의 내부를 이해해야 합니다. |
| 보일러플레이트 | 대규모 그래프에는 높음 — 50개 클래스 앱을 수동으로 배선하는 것은 장황합니다. | 낮음 — 컨테이너가 의존성 트리를 걸어 모든 것을 자동으로 구성합니다. |
| 빌드 타임 안전성 | TypeScript가 컴파일 시점에 누락된 생성자 인수를 감지합니다. | 컨테이너에 따라 다름; 토큰 기반 시스템은 바인딩이 누락된 경우 런타임에 실패할 수 있습니다. |
| 최적 | 소~중형 앱, DI를 처음 접하는 팀, 제한된 스코프를 가진 마이크로서비스. | 많은 서비스를 가진 대규모 앱, 관례 우선을 원하는 팀, DI가 중심인 NestJS 같은 프레임워크. |
컨테이너가 가치를 발휘하는 임계값은 다양하지만, 대략적인 가이드는 다음과 같습니다: 컴포지션 루트 자체가 유지보수 부담이 되기 시작할 때 — 항상 스크롤하고, 새 바인딩 추가를 잊거나, 객체 수명과 씨름하고 있다면 — 이것이 컨테이너를 사용할 신호입니다. 그 전까지는 평범한 버전이 이해하고 디버깅하기 더 쉽습니다.
테스트의 보상: 가짜를 주입하고, 빠른 테스트를 얻어라
DI를 배울 가치가 있는 이유 — 어떤 아키텍처적 우아함보다 더 — 는 테스트 스위트에 미치는 영향입니다. 모든 의존성이 생성자를 통해 도착할 때, 테스트를 작성하는 것은 실제 어댑터 대신 가짜를 사용하는 작은 컴포지션 루트를 작성하는 것입니다. 데이터베이스도, 네트워크도, vendor 샌드박스도 없습니다. 테스트는 밀리초 안에 실행되며 스테이징 Stripe 계정에 요청 제한이 있어서 실패하는 일이 없습니다.
// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
receipts: Receipt[] = []
async charge(amount: Money, token: string): Promise<Receipt> {
const r = { id: 'fake-receipt', amount }
this.receipts.push(r)
return r
}
}
class InMemoryOrderRepository implements OrderRepository {
store: Order[] = []
async save(order: Order) { this.store.push(order) }
}
class SpyMailer implements Mailer {
sent: string[] = []
async send(to: string) { this.sent.push(to) }
}
// 테스트 자체는 순수하고, 빠르며, 결정론적입니다:
it('주문을 확인하고 고객에게 알립니다', async () => {
const payments = new InMemoryPaymentGateway()
const repo = new InMemoryOrderRepository()
const mailer = new SpyMailer()
const svc = new OrderService(payments, repo, mailer)
const order = await svc.placeOrder(testCart)
expect(payments.receipts).toHaveLength(1)
expect(repo.store).toContainEqual(order)
expect(mailer.sent).toContain(testCart.email)
})
이것은 Ports & Adapters의 핵심에 있는 같은 통찰입니다 — 인메모리 가짜는 테스트 시간 어댑터이고, 실제 Stripe나 Postgres 구현은 프로덕션 어댑터입니다. DI는 교체를 가능하게 하는 메커니즘이고; 포트 & 어댑터는 경계를 의도적이고 명시적으로 만드는 아키텍처 패턴입니다.
여기서 SOLID에 대해 잠깐 멈출 가치도 있습니다. "D"는 Dependency Inversion Principle을 의미합니다: 고수준 모듈이 저수준 모듈에 의존해서는 안 됩니다; 둘 다 추상화에 의존해야 합니다. 위의 인터페이스들(PaymentGateway, OrderRepository, Mailer)이 그 추상화입니다. OrderService는 고수준 모듈입니다. StripePaymentGateway와 PostgresOrderRepository는 저수준 모듈입니다. 둘 중 어느 것도 상대방에 대해 직접 알지 못합니다 — 그들은 인터페이스에서 만나고, 컴포지션 루트가 그들을 함께 연결합니다.
안티패턴: DI가 잘못되는 방법들
아이디어는 단순하지만, 그것을 약화시키는 몇 가지 잘 알려진 방법들이 있습니다.
Service Locator
Service Locator는 코드가 런타임에 이름으로 의존성을 검색하기 위해 호출하는 전역 레지스트리입니다:
// 안티패턴: Service Locator — DI처럼 보이지만 아닙니다
class OrderService {
async placeOrder(cart: Cart) {
const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
const repo = ServiceLocator.get<OrderRepository>('OrderRepository')
// … 계속
}
}
이것은 new Stripe() 인라인보다 깔끔해 보이지만, 같은 문제를 숨기고 있습니다. 의존성은 여전히 시그니처에서 보이지 않습니다. 테스트는 실행 전에 전역 레지스트리를 구성해야 합니다. 그리고 클래스는 어느 시점에서든 무엇이든 요청할 수 있습니다 — DI를 가치 있게 만드는 명시성을 잃었습니다.
과도한 주입 (10인수 생성자)
생성자에 7, 8, 10개의 매개변수가 쌓일 때, 이는 보통 DI가 잘못된 도구라는 신호가 아니라 클래스가 너무 많은 일을 하고 있다는 신호입니다. 해결책은 클래스를 각각 짧은 의존성 목록을 가진 더 작고 집중된 협력자들로 분해하는 것입니다. 생성자 길이는 유용한 건강 지표입니다.
숨겨진 전역 컨테이너
컴포지션 루트에서만이 아닌 서비스 내부에서 DI 컨테이너에 직접 접근하는 것 — 은 다른 이름의 Service Locator 패턴입니다. 규칙은 엄격합니다: 컴포지션 루트만 컨테이너와 이야기합니다. 서비스는 주입된 인터페이스하고만 이야기합니다.
추적할 수 없는 마법
일부 프레임워크는 데코레이터와 리플렉트 메타데이터를 통해 의존성을 배선하는데, 이는 무언가 잘못되었을 때 진정으로 따라가기 어렵습니다. 컨테이너 문서를 읽는 데 자신의 비즈니스 로직을 읽는 것보다 더 많은 시간을 보내고 있다면, 이것은 더 단순한 접근법을 선택하거나 — 최소한 주요 바인딩을 명시적으로 설명하는 잘 문서화된 컴포지션 루트를 추가해야 한다는 신호입니다.
DI 적정 규모: 솔로 프로젝트에서 엔터프라이즈까지
대부분의 아키텍처 아이디어와 마찬가지로, DI는 문제에 맞게 확장됩니다. 목표는 항상 같습니다 — 테스트 가능하고 변경 가능한 코드 — 하지만 그것을 달성하기 위한 올바른 기계장치는 팀과 코드베이스가 성장함에 따라 변합니다.
| 회사 단계 | 권장 접근법 | 일반적인 모습 |
|---|---|---|
| 솔로 / 초기 스타트업 | 단일 컴포지션 루트 파일을 사용한 수동 DI. | 하나의 buildApp() 함수; 아마 10~20줄. 빠르게 작성하고, 읽기 쉽습니다. |
| 소규모 팀 (5~20명 엔지니어) | 여전히 수동 DI, 가능하면 각각 자체 배선 함수를 가진 도메인 모듈로 분리. | buildOrderModule(), buildBillingModule() — 각각 자기 완결적이며, 시작 시 한 번 구성됩니다. |
| 성장하는 스케일업 (20~100명 엔지니어) | 경량 컨테이너가 보상을 주기 시작합니다. tsyringe, InversifyJS, 또는 프레임워크에 내장된 컨테이너를 고려하세요. | 클래스에 데코레이터, 모듈당 구성된 컨테이너; 웹 앱에는 요청 스코프 서비스. |
| 엔터프라이즈 / 대형 플랫폼 | 전체 DI 프레임워크가 거의 필수적입니다. NestJS, Spring Boot, 또는 ASP.NET Core가 이 규모에서 이 아이디어를 중심으로 구축되어 있습니다. | 관례 우선, 라이프사이클 훅, 모듈 import/export, 멀티 테넌트 스코핑. |
핀테크 스타트업은 세 개의 서비스를 연결하는 30줄짜리 컴포지션 루트로 시작할 수 있습니다. 2년 후, 같은 코드베이스에 60개의 서비스가 생깁니다: 수동 배선이 그 자체로 유지보수 작업이 되었고, 팀은 그래프를 자동으로 처리하기 위해 NestJS나 InversifyJS에 손을 뻗습니다. 전환은 간단합니다 왜냐하면 기본 패턴 — 생성자 주입, 인터페이스, 명확한 컴포지션 루트 — 이 동일하게 유지되기 때문입니다. 컨테이너는 기계적 보조자이지, 다른 아이디어가 아닙니다.
수동 DI와 컴포지션 루트로 시작하세요. 배선 파일이 부담이 되면 언제든 컨테이너를 도입할 수 있으며, 마이그레이션은 기계적입니다: 명시적인 new 호출을 등록으로 교체하는 것입니다. 도메인, 서비스, 또는 테스트에서는 아무것도 바꿀 필요가 없습니다 — 컴포지션 루트만 바꾸면 됩니다.
핵심 요약
- 경직되고 테스트 불가능한 코드의 근본 원인은 클래스가 자신의 의존성에 대해
new를 호출한다는 것입니다. 해결책은 그것들을 전달하는 것입니다. - Inversion of Control은 원칙입니다 (다른 누군가가 의존성을 제공하는 것을 담당합니다). Dependency Injection은 그것을 구현하는 가장 명시적이고 테스트하기 쉬운 방법입니다.
- 생성자 주입이 기본 선택입니다. 의존성이 시그니처에 보이고, 구성 시점에 필요하며, 테스트에서 쉽게 교체할 수 있습니다.
- 컴포지션 루트는 코드베이스에서 실제 객체들이 연결되는 단 하나의 장소입니다. 다른 모든 것은 인터페이스를 통해 이야기합니다.
- 수동 DI는 소규모 코드베이스에서 더 단순하고 디버깅하기 쉽습니다. DI 컨테이너는 컴포지션 루트 자체가 유지보수 부담이 될 때 가치를 발휘합니다.
- 테스트의 보상은 즉각적입니다: 테스트에 가짜를 주입하고, 네트워크와 데이터베이스를 건너뛰고, 수천 개의 케이스를 초 단위로 실행합니다.
- 이것은 또한 실제 Dependency Inversion Principle(SOLID의 "D")입니다: 고수준 모듈과 저수준 모듈이 모두 추상화에 의존하며, 서로 직접 의존하지 않습니다.
- Service Locator(숨겨진 전역 레지스트리), 과도한 주입(10인수 생성자), 컴포지션 루트 외부에서 컨테이너 접근을 피하세요.
의존성이 어떻게 흐르는지 확실히 이해했다면, 자연스럽게 다음 질문은 파일 자체를 어떻게 구성할까입니다. 이 시리즈의 다음 부분에서 정확히 그것을 살펴봅니다: 코드베이스 구조화: 기능 폴더 vs 레이어 — 그리고 왜 그 답이 코드의 모양만큼이나 팀의 모양에 달려 있는지.