Skip to content
Code Card

Composition Root와 실패 경계

Composition Root를 둔 의존성 주입은 객체가 자기 의존성을 직접 만들지 않고, 애플리케이션 시작 지점에서 의존성 그래프를 조립한 뒤 필요한 객체에 전달하는 패턴이다. 서비스는 "무엇이 필요한지"를 생성자나 함수 인자로 드러내고, "무엇을 실제로 붙일지"는 바깥 조립 계층이 결정한다.

연습 메모 · 33 min read · Medium

Composition Root를 둔 의존성 주입은 객체가 자기 의존성을 직접 만들지 않고, 애플리케이션 시작 지점에서 의존성 그래프를 조립한 뒤 필요한 객체에 전달하는 패턴이다.

핵심은 new EmailClient(...), new Clock(...), new Logger(...) 같은 생성 책임을 비즈니스 서비스 내부에 두지 않는 것이다.

서비스는 "무엇이 필요한지"를 생성자나 함수 인자로 드러내고, "무엇을 실제로 붙일지"는 바깥 조립 계층이 결정한다.

이 패턴의 의도는 생성 정책과 실행 정책을 분리하는 것이다.
결제 영수증 발송 서비스가 SMTP 클라이언트, 템플릿 렌더러, 현재 시간, 저장소를 내부에서 직접 만들면 테스트가 어렵고, 운영 환경 교체도 어렵다.
반대로 서비스가 EmailSender, ReceiptRenderer, Clock 같은 추상 의존성만 받으면, 운영에서는 실제 구현을 넣고 테스트에서는 fake 구현을 넣을 수 있다.

Martin Fowler는 Dependency Injection을 컴포넌트가 직접 의존성을 찾거나 만들지 않고 외부에서 의존성을 제공받는 방식으로 설명하며, Service Locator와 대비한다 1

Fowler의 핵심 구분은 "의존성을 어디에서 찾는가"다. DI는 의존성을 외부에서 전달받고, Service Locator는 객체가 전역 registry나 locator를 통해 직접 찾아간다. 한편 "Composition Root"라는 용어 자체는 Fowler가 아니라 Mark Seemann이 정립한 것으로, 객체 그래프를 조립하는 시작 경계를 가리킨다.2

다만 "전역에 무조건 하나"는 아니다. 웹 서버, CLI, worker, 마이그레이션 도구, 테스트 호스트는 각각 자신의 Composition Root를 가질 수 있다. 정확히는 실행 진입점(entry point)마다 하나이며, 그 실행 단위 안에서는 조립 책임이 흩어지지 않아야 한다는 원칙이다.

.NET 공식 문서도 dependency injection에서 서비스 lifetime을 transient, scoped, singleton으로 등록할 수 있으며, 각 서비스에 적절한 lifetime을 선택해야 한다고 설명한다3. 또한 .NET DI는 생성자 주입을 통해 public constructor의 의존성을 해석한다. 즉 DI는 단순히 "인터페이스를 많이 쓰자"가 아니라, 의존성 생성 위치, lifetime, 테스트 대체 가능성, 실행 경계를 함께 설계하는 패턴이다.

다만 service lifetime을 선택할 수 있다는 것은 편의인 동시에 위험이다. singleton, scoped, transient를 고르는 일은 단순 설정처럼 보이지만, 실제로는 객체의 수명과 상태 공유 범위를 결정하는 아키텍처 판단이다.

특히 잘못된 lifetime 설정은 Captive Dependency를 만들 수 있다. 예를 들어 singleton 서비스가 scoped 의존성을 붙잡으면, 요청 단위로 분리되어야 할 상태가 더 긴 수명의 객체 안에 갇힌다. 이러면 사용자별 상태가 섞이거나, 이미 폐기되어야 할 리소스가 오래 살아남거나, 테스트에서는 보이지 않던 운영 장애가 생긴다.

즉 DI container가 lifetime을 선택하게 해준다는 말은, 그만큼 잘못 선택할 지점이 늘어난다는 점이다. 보통 이것은 문법 문제가 아니라 경험 문제다.

개인메모: 이 짓거리를 하는 이유는 결국 비즈니스 규칙만큼은 멀쩡해야 하기 때문이다. SMTP가 죽든, 시간이 바뀌든, 설정 파일이 꼬이든, 테스트에서 fake를 넣든, 핵심 도메인 로직은 자기 할 일을 일관되게 해야 한다.설계의 중심을 비즈니스 로직에 두겠다고 말하는 순간 별의별 테크닉이 따라온다.

DI, Composition Root, Result, 생성 경계, lifetime 정책, Service Locator 금지는 전부 그 부산물이다.

객체를 예쁘게 만들려는 게 아니라, 비즈니스 로직을 외부 세계의 진흙탕에서 떼어놓으려는 것이다. 패턴 암기 카드를 작성하면서 할 말은 아니지만, 비즈니스 로직을 무결하게 만들기 위한 테크닉들로 파생된 것뿐이다.

핵심 암기 공식:

Text
Dependency Injection = 필요한 것을 내부에서 만들지 않고 외부에서 받는다
Composition Root = 애플리케이션 시작 지점에서 객체 그래프를 조립한다
Constructor Boundary = 필수 의존성은 생성자에서 드러낸다
Lifetime Policy = singleton / scoped / transient 수명을 조립 계층에서 결정한다
No Service Locator = 실행 객체가 전역 registry를 직접 조회하지 않는다
Predictable Failure = 검증 같은 예측 가능한 실패는 예외가 아니라 Result로 표현하는 편이 명확하다.

1. 문제 상황

주문 결제가 완료되면 고객에게 영수증 이메일을 보내는 서비스를 만든다고 하자.

필요한 책임은 다음이다.

Text
- 주문 정보를 영수증 HTML로 렌더링한다.
- 이메일을 발송한다.
- 현재 시각을 영수증에 넣는다.
- 성공 결과를 반환한다.

나쁜 설계에서는 서비스 내부에서 직접 의존성을 만든다.
나랑 같이 작업하던 선배는 한국 프로그래밍식 의존성이라고 말했는데,(모든 안 좋은 관례에 대해서 한국 프로그래밍 방식이라고 붙이는 걸 좋아하셨던 분이다.)

보통은 납품형 의존성이라고 한다. 적당히 작은 프로그램에 많이 들어가는 형태다.
아래가 그 예시다.

Text
ReceiptEmailService
-> new SystemClock()
-> new HtmlReceiptRenderer()
-> new SmtpEmailSender()

