Devin.KR

의존성 주입 원리 - 생성자 주입과 수명

개발자KR 조회 0

이 장에서 배우는 것

클래스가 다른 클래스를 필요로 할 때 그 객체를 누가 만들고 언제 버릴지 정하는 일은 서비스가 커질수록 코드 곳곳에 흩어진다. 이 장에서는 그 일을 한곳으로 모으는 의존성 주입(dependency injection)을 다룬다. 생성자 매개변수만 보고 객체 그래프를 조립하는 작은 컨테이너(container)를 리플렉션으로 직접 만든다. 그다음 같은 기능이 .NET Generic Host 에서는 어떤 이름으로 제공되는지 비교한다.

  • 생성자 주입(constructor injection)이 무엇이고, 조립 코드를 한곳에 모으면 무엇이 달라지는지 설명한다.
  • 리플렉션으로 생성자 매개변수를 읽어 의존 객체를 재귀적으로 만드는 50줄 남짓의 컨테이너를 작성한다.
  • Singleton, Scoped, Transient 수명(lifetime)의 차이와 객체 해제 시점을 실행 결과로 확인한다.
  • 수명이 긴 객체가 수명이 짧은 객체를 붙잡는 문제(captive dependency)를 재현하고 스코프 검증으로 막는다.
  • 직접 만든 컨테이너와 Generic Host 의 기능 차이를 구분한다.

문제 상황

택배 물류 센터의 주문 접수 서비스가 있다고 하자. 접수 요청 하나를 처리하려면 송장 번호 발급기, 저장소, 감사 로그, 라벨 출력기가 필요하다. 처음에는 OrderService 안에서 필요한 객체를 new 로 만든다.

public string Accept(string receiver, string region)
{
    var repository = new InMemoryParcelRepository();
    var numbers = new TrackingNumberGenerator();
    var audit = new AuditLog(new RequestContext());
    ...
}

이 코드에는 세 가지 문제가 있다. 첫째, 접수할 때마다 저장소가 새로 만들어져 이전 소포가 사라진다. 저장소는 프로그램 전체에서 하나여야 한다. 둘째, 송장 번호 발급기도 호출마다 새로 만들어지므로 번호가 계속 1001 로 시작한다. 셋째, 감사 로그는 요청 하나 안에서는 같은 객체를 써야 하고 요청이 끝나면 해제해야 한다. 객체마다 "몇 개를 만들고 언제 버리는가"가 다른데 이 규칙이 new 가 있는 위치마다 따로 구현된다.

규칙을 한곳에서 선언하고, 클래스는 필요한 것을 생성자로 받기만 하게 만들면 이 문제가 줄어든다. 그 역할을 하는 것이 의존성 주입 컨테이너이고, 이 장에서는 그 원리를 직접 구현해서 확인한다.

생성자 주입과 컨테이너

생성자 주입

생성자 주입은 클래스가 필요한 협력 객체를 생성자 매개변수로 선언하는 방식이다. 클래스는 협력 객체를 어떻게 만드는지 모르고, 인터페이스 이름만 안다. 객체를 조립하는 일은 프로그램의 시작 지점 한곳에서 한다. 이 위치를 조립 루트(composition root)라고 부른다.

public OrderService(IParcelRepository repository,
                    ITrackingNumberGenerator numbers,
                    IAuditLog audit,
                    ILabelPrinter printer)

이 시그니처만 읽어도 OrderService 가 무엇에 의존하는지 드러난다. 필드가 readonly 이므로 생성이 끝난 뒤에는 의존 객체가 바뀌지 않는다. 의존 객체 하나가 빠진 채 객체가 만들어지는 상태도 생기지 않는다.

컨테이너가 하는 일

컨테이너는 크게 세 가지를 한다. 첫째, "서비스 타입에 대해 어떤 구현 타입을 어떤 수명으로 만든다"는 등록 정보를 보관한다. 둘째, 타입을 요청받으면 생성자 매개변수를 읽어 각 매개변수를 다시 같은 방식으로 해결하고 그 결과로 생성자를 호출한다. 셋째, 수명 규칙에 따라 만든 객체를 캐시하고, 컨테이너나 스코프가 끝날 때 IDisposable 객체를 해제한다.

