의존성 주입
알림을 보내는 코드가 메일 전송기를 직접 만들면, 메일 대신 문자 메시지를 보내고 싶을 때 알림 코드까지 고치게 된다. 전송기를 바깥에서 만들어 전달하면 알림 코드는 같은 호출 약속을 사용하는 채로 남고, 어떤 전송기를 연결할지는 프로그램을 조립하는 쪽에서 정할 수 있다. 필요한 협력 객체를 외부에서 연결하는 이 방법을 의존성 주입(dependency injection, DI)이라고 한다.1
연결 방법만 정해서는 충분하지 않다. 같은 전송기를 여러 요청이 공유할지, 요청마다 새로 만들지, 연결을 누가 닫을지도 정해야 한다. 의존성 주입과 객체 수명은 서로 다른 질문이지만, 실제 프로그램을 조립할 때 함께 만나게 된다.
프레임워크의 호출 관계에서 객체 조립으로
Johnson과 Foote의 1988년 Designing Reusable Classes는 프레임워크가 큰 실행 흐름을 맡고 애플리케이션이 제공한 메서드를 호출하는 관계를 제어의 역전(inversion of control, IoC)이라고 설명했다. 라이브러리를 호출하는 프로그램과, 프로그램의 일부를 호출하는 프레임워크를 구별한 것이다.2
1996년 Robert C. Martin의 의존성 역전 원칙(dependency inversion principle, DIP)은 소스 코드의 의존 방향을 다룬다. 업무 정책과 구체 구현이 추상화를 향하도록 설계해, 하위 구현의 변경 때문에 상위 정책이 함께 바뀌는 일을 줄이려는 원칙이다. 생성자에서 객체를 받더라도 매개변수 타입이 구체적인 메일 전송기라면, 정책 코드에는 여전히 그 구현에 대한 의존성이 남는다.3
Martin Fowler는 2004년 1월 23일 글에서 당시 Java의 경량 컨테이너들이 하던 객체 연결을 살펴보고, 넓게 쓰이던 IoC라는 이름 대신 DI라는 구체적인 이름으로 설명했다. 글에는 생성자·setter·인터페이스를 통한 주입과 Service Locator의 차이가 함께 나온다. 이 날짜는 해당 글의 발표 시점이며, 협력 객체를 바깥에서 전달하는 기법 전체의 발명일로 삼을 수는 없다.1
세 용어가 묻는 내용은 조금씩 다르다. IoC는 누가 실행 흐름을 호출하는지, DI는 누가 협력 객체를 연결하는지, DIP는 정책과 구현 사이의 소스 의존성이 어디를 향하는지에 초점을 둔다.
왜 전송기를 바깥에서 전달하는가
다음 예제는 알림 문장을 만드는 일과 전송기를 고르는 일을 나눈다. 실제 메일 서버 대신 문자열을 반환하는 전송기를 사용하므로, 네트워크 없이 어떤 구현이 호출됐는지 확인할 수 있다.
type Sender = { send(message: string): string };
function createNotifier(sender: Sender) {
return {
notify(name: string) {
return sender.send(`Hello, ${name}`);
},
};
}
const mail: Sender = { send: message => `mail:${message}` };
const sms: Sender = { send: message => `sms:${message}` };
console.log(createNotifier(mail).notify('Ada'));
console.log(createNotifier(sms).notify('Ada'));
출력은 mail:Hello, Ada와 sms:Hello, Ada다. createNotifier는 어떤 전송기를 만들지 결정하지 않고, 전달받은 send를 호출한다. 이 정도의 조립에는 컨테이너가 필요 없다. 호출자가 Sender를 고르고, 알림 코드는 그 약속만 사용한다.
관계는 조립 코드 → createNotifier(sender) → sender.send(message)다. 알림 코드가 객체를 어디서 얻을지 매번 전역 저장소에 물으면 알림 코드 안에 조회 책임이 남는데, 이 점이 필요한 객체를 직접 전달받는 방식과 Service Locator를 구별한다.1
같은 객체를 얼마나 오래 공유할까
.NET DI에서 transient는 컨테이너에 요청할 때마다 생성하고, scoped는 같은 범위 안에서 공유하며, singleton은 해당 컨테이너의 수명 동안 공유한다. ASP.NET Core는 보통 요청마다 범위를 만들지만, 백그라운드 작업도 범위를 명시적으로 만들 수 있다.4
using System;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<RequestContext>();
using var provider = services.BuildServiceProvider();
using var scopeA = provider.CreateScope();
using var scopeB = provider.CreateScope();
var first = scopeA.ServiceProvider.GetRequiredService<RequestContext>();
var again = scopeA.ServiceProvider.GetRequiredService<RequestContext>();
var other = scopeB.ServiceProvider.GetRequiredService<RequestContext>();
Console.WriteLine(ReferenceEquals(first, again));
Console.WriteLine(!ReferenceEquals(first, other));
sealed class RequestContext { }
두 줄 모두 True다. 범위 A에서는 같은 객체를 돌려주고 범위 B에서는 다른 객체를 만든다. 반대로 singleton이 짧게 살아야 할 scoped 객체를 직접 붙잡으면, 그 참조가 긴 수명으로 끌려 올라갈 수 있다. 수명이 다른 서비스를 연결할 때에는 무엇을 공유하는지부터 확인한다.
해제 책임은 컨테이너의 계약을 따른다. 예를 들어 Spring의 prototype은 요청마다 생성하지만 컨테이너가 파괴 콜백까지 관리하지 않으며, singleton에 prototype을 직접 주입하면 singleton을 만들 때 얻은 그 객체를 계속 사용한다.5 DI를 썼다는 사실만으로 객체가 매번 생성되거나 자동으로 정리되는 것은 아니다.
예제 환경: TypeScript는 Node.js의 타입 제거 실행 또는 TypeScript 도구로 실행할 수 있다. C# 예제는 .NET 10과 Microsoft.AspNetCore.App의 DI 어셈블리를 사용한다.
함께 읽기
전략 패턴은 바꿔 끼우는 동작을, 어댑터 패턴은 서로 다른 호출 약속을 연결하는 방법을 설명한다. DI는 그렇게 만든 객체를 사용처에 연결한다. 싱글턴 패턴의 유일성 범위와 컨테이너가 정한 singleton 수명도 구별해서 읽으면 좋다.
각주
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern, 2004-01-23, “Inversion of Control”, “Forms of Dependency Injection”, “Separating Configuration from Use”. 저자 원문. ↩
- Ralph E. Johnson, Brian Foote, Designing Reusable Classes, Journal of Object-Oriented Programming 1(2), 1988, pp.22-35, “White-box vs. Black-box Frameworks”. 저자 제공 원문. ↩
- Robert C. Martin, The Dependency Inversion Principle, C++ Report, 1996, PDF p.6 및 Button/Lamp 사례. 원문. ↩
- Microsoft, “Service lifetimes”, .NET DI 공식 문서. 문서. ↩
- Spring Framework, “Bean Scopes”, singleton·prototype 및 singleton-with-prototype 절. 공식 문서. ↩