이렇게 되면 테스트에서 SMTP 발송을 막기 어렵고, 현재 시각을 고정하기 어렵고, 렌더러 교체도 어렵다. 이 카드의 도메인은 하나다. 영수증 이메일 발송 서비스를 Composition Root에서 조립한다.


2. 핵심 표현

공통 예제는 ReceiptEmailService다.

Text
ReceiptEmailService = orchestration
ReceiptRenderer     = 주문 -> HTML 변환
EmailSender         = 이메일 I/O
Clock               = 현재 시각 의존성
Composition Root    = 실제 구현 객체 조립

검증 정책 하나를 카드 전체에 통일한다.
입력 검증 같은 예측 가능한 실패는 예외가 아니라 Result로 반환하고, 그 검증은 서비스 실행부가 아니라 OrderReceipt를 만드는 생성 경계(팩토리)에 둔다.
그 결과 ReceiptEmailService는 이미 유효한 OrderReceipt만 받아 실행에만 집중한다. 의존성 배선 오류(널 의존성)처럼 복구 불가능한 프로그래밍 실수만 생성자에서 예외로 막는다.


C#

C#
using System;
using System.Threading;
using System.Threading.Tasks;

public enum ReceiptError
{
    EmptyOrderId,
    EmptyCustomerEmail,
    NegativeTotal,
}

public readonly struct Result<TValue, TError>
{
    private readonly TValue value;
    private readonly TError error;
    private readonly bool isOk;
    private readonly bool isInitialized;

    private Result(
        bool isOk,
        TValue value,
        TError error)
    {
        this.isOk = isOk;
        this.value = value;
        this.error = error;
        this.isInitialized = true;
    }

    public bool IsOk
    {
        get
        {
            EnsureInitialized();
            return this.isOk;
        }
    }

    public static Result<TValue, TError> Ok(TValue value)
    {
        return new Result<TValue, TError>(true, value, default!);
    }

    public static Result<TValue, TError> Fail(TError error)
    {
        return new Result<TValue, TError>(false, default!, error);
    }

    public bool TryGetValue(out TValue value)
    {
        EnsureInitialized();
        value = this.value;
        return this.isOk;
    }

    public TResult Match<TResult>(
        Func<TValue, TResult> onOk,
        Func<TError, TResult> onFail)
    {
        EnsureInitialized();

        if (this.isOk)
        {
            return onOk(this.value);
        }

        return onFail(this.error);
    }

    private void EnsureInitialized()
    {
        if (!this.isInitialized)
        {
            throw new InvalidOperationException(
                "Result가 초기화되지 않았습니다. Ok 또는 Fail로 생성해야 합니다.");
        }
    }
}

public sealed record OrderReceipt
{
    public string OrderId { get; }

    public string CustomerEmail { get; }

    public long TotalCents { get; }

    private OrderReceipt(
        string orderId,
        string customerEmail,
        long totalCents)
    {
        this.OrderId = orderId;
        this.CustomerEmail = customerEmail;
        this.TotalCents = totalCents;
    }

    public static Result<OrderReceipt, ReceiptError> TryCreate(
        string orderId,
        string customerEmail,
        long totalCents)
    {
        if (string.IsNullOrWhiteSpace(orderId))
        {
            return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.EmptyOrderId);
        }

        if (string.IsNullOrWhiteSpace(customerEmail))
        {
            return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.EmptyCustomerEmail);
        }

        if (totalCents < 0)
        {
            return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.NegativeTotal);
        }

        OrderReceipt receipt = new OrderReceipt(
            orderId,
            customerEmail,
            totalCents);

        return Result<OrderReceipt, ReceiptError>.Ok(receipt);
    }
}

public sealed record SendReceiptResult(
    string OrderId,
    string CustomerEmail,
    DateTimeOffset SentAt);

public interface ReceiptRenderer
{
    string RenderHtml(
        OrderReceipt receipt,
        DateTimeOffset issuedAt);
}

public interface EmailSender
{
    ValueTask SendAsync(
        string toEmail,
        string subject,
        string htmlBody,
        CancellationToken cancellationToken);
}

public interface Clock
{
    DateTimeOffset GetUtcNow();
}

public sealed class ReceiptEmailService
{
    private readonly ReceiptRenderer renderer;
    private readonly EmailSender emailSender;
    private readonly Clock clock;

    public ReceiptEmailService(
        ReceiptRenderer renderer,
        EmailSender emailSender,
        Clock clock)
    {
        this.renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
        this.emailSender = emailSender ?? throw new ArgumentNullException(nameof(emailSender));
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
    }

    public async ValueTask<SendReceiptResult> SendAsync(
        OrderReceipt receipt,
        CancellationToken cancellationToken)
    {
        DateTimeOffset issuedAt = this.clock.GetUtcNow();
        string htmlBody = this.renderer.RenderHtml(receipt, issuedAt);

        await this.emailSender.SendAsync(
            receipt.CustomerEmail,
            $"Receipt for order {receipt.OrderId}",
            htmlBody,
            cancellationToken);

        return new SendReceiptResult(
            receipt.OrderId,
            receipt.CustomerEmail,
            issuedAt);
    }
}

public sealed class SystemClock : Clock
{
    public DateTimeOffset GetUtcNow()
    {
        return DateTimeOffset.UtcNow;
    }
}

public sealed class HtmlReceiptRenderer : ReceiptRenderer
{
    public string RenderHtml(
        OrderReceipt receipt,
        DateTimeOffset issuedAt)
    {
        return $"<h1>Order {receipt.OrderId}</h1><p>Total: {receipt.TotalCents}</p><p>{issuedAt:O}</p>";
    }
}

(예제는 단순화를 위해 문자열 보간으로 HTML을 만들지만, 실무에서는 사용자 입력이 들어가는 값은 반드시 HTML escape하거나 템플릿 엔진의 escape 기능을 사용해야 한다.)

OrderReceipt는 private 생성자와 TryCreate 팩토리만 노출하므로, 유효하지 않은 상태를 애초에 만들 수 없다.
검증 실패는 Result<OrderReceipt, ReceiptError>로 반환되어 호출 경계에서 처리된다. 반면 널 의존성은 의존성 조립 오류이므로 생성자에서 ArgumentNullException으로 막는다. 이 둘은 성격이 다른 실패다.