이 장의 컨테이너는 이 세 가지만 구현한다. 생성자는 public 생성자 중 매개변수가 가장 많은 것을 고른다. 순환 의존은 해결 경로를 기록해 두었다가 같은 타입이 다시 나오면 예외로 알린다. 리플렉션은 생성자 정보를 읽고 호출하는 데만 쓰며, 자세한 내용은 뒤에서 리플렉션을 다루는 장에서 본다.

세 가지 수명

수명은 "같은 타입을 여러 번 요청했을 때 같은 객체를 돌려주는 범위"를 정한다. 여기서 스코프(scope)는 웹 요청이나 메시지 처리 한 건처럼 작업 단위 하나에 대응하는 범위다. 물류 서비스에서는 주문 접수 요청 하나가 스코프 하나다.

수명별로 같은 객체가 공유되는 범위와 해제 시점
수명같은 객체를 받는 범위해제 시점이 장의 예
Singleton컨테이너 전체컨테이너를 해제할 때IParcelRepository
Scoped같은 스코프 안스코프를 해제할 때IAuditLog
Transient공유하지 않음(요청마다 새로 생성)생성한 스코프를 해제할 때ILabelPrinter
Singleton 은 루트가 하나만 갖고, Scoped 는 스코프마다 하나, Transient 는 꺼낼 때마다 새로 만든다.

그림처럼 저장소와 번호 발급기는 루트에 하나씩만 있고 모든 스코프가 공유한다. 요청 컨텍스트와 감사 로그는 스코프마다 따로 있고, 라벨 출력기는 꺼낼 때마다 새로 만들어진다.

Transient 는 "꺼낼 때마다"가 기준이라는 점에 주의해야 한다. Scoped 인 OrderService 가 생성자로 Transient 인 ILabelPrinter 를 받으면, 출력기는 OrderService 가 만들어질 때 한 번만 생성된다. 그래서 같은 OrderService 로 Accept 를 두 번 호출해도 같은 출력기가 쓰이고, 실행 결과에서 두 줄의 라벨 번호가 같게 나온다.

캡처된 의존성 문제

수명이 긴 객체가 수명이 짧은 객체를 생성자로 받으면, 받은 객체는 원래 수명이 끝난 뒤에도 계속 살아 있다. 이것을 캡처된 의존성(captive dependency)이라고 부른다. 예를 들어 Singleton 대시보드가 Scoped 요청 컨텍스트를 받으면, 대시보드는 앱이 끝날 때까지 컨텍스트 하나를 들고 있다. 이미 끝난 요청의 컨텍스트이거나, 어느 요청에도 속하지 않은 컨텍스트이다.

Singleton 이 생성 시점의 Scoped 객체를 붙잡으면 이후 요청이 와도 오래된 객체를 본다.

이 문제는 컴파일 오류도 예외도 만들지 않고 값만 조용히 틀리게 한다. 그래서 컨테이너가 객체를 만드는 시점에 해결 경로를 검사하는 것이 일반적이다. 이 장의 컨테이너도 validateScopes 옵션으로 두 가지를 검사한다. Singleton 이 경로에 있는 상태에서 Scoped 가 요청되면 거부한다. 스코프가 아닌 루트에서 Scoped 를 직접 요청해도 거부한다.

이 컨테이너는 Singleton 을 만들 때 루트 스코프에서 의존 객체를 해결한다. Singleton 이 스코프에서 만든 Transient 를 받으면 그 스코프가 닫힐 때 같이 해제되기 때문이다. 검증을 끄면 Singleton 이 받은 Scoped 객체는 어느 요청에도 속하지 않은, 루트에 만들어진 하나가 된다. 완성 코드의 3번 출력이 이 경우다.

Generic Host 와 비교

.NET 이 제공하는 Generic Host 는 같은 개념을 더 많은 기능과 함께 제공한다. 다만 Microsoft.Extensions.Hosting 은 NuGet 패키지이므로 이 책의 실행 환경(BCL 만 사용)에서는 돌릴 수 없다. 아래 조각은 비교용이며 실행 대상이 아니다.

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IParcelRepository, InMemoryParcelRepository>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddTransient<ILabelPrinter, LabelPrinter>();
using var host = builder.Build();
using var scope = host.Services.CreateScope();
var order = scope.ServiceProvider.GetRequiredService<OrderService>();

등록 방식과 스코프 생성 방식이 이 장의 컨테이너와 거의 같다. 이 장에서 Add<TService, TImplementation>(Lifetime) 으로 한 일이 여기서는 AddSingleton, AddScoped, AddTransient 로 나뉘어 있다. 스코프 안에서는 scope.Resolve<T>() 대신 scope.ServiceProvider.GetRequiredService<T>() 를 쓴다. 개발 환경에서는 스코프 검증과 빌드 시점 검증이 기본으로 켜진다. 자세한 동작은 .NET 의존성 주입 공식 문서에서 확인할 수 있다.

직접 만든 컨테이너와 Generic Host 의 차이
항목이 장의 MiniContainerGeneric Host
생성자 선택매개변수가 가장 많은 public 생성자해결 가능한 생성자 중 가장 많은 것, 모호하면 예외
등록 방식타입 쌍과 수명만 지원팩토리 델리게이트, 기존 인스턴스, 열린 제네릭, 키 지정 서비스 지원
같은 타입 여러 등록나중 등록이 덮어씀모두 보관하고 IEnumerable<T> 로 받을 수 있음
검증요청 시점에 검사빌드 시점과 요청 시점에 검사
비동기 해제지원하지 않음IAsyncDisposable 지원
앱 수명 관리없음구성, 로깅, 호스티드 서비스와 함께 시작·종료 관리

실무에서는 직접 컨테이너를 만들 일이 거의 없다. 그래도 원리를 알면 "왜 이 서비스가 해제되지 않는가", "왜 이 값이 요청마다 바뀌지 않는가" 같은 문제를 수명 규칙에서 바로 추적할 수 있다.

완성 코드

Program.cs 한 파일이다. 위쪽 최상위 문장이 세 가지 시나리오를 실행하고, 아래쪽에 컨테이너와 도메인 타입이 있다.

Console.WriteLine("== 1. 수명 비교 ==");
using var container = BuildContainer(validateScopes: true);

using (var scopeA = container.CreateScope())
{
    scopeA.Resolve<IRequestContext>().RequestId = "REQ-A";
    var order = scopeA.Resolve<OrderService>();
    Console.WriteLine(order.Accept("김하늘", "서울"));
    Console.WriteLine(order.Accept("박바다", "부산"));
    Console.WriteLine("같은 스코프의 IAuditLog 는 동일 인스턴스: "
        + ReferenceEquals(scopeA.Resolve<IAuditLog>(), scopeA.Resolve<IAuditLog>()));
    Console.WriteLine("Transient 는 꺼낼 때마다 새 인스턴스: "
        + !ReferenceEquals(scopeA.Resolve<ILabelPrinter>(), scopeA.Resolve<ILabelPrinter>()));
    foreach (var line in scopeA.Resolve<IAuditLog>().Lines)
    {
        Console.WriteLine("  " + line);
    }
    Console.WriteLine("스코프 A 를 닫는다");
}

using (var scopeB = container.CreateScope())
{
    scopeB.Resolve<IRequestContext>().RequestId = "REQ-B";
    var order = scopeB.Resolve<OrderService>();
    Console.WriteLine(order.Accept("이서준", "대전"));
    Console.WriteLine("저장소 누적 건수: " + scopeB.Resolve<IParcelRepository>().Count);
    Console.WriteLine("스코프 B 를 닫는다");
}

Console.WriteLine();
Console.WriteLine("== 2. 스코프 검증 ==");
Try("루트에서 IAuditLog", () => container.Resolve<IAuditLog>());
Try("Singleton 이 Scoped 를 주입", () =>
{
    using var scope = container.CreateScope();
    return scope.Resolve<IDashboard>();
});