한 가지 함정으로는, Resultreadonly structdefault(Result<...>)로도 초기화될 수 있는데, 이 상태는 Ok/Fail을 거치지 않은 값 타입 기본 초기화이며, Result 생성 규약을 위반한 상태다.(struct는 매개변수 없는 기본 생성자를 막을 수 없다).
이때 내부 isOkfalse, error는 타입 기본값이 되어, 그대로 Match가 호출되면 존재하지 않던 spurious 실패를 조용히 반환하거나(참조형 error면 NullReferenceException) 잘못된 분기를 탄다.
그래서 isInitialized 플래그로 이를 감지해, 초기화되지 않은 Result 사용 시 InvalidOperationException으로 즉시 드러낸다.
이건 복구 불가능한 프로그래밍 오류이므로 예외를 드러내야한다.
(이런 사소한 테크닉과 경험이 연봉을 올리는 좋은 테크닉이다)

그리고 C#에서는 SMTP 실패 시 시스템 예외(Exception)가 상위 호출부로 그대로 던져지며(Bubbling), 이를 잡지 않고 전파하는 것이 올바른 책임 위임이다

실무나 현장에서는 Result 도 잘 쓰지 않지만, 그래도 쓴다면, 이렇게 직접 Result를 만들기보다 OneOf, FluentResults 같은 라이브러리나 팀 표준 Result 타입을 써도 된다.
여기서는 경계를 설명하기 위해 최소 구현을 둔다.


TypeScript

TypeScript
type ReceiptError =
  | "EmptyOrderId"
  | "EmptyCustomerEmail"
  | "InvalidTotal";

type Result<TValue, TError> =
  | Readonly<{ ok: true; value: TValue }>
  | Readonly<{ ok: false; error: TError }>;

type OrderReceipt = Readonly<{
  orderId: string;
  customerEmail: string;
  totalCents: number;
}>;

type SendReceiptResult = Readonly<{
  orderId: string;
  customerEmail: string;
  sentAt: Date;
}>;

type ReceiptRenderer = Readonly<{
  renderHtml: (
    receipt: OrderReceipt,
    issuedAt: Date,
  ) => string;
}>;

type EmailSender = Readonly<{
  sendAsync: (
    toEmail: string,
    subject: string,
    htmlBody: string,
    signal?: AbortSignal,
  ) => Promise<void>;
}>;

type Clock = Readonly<{
  getUtcNow: () => Date;
}>;

export function tryCreateOrderReceipt(
  orderId: string,
  customerEmail: string,
  totalCents: number,
): Result<OrderReceipt, ReceiptError>
{
  if (orderId.trim().length === 0) {
    return { ok: false, error: "EmptyOrderId" };
  }

  if (customerEmail.trim().length === 0) {
    return { ok: false, error: "EmptyCustomerEmail" };
  }

  if (!Number.isInteger(totalCents) || totalCents < 0) {
    return { ok: false, error: "InvalidTotal" };
  }

  return {
    ok: true,
    value: { orderId, customerEmail, totalCents },
  };
}

export class ReceiptEmailService
{
  readonly #renderer: ReceiptRenderer;
  readonly #emailSender: EmailSender;
  readonly #clock: Clock;

  public constructor(
    renderer: ReceiptRenderer,
    emailSender: EmailSender,
    clock: Clock,
  )
  {
    if (renderer === undefined || renderer === null) {
      throw new TypeError("renderer is required.");
    }

    if (emailSender === undefined || emailSender === null) {
      throw new TypeError("emailSender is required.");
    }

    if (clock === undefined || clock === null) {
      throw new TypeError("clock is required.");
    }

    this.#renderer = renderer;
    this.#emailSender = emailSender;
    this.#clock = clock;
  }

  public async sendAsync(
    receipt: OrderReceipt,
    signal?: AbortSignal,
  ): Promise<SendReceiptResult>
  {
    const issuedAt = this.#clock.getUtcNow();
    const htmlBody = this.#renderer.renderHtml(receipt, issuedAt);

    await this.#emailSender.sendAsync(
      receipt.customerEmail,
      `Receipt for order ${receipt.orderId}`,
      htmlBody,
      signal,
    );

    return {
      orderId: receipt.orderId,
      customerEmail: receipt.customerEmail,
      sentAt: issuedAt,
    };
  }
}

export const systemClock: Clock = {
  getUtcNow: () => new Date(),
};

export const htmlReceiptRenderer: ReceiptRenderer = {
  renderHtml: (receipt, issuedAt) => {
    return `<h1>Order ${receipt.orderId}</h1><p>Total: ${receipt.totalCents}</p><p>${issuedAt.toISOString()}</p>`;
  },
};

TypeScript는 구조적 타입이라 OrderReceipt 리터럴을 직접 만들 수도 있지만, 유효성을 보장하려면 항상 tryCreateOrderReceipt를 거치도록 팀 규약으로 정한다.
더 강하게 막고 싶다면 branded type이나 private 생성자를 가진 class로 감싸야 한다. 여기서는 생성 경계의 개념을 보여주기 위해 단순한 구조적 타입으로 둔다.

그리고 서비스는 검증하지 않고 실행만 한다. 이유는 ReceiptEmailService의 책임이 “입력값이 영수증으로 성립하는지 판단하는 것”이 아니라, “이미 유효한 영수증을 HTML로 렌더링하고 이메일로 발송하는 것”이기 때문이다.

검증을 서비스 안에 넣으면 생성 경계가 흐려진다. 같은 OrderReceipt를 다른 use case에서 사용할 때마다 검증 코드가 반복되고, 어떤 경로에서는 검증하고 어떤 경로에서는 빠뜨리는 문제가 생긴다. 반대로 tryCreateOrderReceipt를 유일한 생성 경계로 두면, 서비스는 OrderReceipt가 이미 유효하다는 전제 위에서 실행할 수 있다.

즉 검증은 객체가 만들어지는 경계에서 하고, 서비스는 유효한 객체를 받아 use case를 실행한다.
보통 초보 프로그래머는 모든 것에 검증을 넣으려고 하는데, 오히려 너무 많은 검증은 폐해가 된다.
결국 프로그래밍이라는 건 논리적 경계를 어떻게 설정하냐인데, 팀 규약에 따라 다르지만 일반적으로 권장되는 관례가 있다는 점을 명시해야한다.


Python

Python
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum, auto
from typing import Generic, Protocol, TypeVar

TValue = TypeVar("TValue")
TError = TypeVar("TError")

class ReceiptError(Enum):
    EMPTY_ORDER_ID = auto()
    EMPTY_CUSTOMER_EMAIL = auto()
    NEGATIVE_TOTAL = auto()

@dataclass(frozen=True)
class Ok(Generic[TValue]):
    value: TValue

@dataclass(frozen=True)
class Err(Generic[TError]):
    error: TError

@dataclass(frozen=True)
class OrderReceipt:
    order_id: str
    customer_email: str
    total_cents: int