Console.WriteLine();
Console.WriteLine("== 3. 검증을 끈 경우 ==");
using var loose = BuildContainer(validateScopes: false);
foreach (var requestId in new[] { "REQ-C", "REQ-D" })
{
    using var scope = loose.CreateScope();
    scope.Resolve<IRequestContext>().RequestId = requestId;
    Console.WriteLine($"  현재 요청 {requestId} / {scope.Resolve<IDashboard>().Describe()}");
}

static MiniContainer BuildContainer(bool validateScopes) => new MiniContainer(validateScopes)
    .Add<IParcelRepository, InMemoryParcelRepository>(Lifetime.Singleton)
    .Add<ITrackingNumberGenerator, TrackingNumberGenerator>(Lifetime.Singleton)
    .Add<IRequestContext, RequestContext>(Lifetime.Scoped)
    .Add<IAuditLog, AuditLog>(Lifetime.Scoped)
    .Add<ILabelPrinter, LabelPrinter>(Lifetime.Transient)
    .Add<OrderService>(Lifetime.Scoped)
    .Add<IDashboard, RegionDashboard>(Lifetime.Singleton);

static void Try(string title, Func<object> action)
{
    try
    {
        action();
        Console.WriteLine($"  {title}: 성공");
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"  {title}: 거부 - {ex.Message}");
    }
}

enum Lifetime { Singleton, Scoped, Transient }

sealed class MiniContainer : IDisposable
{
    private readonly Dictionary<Type, (Type Implementation, Lifetime Lifetime)> _registry = new();
    private readonly Dictionary<Type, object> _singletons = new();
    private readonly List<IDisposable> _singletonDisposables = new();
    private readonly bool _validateScopes;
    private readonly Scope _root;

    public MiniContainer(bool validateScopes)
    {
        _validateScopes = validateScopes;
        _root = new Scope(this, isRoot: true);
    }

    public MiniContainer Add<TService, TImplementation>(Lifetime lifetime)
        where TImplementation : TService
    {
        _registry[typeof(TService)] = (typeof(TImplementation), lifetime);
        return this;
    }

    public MiniContainer Add<TService>(Lifetime lifetime) where TService : class
        => Add<TService, TService>(lifetime);

    public Scope CreateScope() => new(this, isRoot: false);

    public T Resolve<T>() where T : notnull => _root.Resolve<T>();

    public void Dispose()
    {
        _root.Dispose();
        for (var i = _singletonDisposables.Count - 1; i >= 0; i--)
        {
            _singletonDisposables[i].Dispose();
        }
        _singletonDisposables.Clear();
    }

    private object Resolve(Type service, Scope scope, List<Type> chain)
    {
        if (!_registry.TryGetValue(service, out var entry))
        {
            throw new InvalidOperationException($"등록되지 않은 서비스: {service.Name}");
        }
        if (chain.Contains(service))
        {
            throw new InvalidOperationException(
                "순환 의존: " + string.Join(" -> ", chain.Append(service).Select(t => t.Name)));
        }
        if (_validateScopes && entry.Lifetime == Lifetime.Scoped)
        {
            var holder = chain.FirstOrDefault(t => _registry[t].Lifetime == Lifetime.Singleton);
            if (holder is not null)
            {
                throw new InvalidOperationException(
                    $"Singleton {holder.Name} 가 Scoped 서비스 {service.Name} 를 붙잡는다");
            }
            if (scope.IsRoot)
            {
                throw new InvalidOperationException(
                    $"루트에서 Scoped 서비스 {service.Name} 를 꺼낼 수 없다");
            }
        }

        if (entry.Lifetime == Lifetime.Singleton)
        {
            if (_singletons.TryGetValue(service, out var shared))
            {
                return shared;
            }
            var singleton = Create(entry.Implementation, _root, chain, service);
            _singletons[service] = singleton;
            if (singleton is IDisposable disposable)
            {
                _singletonDisposables.Add(disposable);
            }
            return singleton;
        }

        if (entry.Lifetime == Lifetime.Scoped)
        {
            if (scope.Instances.TryGetValue(service, out var cached))
            {
                return cached;
            }
            var scoped = Create(entry.Implementation, scope, chain, service);
            scope.Instances[service] = scoped;
            scope.Track(scoped);
            return scoped;
        }

        var transient = Create(entry.Implementation, scope, chain, service);
        scope.Track(transient);
        return transient;
    }

    private object Create(Type implementation, Scope scope, List<Type> chain, Type service)
    {
        var constructor = implementation.GetConstructors()
            .OrderByDescending(c => c.GetParameters().Length)
            .First();
        chain.Add(service);
        var arguments = constructor.GetParameters()
            .Select(p => Resolve(p.ParameterType, scope, chain))
            .ToArray();
        chain.RemoveAt(chain.Count - 1);
        return constructor.Invoke(arguments);
    }

    public sealed class Scope : IDisposable
    {
        private readonly MiniContainer _owner;
        private readonly List<IDisposable> _disposables = new();

        internal Scope(MiniContainer owner, bool isRoot)
        {
            _owner = owner;
            IsRoot = isRoot;
        }

        internal bool IsRoot { get; }

        internal Dictionary<Type, object> Instances { get; } = new();

        public T Resolve<T>() where T : notnull
            => (T)_owner.Resolve(typeof(T), this, new List<Type>());

        internal void Track(object instance)
        {
            if (instance is IDisposable disposable)
            {
                _disposables.Add(disposable);
            }
        }

        public void Dispose()
        {
            for (var i = _disposables.Count - 1; i >= 0; i--)
            {
                _disposables[i].Dispose();
            }
            _disposables.Clear();
            Instances.Clear();
        }
    }
}

record Parcel(string TrackingNumber, string Receiver, string Region);

interface IParcelRepository
{
    int Count { get; }
    void Add(Parcel parcel);
}

sealed class InMemoryParcelRepository : IParcelRepository
{
    private readonly object _gate = new();
    private readonly List<Parcel> _items = new();

    public int Count
    {
        get { lock (_gate) { return _items.Count; } }
    }

    public void Add(Parcel parcel)
    {
        lock (_gate) { _items.Add(parcel); }
    }
}

interface ITrackingNumberGenerator
{
    string Next();
}

sealed class TrackingNumberGenerator : ITrackingNumberGenerator
{
    private int _last = 1000;

    public string Next() => $"TRK-{Interlocked.Increment(ref _last)}";
}

interface IRequestContext
{
    string RequestId { get; set; }
}

sealed class RequestContext : IRequestContext
{
    public string RequestId { get; set; } = "(없음)";
}

interface IAuditLog
{
    IReadOnlyList<string> Lines { get; }
    void Write(string message);
}

sealed class AuditLog : IAuditLog, IDisposable
{
    private readonly IRequestContext _context;
    private readonly List<string> _lines = new();

    public AuditLog(IRequestContext context)
    {
        _context = context;
    }

    public IReadOnlyList<string> Lines => _lines;

    public void Write(string message) => _lines.Add($"[{_context.RequestId}] {message}");

    public void Dispose()
        => Console.WriteLine($"  AuditLog 해제 ({_lines.Count}줄, 요청 {_context.RequestId})");
}

interface ILabelPrinter
{
    string Print(Parcel parcel);
}

sealed class LabelPrinter : ILabelPrinter
{
    private static int _created;

    public int InstanceNo { get; } = Interlocked.Increment(ref _created);

    public string Print(Parcel parcel)
        => $"라벨#{InstanceNo} {parcel.TrackingNumber} {parcel.Receiver}/{parcel.Region}";
}

sealed class OrderService
{
    private readonly IParcelRepository _repository;
    private readonly ITrackingNumberGenerator _numbers;
    private readonly IAuditLog _audit;
    private readonly ILabelPrinter _printer;

    public OrderService(IParcelRepository repository, ITrackingNumberGenerator numbers,
                        IAuditLog audit, ILabelPrinter printer)
    {
        _repository = repository;
        _numbers = numbers;
        _audit = audit;
        _printer = printer;
    }

    public string Accept(string receiver, string region)
    {
        var parcel = new Parcel(_numbers.Next(), receiver, region);
        _repository.Add(parcel);
        _audit.Write($"접수 {parcel.TrackingNumber}");
        return _printer.Print(parcel);
    }
}

interface IDashboard
{
    string Describe();
}