@dataclass(frozen=True)
class SendReceiptResult:
    order_id: str
    customer_email: str
    sent_at: datetime

class ReceiptRenderer(Protocol):
    def render_html(
        self,
        receipt: OrderReceipt,
        issued_at: datetime,
    ) -> str:
        ...

class EmailSender(Protocol):
    async def send_async(
        self,
        to_email: str,
        subject: str,
        html_body: str,
    ) -> None:
        ...

class Clock(Protocol):
    def get_utc_now(self) -> datetime:
        ...

def try_create_order_receipt(
    order_id: str,
    customer_email: str,
    total_cents: int,
) -> Ok[OrderReceipt] | Err[ReceiptError]:
    if order_id.strip() == "":
        return Err(ReceiptError.EMPTY_ORDER_ID)

    if customer_email.strip() == "":
        return Err(ReceiptError.EMPTY_CUSTOMER_EMAIL)

    if total_cents < 0:
        return Err(ReceiptError.NEGATIVE_TOTAL)

    return Ok(
        OrderReceipt(
            order_id=order_id,
            customer_email=customer_email,
            total_cents=total_cents,
        )
    )

class ReceiptEmailService:
    def __init__(
        self,
        renderer: ReceiptRenderer,
        email_sender: EmailSender,
        clock: Clock,
    ) -> None:
        self._renderer = renderer
        self._email_sender = email_sender
        self._clock = clock

    async def send_async(
        self,
        receipt: OrderReceipt,
    ) -> SendReceiptResult:
        issued_at = self._clock.get_utc_now()
        html_body = self._renderer.render_html(receipt, issued_at)

        await self._email_sender.send_async(
            receipt.customer_email,
            f"Receipt for order {receipt.order_id}",
            html_body,
        )

        return SendReceiptResult(
            order_id=receipt.order_id,
            customer_email=receipt.customer_email,
            sent_at=issued_at,
        )

class SystemClock:
    def get_utc_now(self) -> datetime:
        return datetime.now(UTC)

class HtmlReceiptRenderer:
    def render_html(
        self,
        receipt: OrderReceipt,
        issued_at: datetime,
    ) -> str:
        return (
            f"<h1>Order {receipt.order_id}</h1>"
            f"<p>Total: {receipt.total_cents}</p>"
            f"<p>{issued_at.isoformat()}</p>"
        )

검증은 try_create_order_receiptOk/Err로 반환한다. 호출 경계에서 match 또는 isinstance로 분기해 처리한다.
Python 예제에서도 실제 EmailSender는 네트워크 실패를 예외로 던질 수 있으므로, 테스트에서는 성공 Fake뿐 아니라 실패 Fake도 만들어 상위 경계가 예외를 처리하는지 확인한다.


Rust