sealed class RegionDashboard : IDashboard
{
    private readonly IRequestContext _context;

    public RegionDashboard(IRequestContext context)
    {
        _context = context;
    }

    public string Describe() => $"대시보드가 보는 요청: {_context.RequestId}";
}

줄별 해설

시나리오 1: 수명 비교

BuildContainer(validateScopes: true) 는 등록 일곱 줄을 모아 둔 함수다. 이 함수가 조립 루트 역할을 한다. 서비스 타입과 구현 타입, 수명이 여기에만 나오고 OrderService 안에는 new 가 없다.

스코프 A 에서 먼저 IRequestContext 를 꺼내 요청 번호를 넣는다. 이어서 OrderService 를 해결하면 컨테이너가 생성자 매개변수 네 개를 차례로 해결한다. 저장소와 번호 발급기는 루트의 Singleton, 감사 로그는 스코프의 Scoped 이고, 이때 감사 로그가 받는 컨텍스트는 방금 요청 번호를 넣은 그 객체다. 라벨 출력기는 Transient 라서 이때 1번이 새로 만들어진다.

Accept 를 두 번 호출하면 두 줄 모두 "라벨#1" 이 나온다. OrderService 하나가 출력기 하나를 들고 있기 때문이다. 이어지는 두 줄은 같은 스코프에서 IAuditLog 를 두 번 꺼내면 같은 객체이고, ILabelPrinter 를 두 번 꺼내면 다른 객체라는 것을 확인한다. 이 과정에서 출력기 2번과 3번이 만들어진다.

스코프 A 의 닫는 중괄호에서 Dispose 가 호출되고, 스코프가 추적하던 AuditLog 가 해제되면서 해제 메시지가 나온다. 메시지는 "스코프 A 를 닫는다" 다음에 나온다.

스코프 B 는 새 OrderService 와 새 출력기(4번)를 만든다. 송장 번호는 1003 으로 이어지고 저장소 누적 건수는 3 이다. 번호 발급기와 저장소가 Singleton 이라 스코프가 바뀌어도 상태가 유지된다는 뜻이다.

시나리오 2: 스코프 검증

루트에서 IAuditLog 를 요청하면 스코프가 아니라서 거부된다. 두 번째 시도는 스코프를 만들어 IDashboard 를 요청한다. IDashboard 는 Singleton 이고, 생성자가 IRequestContext(Scoped)를 받는다. 해결 경로 chain 에 Singleton 이 있는 상태에서 Scoped 를 요청하므로 거부된다.

시나리오 3: 검증을 끈 경우

검증을 끄면 IDashboard 가 만들어진다. Singleton 은 루트 스코프에서 만들어지므로 대시보드는 루트 스코프에 새로 생긴 컨텍스트를 받는다. 이 컨텍스트에는 아무도 요청 번호를 넣지 않았으므로 기본값 "(없음)" 이 나온다. 각 요청 스코프에서 번호를 넣은 컨텍스트와는 다른 객체다.

컨테이너 구현

_registry 는 서비스 타입을 키로 구현 타입과 수명을 보관한다. Add 는 where TImplementation : TService 제약으로 "서비스 타입에 할당할 수 없는 구현"을 컴파일 시점에 막는다. 제네릭 제약은 앞서 제네릭을 깊이 다룬 장에서 본 그대로다.

Resolve(Type, Scope, List<Type>) 는 미등록 검사, 순환 검사, 스코프 검증을 차례로 한 다음 수명에 따라 분기한다. Singleton 은 _singletons, Scoped 는 스코프의 Instances 에 캐시한다. Transient 는 캐시하지 않고 해제 추적만 한다.

Create 는 생성자를 고르고 매개변수를 재귀로 해결한 뒤 Invoke 로 호출한다. 해결 중인 타입을 chain 에 넣었다 빼는 것이 순환 검사와 캡처 검사의 근거다. 의존 객체가 먼저 만들어져 추적 목록에 먼저 들어가므로, Dispose 에서 목록을 뒤에서부터 돌면 의존하는 쪽이 먼저 해제된다.

실행 결과

$ dotnet run
== 1. 수명 비교 ==
라벨#1 TRK-1001 김하늘/서울
라벨#1 TRK-1002 박바다/부산
같은 스코프의 IAuditLog 는 동일 인스턴스: True
Transient 는 꺼낼 때마다 새 인스턴스: True
  [REQ-A] 접수 TRK-1001
  [REQ-A] 접수 TRK-1002
스코프 A 를 닫는다
  AuditLog 해제 (2줄, 요청 REQ-A)
라벨#4 TRK-1003 이서준/대전
저장소 누적 건수: 3
스코프 B 를 닫는다
  AuditLog 해제 (1줄, 요청 REQ-B)

== 2. 스코프 검증 ==
  루트에서 IAuditLog: 거부 - 루트에서 Scoped 서비스 IAuditLog 를 꺼낼 수 없다
  Singleton 이 Scoped 를 주입: 거부 - Singleton IDashboard 가 Scoped 서비스 IRequestContext 를 붙잡는다

== 3. 검증을 끈 경우 ==
  현재 요청 REQ-C / 대시보드가 보는 요청: (없음)
  현재 요청 REQ-D / 대시보드가 보는 요청: (없음)

실무에서 자주 틀리는 것

Singleton 이 요청 단위 객체를 생성자로 받는다

틀린 코드는 앞의 RegionDashboard 처럼 Singleton 이 IRequestContext 를 필드로 들고 있는 형태다.

sealed class RegionDashboard : IDashboard
{
    private readonly IRequestContext _context;
    public RegionDashboard(IRequestContext context) { _context = context; }
    public string Describe() => $"대시보드가 보는 요청: {_context.RequestId}";
}

고친 코드는 요청 단위 값을 생성자가 아니라 호출 때 인자로 받는다. 대시보드는 상태가 없으므로 Singleton 으로 남아도 안전하다.

sealed class RegionDashboard : IDashboard
{
    public string Describe(IRequestContext context) => $"대시보드가 보는 요청: {context.RequestId}";
}

수명이 긴 객체가 요청 단위 객체를 자주 써야 한다면 수명을 짧게 바꾸는 것도 방법이다. 스코프를 새로 열어 그 안에서 꺼내 쓰는 방식도 있다. Generic Host 에서는 IServiceScopeFactory 가 그 용도다.

서비스 안에서 컨테이너를 직접 호출한다

컨테이너를 생성자로 받고 필요할 때 꺼내 쓰면 동작은 하지만 의존성이 시그니처에서 사라진다.

sealed class OrderService
{
    private readonly MiniContainer.Scope _scope;
    public OrderService(MiniContainer.Scope scope) { _scope = scope; }

    public void Accept()
    {
        var repository = _scope.Resolve<IParcelRepository>();
        ...
    }
}

이런 형태를 서비스 로케이터(service locator)라고 한다. 생성자만 봐서는 OrderService 가 무엇을 필요로 하는지 알 수 없다. 테스트에서도 컨테이너 전체를 준비해야 한다. 고친 코드는 필요한 것을 생성자 매개변수로 선언하는 원래 형태다.

sealed class OrderService
{
    private readonly IParcelRepository _repository;
    public OrderService(IParcelRepository repository) { _repository = repository; }
}

스코프가 닫힌 뒤에 꺼낸 객체를 쓴다

스코프 밖으로 객체 참조를 가져가면 이미 해제된 객체를 쓰게 된다.

IAuditLog audit;
using (var scope = container.CreateScope())
{
    audit = scope.Resolve<IAuditLog>();
}
audit.Write("늦은 기록");

이 예에서는 예외가 나지 않아서 문제가 더 늦게 발견된다. 파일이나 연결을 쥔 객체였다면 닫힌 자원을 쓰는 오류가 난다. 고친 코드는 객체 사용을 스코프 블록 안에서 끝낸다.

using (var scope = container.CreateScope())
{
    var audit = scope.Resolve<IAuditLog>();
    audit.Write("스코프 안의 기록");
}

비동기 작업에서는 스코프를 여는 using 블록이 await 까지 포함하도록 주의해야 한다. 작업이 끝나기 전에 스코프가 닫히면 같은 문제가 생긴다.