Rust
use async_trait::async_trait;
use time::OffsetDateTime;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReceiptError {
    EmptyOrderId,
    EmptyCustomerEmail,
    NegativeTotal,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrderReceipt {
    order_id: String,
    customer_email: String,
    total_cents: i64,
}

impl OrderReceipt {
    pub fn try_new(
        order_id: String,
        customer_email: String,
        total_cents: i64,
    ) -> Result<Self, ReceiptError> {
        if order_id.trim().is_empty() {
            return Err(ReceiptError::EmptyOrderId);
        }

        if customer_email.trim().is_empty() {
            return Err(ReceiptError::EmptyCustomerEmail);
        }

        if total_cents < 0 {
            return Err(ReceiptError::NegativeTotal);
        }

        Ok(Self {
            order_id,
            customer_email,
            total_cents,
        })
    }

    pub fn order_id(&self) -> &str {
        &self.order_id
    }

    pub fn customer_email(&self) -> &str {
        &self.customer_email
    }

    pub fn total_cents(&self) -> i64 {
        self.total_cents
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendReceiptResult {
    order_id: String,
    customer_email: String,
    sent_at: OffsetDateTime,
}

impl SendReceiptResult {
    pub fn order_id(&self) -> &str {
        &self.order_id
    }

    pub fn customer_email(&self) -> &str {
        &self.customer_email
    }

    pub fn sent_at(&self) -> OffsetDateTime {
        self.sent_at
    }
}

pub trait ReceiptRenderer: Send + Sync {
    fn render_html(
        &self,
        receipt: &OrderReceipt,
        issued_at: OffsetDateTime,
    ) -> String;
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailSendError {
    Timeout,
    DnsFailure,
    AuthenticationFailed,
    TransportFailed,
}

#[async_trait]
pub trait EmailSender: Send + Sync {
    async fn send_async(
        &self,
        to_email: &str,
        subject: &str,
        html_body: &str,
    ) -> Result<(), EmailSendError>;
}

pub trait Clock: Send + Sync {
    fn get_utc_now(&self) -> OffsetDateTime;
}

pub struct ReceiptEmailService<TRenderer, TEmailSender, TClock>
where
    TRenderer: ReceiptRenderer,
    TEmailSender: EmailSender,
    TClock: Clock,
{
    renderer: TRenderer,
    email_sender: TEmailSender,
    clock: TClock,
}

impl<TRenderer, TEmailSender, TClock>
    ReceiptEmailService<TRenderer, TEmailSender, TClock>
where
    TRenderer: ReceiptRenderer,
    TEmailSender: EmailSender,
    TClock: Clock,
{
    pub fn new(
        renderer: TRenderer,
        email_sender: TEmailSender,
        clock: TClock,
    ) -> Self {
        Self {
            renderer,
            email_sender,
            clock,
        }
    }

    pub async fn send_async(
        &self,
        receipt: &OrderReceipt,
    ) -> Result<SendReceiptResult, EmailSendError> {
        let issued_at = self.clock.get_utc_now();
        let html_body = self.renderer.render_html(receipt, issued_at);
        let subject = format!("Receipt for order {}", receipt.order_id());

        self.email_sender
            .send_async(
                receipt.customer_email(),
                &subject,
                &html_body,
            )
            .await?;

        Ok(SendReceiptResult {
            order_id: receipt.order_id().to_owned(),
            customer_email: receipt.customer_email().to_owned(),
            sent_at: issued_at,
        })
    }
}

pub struct SystemClock;

impl Clock for SystemClock {
    fn get_utc_now(&self) -> OffsetDateTime {
        OffsetDateTime::now_utc()
    }
}

pub struct HtmlReceiptRenderer;

impl ReceiptRenderer for HtmlReceiptRenderer {
    fn render_html(
        &self,
        receipt: &OrderReceipt,
        issued_at: OffsetDateTime,
    ) -> String {
        format!(
            "<h1>Order {}</h1><p>Total: {}</p><p>{}</p>",
            receipt.order_id(),
            receipt.total_cents(),
            issued_at
        )
    }
}

Rust에서는 assert!로 패닉을 내는 대신 try_newResult<Self, ReceiptError>를 반환한다. 외부 입력, 예를 들어 빈 order_id 같은 값 때문에 서비스가 패닉하지 않고, 호출부가 Err를 명시적으로 처리하게 하기 위해서다.

위 예제는 정적 조합을 보여주기 위해 generic type parameter를 사용하긴하는데, 런타임에 구현을 바꿔야 한다면 별도 버전으로 필드 타입을 Box<dyn EmailSender + Send + Sync> 또는 Arc<dyn EmailSender + Send + Sync> 같은 trait object로 잡는 설계를 선택할 수 있다.

멀티스레드 async runtime에서 Arc로 공유하거나 tokio::spawn으로 task에 넘길 가능성이 있으면 주입되는 trait에 Send + Sync 경계가 필요하다. 이 경계가 없으면 컴파일러가 스레드 간 이동 가능성을 확인할 수 없어 실제 동시성 코드에서 컴파일 에러가 난다. 단일 스레드 실행만 전제한다면 #[async_trait(?Send)] 같은 선택지도 있지만, 서버 코드에서는 보통 Send + Sync를 명시하는 편이 안전하다.

이메일 발송 실패는 타입에 드러난다. EmailSender::send_asyncResult<(), EmailSendError>를 반환하고, 서비스는 ?로 전파해 Result<SendReceiptResult, EmailSendError>를 반환한다. C#/TypeScript/Python에서는 이 I/O 실패가 예외나 rejected promise로 상위 경계에 전달되지만, Rust에서는 타입 시스템에 명시하는 편이 Rust식 실패 처리 철학과 더 맞는다.

검증 실패인 ReceiptError와 발송 실패인 EmailSendError를 서로 다른 타입으로 나눈 점도 중요하다. 하나는 도메인 입력 검증 실패이고, 다른 하나는 외부 I/O 실패다. 두 실패는 복구 위치와 정책이 다르므로 같은 에러 타입으로 섞지 않는다.


3. 호출부

하나의 실행 단위 안에서는 Composition Root가 실제 구현을 만드는 중심 조립 지점이다. 여기서는 TypeScript 기준으로 애플리케이션 시작부에서 객체 그래프를 만들고, 검증 Result를 경계에서 처리한다.

TypeScript
type AppServices = Readonly<{
  receiptEmailService: ReceiptEmailService;
}>;

class SmtpEmailSender implements EmailSender
{
  readonly #host: string;

  public constructor(host: string)
  {
    if (host.trim().length === 0) {
      throw new RangeError("host는 비어 있을 수 없습니다.");
    }

    this.#host = host;
  }

  public async sendAsync(
    toEmail: string,
    subject: string,
    htmlBody: string,
    _signal?: AbortSignal,
  ): Promise<void>
  {
    console.info("smtp.send", {
      host: this.#host,
      toEmail,
      subject,
      bodyLength: htmlBody.length,
    });
  }
}

export function buildAppServices(): AppServices
{
  const emailSender = new SmtpEmailSender("smtp.example.com");
  const receiptEmailService = new ReceiptEmailService(
    htmlReceiptRenderer,
    emailSender,
    systemClock,
  );

  return {
    receiptEmailService,
  };
}

export async function handlePaymentCompletedAsync(
  input: Readonly<{
    orderId: string;
    customerEmail: string;
    totalCents: number;
  }>,
  services: AppServices,
  signal?: AbortSignal,
): Promise<Result<SendReceiptResult, ReceiptError>>
{
  const receiptResult = tryCreateOrderReceipt(
    input.orderId,
    input.customerEmail,
    input.totalCents,
  );

  if (!receiptResult.ok) {
    return receiptResult;
  }

  const sentAt = await services.receiptEmailService.sendAsync(
    receiptResult.value,
    signal,
  );

  return {
    ok: true,
    value: sentAt,
  };
}

책임 분리:

Text
buildAppServices             = Composition Root, 실제 구현과 lifetime 조립
tryCreateOrderReceipt        = 입력 검증, 예측 가능한 실패를 Result로 반환
ReceiptEmailService          = 영수증 발송 use case orchestration, 실행만 담당
ReceiptRenderer              = 순수 HTML 변환
EmailSender                  = 외부 이메일 I/O
Clock                        = 시간 의존성
handlePaymentCompletedAsync  = 이벤트/API 경계에서 검증 Result 처리 후 use case 호출

중요한 점은 두 가지다. ReceiptEmailServiceSmtpEmailSender를 직접 만들지 않는다는 것(조립은 Composition Root에서 끝난다).

그리고 검증 실패가 예외로 튀지 않고 Result로 반환되어, 경계 함수가 가드절로 먼저 처리한다는 것.

이 함수의 Result는 입력 검증 실패만 표현한다. SMTP 실패 같은 I/O 실패는 rejected promise로 상위 경계에 전파되며, 이벤트 핸들러나 worker 경계에서 재시도/백오프/알림 정책으로 처리한다. 즉 검증 실패는 ResultErr로, I/O 실패는 rejection으로 갈라지는 것이 이 호출부의 실패 모델이다.


4. 읽는 순서

Text
이 객체가 필요한 의존성은 무엇인가
-> 그 의존성이 생성자나 함수 인자로 드러나는가
-> 객체 내부에서 new/환경변수/전역 registry를 직접 조회하지 않는가
-> 실제 구현 선택은 Composition Root에 모여 있는가
-> 예측 가능한 검증 실패가 예외가 아니라 Result로 표현되는가
-> 그 검증이 생성 경계(팩토리)에 모여 있는가
-> lifetime이 singleton/scoped/transient 중 무엇인가
-> 테스트에서 fake 구현을 넣을 수 있는가
-> I/O 의존성과 순수 변환 의존성이 분리되어 있는가

DI 코드는 "인터페이스가 있는가"보다 "생성 책임이 어디에 있는가"를 먼저 찾아야한다.


5. 경계와 오해

Dependency Injection은 모든 객체에 인터페이스를 붙이는 패턴이 아니다. 바뀔 가능성이 낮고 순수한 값 객체, DTO, 작은 계산 함수에는 DI가 필요 없다.
DI가 필요한 곳은 시간, 파일, 네트워크, DB, 메시지 큐, 외부 API, randomness, configuration처럼 테스트와 운영 환경에 따라 구현이 달라질 수 있는 의존성이다.

바꿔 말하면, 모든 new가 나쁜 것은 아니다. 값 객체, DTO, 순수 계산 객체를 내부에서 만드는 것은 보통 문제 되지 않는다. 문제가 되는 것은 SMTP, DB, 파일, 시간, 네트워크, 설정처럼 환경과 수명 정책을 가진 의존성을 use case 내부에서 직접 만드는 것이다. new를 금지하는 게 아니라, 외부 세계와 연결되는 구현의 생성을 use case 밖으로 밀어내는 것이 요점이다.

도메인 수준의 예측 가능한 실패와 시스템 실패를 분리해야 한다. OrderId가 비어 있는 것은 입력 검증 실패이고, 이것은 예외가 아니라 Result로 모델링해 생성 경계에서 반환한다. SMTP timeout, DNS 실패, 인증 실패는 이메일 I/O 실패이고, 이것은 재시도나 상위 경계 처리 대상이다. 널 의존성 주입 같은 배선 실수는 복구 불가능한 프로그래밍 오류이므로 생성자에서 즉시 예외로 막는다. DI는 실패 처리 방식 자체가 아니라, 실패가 발생하는 경계를 분리해 테스트와 정책 처리를 가능하게 만드는 패턴이다.

DI Container는 선택 사항이다. 물론 교과서적으로는 그렇다는 말이고, DI 컨테이너를 필수로 요구하는 클라이언트가 있다.

C#/.NET처럼 container가 표준 생태계에 깊게 들어와 있는 언어에서는 container 기반 DI가 자연스럽다. 반면 TypeScript, Python, Rust에서는 manual wiring이 더 단순하고 명확한 경우가 많다. Fowler도 DI를 설명할 때 container 자체보다 의존성을 외부에서 제공하는 방식에 초점을 둔다.

Service Locator는 DI처럼 보이지만 책임 방향이 다르다. ReceiptEmailServiceGlobalContainer.get("emailSender")를 호출하면 의존성이 생성자에 드러나지 않는다. 테스트도 어려워지고, 객체가 어떤 의존성을 쓰는지 코드 서명만으로 알 수 없다. 의존성이 숨겨지는 순간 Composition Root의 가치가 줄어든다.

실무에서 자주 하는 실수들:

Text
- 서비스 내부에서 new SmtpEmailSender()를 직접 호출함
- 서비스가 전역 container/service locator를 직접 조회함
- 예측 가능한 검증 실패를 예외/패닉으로 처리해 실행부로 새어 나옴
- singleton 객체가 request-scoped 상태를 들고 있음
- transient로 만들어야 할 무거운 client를 매 요청마다 새로 생성함
- 테스트에서 실제 SMTP/DB/clock을 사용함
- interface가 너무 많아 순수 값 객체까지 불필요하게 추상화됨
- Composition Root가 여러 곳으로 흩어져 객체 그래프를 추적할 수 없음
- configuration 읽기와 use case 실행이 같은 클래스에 섞임

6. 잘못된 예제

TypeScript
class BadReceiptEmailService
{
  public async sendAsync(receipt: OrderReceipt): Promise<SendReceiptResult>
  {
    const renderer = htmlReceiptRenderer;
    const clock = systemClock;
    const emailSender = new SmtpEmailSender("smtp.example.com");

    const issuedAt = clock.getUtcNow();
    const htmlBody = renderer.renderHtml(receipt, issuedAt);

    await emailSender.sendAsync(
      receipt.customerEmail,
      `Receipt for order ${receipt.orderId}`,
      htmlBody,
    );

    return {
      orderId: receipt.orderId,
      customerEmail: receipt.customerEmail,
      sentAt: issuedAt,
    };
  }
}

나쁜 이유:

Text
- 서비스 내부에서 실제 SMTP 구현을 직접 생성한다.
- 테스트에서 fake EmailSender를 넣을 수 없다.
- 현재 시각을 고정하기 어렵다.
- SMTP host 설정이 Composition Root가 아니라 use case 안에 박혀 있다.
- 서비스 생성 비용과 SMTP client lifetime을 제어할 수 없다.
- 의존성이 생성자 서명에 드러나지 않아 코드 리뷰가 어렵다.

더 나쁜 형태는 전역 locator다.

TypeScript
class LocatorBasedReceiptEmailService
{
  public async sendAsync(receipt: OrderReceipt): Promise<SendReceiptResult>
  {
    const emailSender = globalContainer.resolve<EmailSender>("emailSender");
    const clock = globalContainer.resolve<Clock>("clock");
    const renderer = globalContainer.resolve<ReceiptRenderer>("renderer");

    const issuedAt = clock.getUtcNow();
    const htmlBody = renderer.renderHtml(receipt, issuedAt);

    await emailSender.sendAsync(receipt.customerEmail, "Receipt", htmlBody);

    return {
      orderId: receipt.orderId,
      customerEmail: receipt.customerEmail,
      sentAt: issuedAt,
    };
  }
}

이 코드는 DI처럼 보이지만 객체가 스스로 의존성을 찾아간다. 생성자만 봐서는 필요한 의존성을 알 수 없으므로 Service Locator에 가깝다.

7. 프로덕션 확장

실전 확장은 테스트용 fake dependency와 lifetime 정책 표다. DI의 가치는 테스트에서 가장 먼저 드러난다. 실제 SMTP 서버에 붙지 않고도 영수증 발송 use case를 검증할 수 있어야 한다.

다만 여기서 조심할 점이 있다. Fake는 현실을 단순화하기 위해 쓰지만, 너무 낙관적인 Fake는 오히려 운영 실패를 가린다. 따라서 성공 경로를 기록하는 Fake와 실패 경로를 주입하는 Fake를 둘 다 둬야 한다.

먼저 성공 경로를 검증한다. 여기서 RecordingEmailSender는 실제 SMTP를 흉내 내는 객체가 아니다. 서비스가 어떤 수신자, 제목, 본문으로 이메일 발송을 요청했는지 기록하는 테스트 더블이다.

TypeScript
import assert from "node:assert/strict";
import test from "node:test";

class RecordingEmailSender implements EmailSender
{
  readonly #sentMessages: Array<Readonly<{
    toEmail: string;
    subject: string;
    htmlBody: string;
  }>> = [];

  public async sendAsync(
    toEmail: string,
    subject: string,
    htmlBody: string,
  ): Promise<void>
  {
    this.#sentMessages.push({
      toEmail,
      subject,
      htmlBody,
    });
  }

  public getSentMessages(): readonly Readonly<{
    toEmail: string;
    subject: string;
    htmlBody: string;
  }>[]
  {
    return this.#sentMessages;
  }
}

const fixedClock: Clock = {
  getUtcNow: () => new Date("2026-07-08T00:00:00.000Z"),
};

const testRenderer: ReceiptRenderer = {
  renderHtml: (receipt, issuedAt) => {
    return `order=${receipt.orderId}; total=${receipt.totalCents}; issuedAt=${issuedAt.toISOString()}`;
  },
};

test("tryCreateOrderReceipt는 빈 orderId를 Err로 반환한다", () => {
  const result = tryCreateOrderReceipt("", "[email protected]", 12_000);

  assert.equal(result.ok, false);
  if (result.ok) {
    return;
  }

  assert.equal(result.error, "EmptyOrderId");
});

test("ReceiptEmailService는 이메일 발송 의도를 EmailSender에 전달한다", async () => {
  const emailSender = new RecordingEmailSender();
  const service = new ReceiptEmailService(
    testRenderer,
    emailSender,
    fixedClock,
  );

  const receiptResult = tryCreateOrderReceipt(
    "ord-1",
    "[email protected]",
    12_000,
  );

  assert.equal(receiptResult.ok, true);
  if (!receiptResult.ok) {
    return;
  }

  const result = await service.sendAsync(receiptResult.value);

  assert.equal(result.sentAt.toISOString(), "2026-07-08T00:00:00.000Z");
  assert.deepEqual(emailSender.getSentMessages(), [
    {
      toEmail: "[email protected]",
      subject: "Receipt for order ord-1",
      htmlBody: "order=ord-1; total=12000; issuedAt=2026-07-08T00:00:00.000Z",
    },
  ]);
});

이 테스트는 “진짜 메일이 갔는가”를 검증하지 않는다. 그것은 SMTP 클라이언트나 통합 테스트의 관심사다. 여기서 검증하는 것은 ReceiptEmailService가 올바른 이메일 발송 의도를 만들고, 그것을 EmailSender 의존성에 전달했는가이다.

하지만 여기서 끝내면 테스트는 지나치게 낙관적이다.
보통 여기까지만 끝내면, 테스트 광신자들은 잘못된 테스트를 작성했니 하면서 잔소리를 시작할텐데 보통 테스트는 기본적으로 2가지 케이스를 작성하는게 좋다. 성공 케이스/실패 케이스를 작성하는 것이다. 프로그래밍이라는 작업은 하이에나에게 먹이를 던져주는 작업과 같다. 보통 프로그래머라는 직종을 가진 사람들은 항상 자기가 남들보다 우수하다는 걸 끊임없이 강조하고 싶어하며, 그들은 언제나 비판의 송곳니를 들이밀기 때문에 정신차려야한다.

현실의 SMTP는 언제든 실패한다. 타임아웃이 나고, DNS가 실패하고, 인증 서버가 죽고, 네트워크가 끊긴다. 성공 Fake에만 의존하면 정작 I/O 에러가 났을 때 상위 계층이 재시도할지, 백오프할지, 서킷 브레이커를 열지, 사용자에게 어떻게 노출할지 하는 실패 복구 시나리오가 통째로 테스트 사각지대가 된다.

그래서 결함 주입(fault injection) Fake로 실패 경로도 테스트한다.

TypeScript에서는 발송 실패가 rejected promise로 표현된다. 이 use case의 책임은 실패를 삼키는 것이 아니라 상위 경계로 전파하는 것이다.

TypeScript
class FailingEmailSender implements EmailSender
{
  readonly #error: Error;

  public constructor(error: Error)
  {
    this.#error = error;
  }

  public async sendAsync(
    _toEmail: string,
    _subject: string,
    _htmlBody: string,
  ): Promise<void>
  {
    throw this.#error;
  }
}

test("ReceiptEmailService는 이메일 발송 실패를 상위 경계로 전파한다", async () => {
  const emailSender = new FailingEmailSender(
    new Error("smtp transport failed"),
  );

  const service = new ReceiptEmailService(
    testRenderer,
    emailSender,
    fixedClock,
  );

  const receiptResult = tryCreateOrderReceipt(
    "ord-1",
    "[email protected]",
    12_000,
  );

  assert.equal(receiptResult.ok, true);
  if (!receiptResult.ok) {
    return;
  }

  await assert.rejects(
    () => service.sendAsync(receiptResult.value),
    /smtp transport failed/,
  );
});

Rust에서는 실패가 타입에 드러난다. EmailSenderResult<(), EmailSendError>를 반환하므로, 실패 Fake는 Err를 반환하면 된다. 서비스가 그 Err를 그대로 돌려주는지 단언한다.

Rust
struct FailingEmailSender;

#[async_trait]
impl EmailSender for FailingEmailSender {
    async fn send_async(
        &self,
        _to_email: &str,
        _subject: &str,
        _html_body: &str,
    ) -> Result<(), EmailSendError> {
        Err(EmailSendError::Timeout)
    }
}

#[tokio::test]
async fn send_failure_is_propagated_as_err() {
    let receipt = OrderReceipt::try_new(
        "ord-1".to_owned(),
        "[email protected]".to_owned(),
        12_000,
    )
    .expect("valid receipt");

    let service = ReceiptEmailService::new(
        HtmlReceiptRenderer,
        FailingEmailSender,
        SystemClock,
    );

    let result = service.send_async(&receipt).await;

    assert_eq!(result, Err(EmailSendError::Timeout));
}

한 가지 경계를 분명히 해야 한다.

재시도, 백오프, 서킷 브레이커, 알림, dead letter queue 같은 복구 정책은 ReceiptEmailService의 책임이 아니다. 이 use case는 영수증을 렌더링하고 이메일 발송 의도를 실행하는 계층이다. 실패를 삼키고 재시도 정책까지 품기 시작하면 use case가 운영 정책까지 떠안게 된다.

복구 정책은 보통 이 use case를 호출하는 경계에 둔다.

Text
API handler
event handler
background worker
message consumer
scheduler

그 계층에서 실패를 잡고, 재시도할지, 백오프할지, 서킷 브레이커를 열지, 관리자에게 알릴지, dead letter queue로 보낼지 결정한다. DI가 여기서 하는 일은 그 실패를 주입 가능하게 만들어 실패 경로 테스트 자체를 가능하게 하는 것이다.

즉 성공 Fake와 결함 주입 Fake를 둘 다 갖춰야 한다.

Text
RecordingEmailSender = 성공 경로에서 서비스가 무엇을 하려고 했는지 기록한다.
FailingEmailSender   = I/O 실패가 상위 경계로 전파되는지 확인한다.

운영에서는 lifetime 표를 명시한다.

Text
ReceiptEmailService     = transient 또는 scoped, 요청/use case 단위
HtmlReceiptRenderer     = singleton, 상태 없는 순수 변환
SystemClock             = singleton, 상태 없음
SmtpEmailSender         = singleton 또는 pooled, connection 관리 정책에 따름
Configuration           = singleton, 부팅 시 검증된 immutable config
RequestContext          = scoped, 요청별 correlation/user 정보

Microsoft의 DI lifetime 문서도 transient, scoped, singleton 수명을 구분하고 각 서비스에 적절한 lifetime을 선택해야 한다고 설명한다.4 이 선택을 대충 하면 실제 장애가 생긴다.
예를 들어 request context를 singleton에 보관하면 사용자 정보가 섞일 수 있고, 무거운 HTTP client를 transient로 매번 만들면 connection 관리가 나빠질 수 있다.

간단한 DI review checklist는 다음과 같다.

Text
- use case 클래스 내부에 new 외부 I/O client가 없는가?
- constructor만 보고 필수 의존성을 알 수 있는가?
- Composition Root가 실행 진입점마다 하나로 모여 있는가?
- 예측 가능한 검증 실패가 예외가 아니라 Result로 반환되는가?
- 그 검증이 생성 경계에 모여 있고 실행부는 실행만 하는가?
- singleton이 mutable request state를 들고 있지 않은가?
- 테스트가 fake clock/fake sender/fake repository로 실행되는가?
- 성공 Fake뿐 아니라 fault-injecting fake로 I/O 실패 경로도 테스트되는가?
- 실패 복구 정책이 use case가 아니라 호출 경계에 있는가?
- Service Locator 호출이 application core로 새지 않는가?

DI는 테스트를 쉽게 만드는 패턴이라고 자주 말하지만, 더 정확히는 실패를 주입할 수 있게 만드는 패턴이다. 성공만 검증하는 테스트는 대개 예쁜 거짓말이다. 운영에서 중요한 것은 성공 경로보다 실패 경로가 어디로 흐르는지다.


8. C# / TypeScript / Python / Rust 비교 메모

언어

관용 표현

주의점

C#

constructor injection, interface, built-in DI container, lifetime 등록

container를 application core에서 직접 resolve하지 말 것

TypeScript

manual wiring, object interface, composition function

decorator/container를 도입하기 전에 수동 조립으로 충분한지 확인

Python

Protocol, constructor injection, 간단한 factory function

monkey patch/global singleton에 기대면 의존성이 숨겨짐

Rust

trait, generic injection, trait object, explicit ownership

lifetime/ownership 때문에 composition boundary를 더 명확히 설계해야 함

C#은 DI container가 표준 생태계에 잘 통합되어 있고, 생성자 주입과 service lifetime 등록이 자연스럽다. 표준 TimeProvider.GetUtcNow()가 있으므로 커스텀 Clock 대신 그것을 쓰는 선택지도 있다5. 애초에 DI 컨테이너 안쓰면 WPF쪽으로는 밥벌이 하기도 힘들기도 하고.

TypeScript는 구조적 타입과 객체 리터럴 덕분에 작은 규모에서는 수동 DI가 간결하다. Python은 Protocol로 필요한 동작을 표현하고 생성자에 명시적으로 넘기는 방식이 읽기 좋다. Rust는 trait generic 또는 trait object를 통해 의존성을 주입하지만, async trait과 ownership 설계가 함께 따라온다.

늘 말하지만, 한 언어의 스타일을 다른 언어에 억지로 이식할 필요는 없다. TypeScript에서 C#식 container annotation을 무리하게 흉내 낼 필요는 없고, C#에서 Rust식 generic injection을 과도하게 밀어붙일 필요도 없다. 공통 원칙은 하나다.

Text
실행 객체는 의존성을 만들지 말고, 조립 계층이 만들어서 넘겨야 한다.

9. 추가로 생각해보기

  • 이 의존성은 순수 계산인가, 외부 I/O인가, 시간/난수/config 같은 환경 의존성인가?

  • 이 객체가 직접 new를 호출해도 되는 대상은 값 객체인가, 외부 자원인가?

  • 예측 가능한 검증 실패를 Result로 모델링하면, 호출 경계의 분기 코드가 어떻게 단순해지는가?

  • Composition Root가 여러 곳으로 흩어지면 배포 설정과 테스트 조립이 어떻게 어려워지는가?

  • singleton으로 둔 객체가 request별 상태를 들고 있지는 않은가?

  • 테스트에서 fake clock, fake sender, fake repository를 넣을 수 없다면 어느 경계가 잘못된 것인가?

  • DI container를 쓰는 것이 코드량을 줄이는가, 아니면 의존성 흐름을 숨기는가?


10. 요약

  • Composition Root 의존성 주입은 객체 그래프 조립을 애플리케이션 시작 경계에 모으는 패턴이다.

  • 서비스는 필요한 의존성을 생성자나 함수 인자로 드러내고, 직접 만들지 않는 편이 좋다.

  • DI의 핵심은 인터페이스 남발이 아니라 생성 책임, lifetime, 테스트 대체 가능성의 분리다.

  • 예측 가능한 검증 실패는 예외가 아니라 Result로 반환하고, 생성 경계에 모아 실행부를 실행만 하게 한다.

  • Service Locator는 의존성을 숨기므로 application core로 새지 않게 해야 한다.

  • singleton/scoped/transient lifetime을 잘못 고르면 상태 누출, 성능 저하, 테스트 불안정이 생긴다.

  • TypeScript/Python/Rust에서는 container 없이 수동 조립이 더 명확한 경우도 많다.

DI는 객체를 예쁘게 만드는 문법이 아니라, 생성 책임과 실패 경계를 한곳에서 통제하기 위한 운영 규율이다.

암기 규칙:

Text
비즈니스 객체는 의존성을 찾거나 만들지 말고, 조립된 의존성을 받아 실행만 해야 한다.

각주

  1. Martin Fowler, "Inversion of Control Containers and the Dependency Injection Pattern"(2004-01-23): https://martinfowler.com/articles/injection.html
  2. Mark Seemann, "Composition Root"(2011-07-28): https://blog.ploeh.dk/2011/07/28/CompositionRoot/
  3. Microsoft Learn, "Dependency injection - .NET": https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection
  4. Microsoft Learn, "Service lifetimes (dependency injection) - .NET": https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#service-lifetimes
  5. Microsoft Learn, "TimeProvider Class": https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider
Composition Root와 실패 경계 | 메모