한눈에 보기

이 장에서 다룬 개념 요약
개념핵심코드에서 확인하는 곳
생성자 주입필요한 객체를 생성자 매개변수로 선언한다OrderService 생성자
조립 루트등록은 시작 지점 한곳에서 한다BuildContainer
Singleton컨테이너 전체에서 하나저장소 건수가 스코프를 넘어 누적됨
Scoped스코프마다 하나, 스코프 종료 때 해제AuditLog 해제 메시지
Transient꺼낼 때마다 새로 생성라벨 번호 1, 2, 3, 4
캡처된 의존성긴 수명이 짧은 수명을 붙잡는 문제시나리오 2, 3
Generic Host같은 모델에 팩토리·키·비동기 해제·빌드 검증을 더함비교 표

연습 문제

  1. 새 프로세스에서 OrderService 를 Lifetime.Transient 로 등록했다. 스코프 하나에서 Resolve<OrderService>() 를 두 번 호출해 각각 Accept("가", "서울"), Accept("나", "부산") 을 호출한다. 출력되는 두 줄을 쓰고, 감사 로그가 몇 개인지 답하라.
  2. ServiceA 의 생성자가 ServiceB 를, ServiceB 의 생성자가 ServiceA 를 받는다. 둘 다 자기 타입으로 등록했을 때 Resolve<ServiceA>() 가 던지는 예외 메시지를 쓰라.
  3. 다음 세 클래스의 적절한 수명을 고르고 이유를 한 줄로 써라. (가) 지역별 배송 요금표를 시작할 때 한 번 읽어 두는 RateTable. (나) 요청 하나 안에서 여러 서비스가 함께 쓰는 RequestContext. (다) 내부 상태 없이 문자열만 만드는 LabelFormatter.
  4. 완성 코드의 RegionDashboard 를 "캡처된 의존성" 없이 고치려고 한다. 생성자에서 IRequestContext 를 받는 대신 Describe 가 컨텍스트를 인자로 받게 바꿨다. 시나리오 3 의 출력 한 줄이 어떻게 달라지는지 쓰고, 시나리오 2 의 두 번째 줄은 어떻게 되는지 답하라.

정답과 해설

  1. 출력은 라벨#1 TRK-1001 가/서울 과 라벨#2 TRK-1002 나/부산 이다. OrderService 를 꺼낼 때마다 새로 만들어지고, 그때마다 Transient 출력기도 새로 만들어지므로 라벨 번호가 1, 2 로 나뉜다. 번호 발급기는 Singleton 이라 송장 번호는 이어진다. 감사 로그는 Scoped 라서 같은 스코프 안에서는 하나이고, 두 서비스가 같은 로그에 기록한다.
  2. 순환 의존: ServiceA -> ServiceB -> ServiceA 이다. A 를 만드는 중 chain 이 [A], B 를 만드는 중 [A, B] 이고, 여기서 다시 A 를 요청하면 chain 에 이미 있으므로 경로에 A 를 붙여 메시지를 만든다. 순환은 생성자 주입으로는 풀 수 없으므로 책임을 나누어 한쪽 의존을 없애야 한다.
  3. (가) Singleton 이다. 읽기 전용이고 모든 요청이 같은 값을 쓴다. (나) Scoped 이다. 요청 안에서는 공유하되 요청 사이에서는 분리해야 한다. (다) Transient 나 Singleton 모두 가능하다. 상태가 없으면 공유해도 문제가 없으므로 Singleton 이 할당이 적지만, 나중에 상태가 생길 가능성이 있으면 Transient 로 두는 편이 안전하다.
  4. 시나리오 3 의 각 줄이 현재 요청 REQ-C / ... 뒤에 요청 번호를 그대로 보여 주려면 호출부가 Describe(scope.Resolve<IRequestContext>()) 처럼 현재 스코프의 컨텍스트를 넘겨야 한다. 그러면 REQ-C, REQ-D 가 각각 나온다. 시나리오 2 의 두 번째 줄은 거부 대신 성공 이 된다. 대시보드가 더 이상 Scoped 서비스에 의존하지 않으므로 검증이 막을 이유가 없다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.