
의존성 주입은 많이 들어봤는데 막상 설명하려고 하면 금방 꼬입니다. 어떤 사람은 스프링 기능이라고 하고, 어떤 사람은 테스트 쉽게 만드는 기술이라고 합니다. 둘 다 완전히 틀린 말은 아니지만, 이 설명만 붙잡으면 DI 자체보다 도구와 효과를 먼저 배우게 됩니다. 이 글에서는 객체 생성과 의존성 전달을 분리하는 방식이라는 기준으로 DI, DI 컨테이너, 서비스 로케이터를 한 번에 정리해보겠습니다.
왜 헷갈릴까
DI가 자꾸 헷갈리는 이유는 보통 세 가지입니다. 많은 사람이 프레임워크 문법으로 먼저 배우고, DI와 DI 컨테이너를 같은 개념처럼 받아들이고, 서비스 로케이터도 비슷하게 바깥에서 무언가를 가져다 쓰는 구조처럼 보기 때문입니다. 하지만 핵심 질문은 단순합니다. 누가 의존성을 만들고, 누가 전달하고, 누가 직접 찾는가입니다.

의존성부터 보자
의존성은 이 객체가 일을 하려면 다른 객체가 필요하다는 뜻입니다. 예를 들어 주문 완료 후 결제 승인과 알림 발송이 필요하다면, 주문 서비스는 혼자 일할 수 없습니다. 결제 처리기와 알림 발송기라는 협력 객체가 필요합니다. 문제는 여기서부터 시작합니다. 주문 서비스가 그 협력 객체를 직접 만들 것인가, 아니면 밖에서 받을 것인가가 설계 차이를 만듭니다.
class PaymentGateway {
public void approve(int amount) {
System.out.println("pay: " + amount);
}
}
class EmailNotifier {
public void send(String message) {
System.out.println("email: " + message);
}
}생성과 사용이 한곳에 섞인 코드
가장 흔한 출발점은 사용하는 객체가 필요한 협력 객체를 직접 만드는 방식입니다. 작은 프로그램에서는 자연스럽고 빠릅니다. 하지만 이 순간부터 업무 로직과 조립 책임이 한 클래스 안에 같이 들어오게 됩니다.
class OrderService {
private final PaymentGateway paymentGateway = new PaymentGateway();
private final EmailNotifier emailNotifier = new EmailNotifier();
public void placeOrder(int amount) {
paymentGateway.approve(amount);
emailNotifier.send("주문이 완료되었습니다.");
}
}이 구조에서는 OrderService가 주문 처리만 하는 것이 아니라, 어떤 구현체를 쓸지와 어떤 객체를 연결할지도 같이 책임집니다. 구현 교체가 생기면 서비스 본문까지 흔들릴 가능성이 커지고, 테스트에서도 진짜 결제나 진짜 알림 구현과 더 강하게 묶이기 쉽습니다.
의존성 주입의 핵심
DI에서는 사용하는 객체가 필요한 의존성을 직접 만들지 않습니다. 외부에서 넣어줍니다. 그래서 서비스는 협력 객체의 구체 구현보다 자신이 필요한 역할에 더 집중할 수 있습니다.
interface PaymentGateway {
void approve(int amount);
}
interface Notifier {
void send(String message);
}
class KakaoPayGateway implements PaymentGateway {
@Override
public void approve(int amount) {
System.out.println("pay: " + amount);
}
}
class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("email: " + message);
}
}
class OrderService {
private final PaymentGateway paymentGateway;
private final Notifier notifier;
public OrderService(PaymentGateway paymentGateway, Notifier notifier) {
this.paymentGateway = paymentGateway;
this.notifier = notifier;
}
public void placeOrder(int amount) {
paymentGateway.approve(amount);
notifier.send("주문이 완료되었습니다.");
}
}중요한 점은 OrderService가 이제 new KakaoPayGateway()를 모른다는 것입니다. 자신이 어떤 역할을 요구하는지만 드러냅니다. DI는 new를 없애는 기술이 아니라 생성 책임을 바깥으로 밀어내는 설계 선택입니다.
DI와 DI 컨테이너는 다르다
컨테이너가 없어도 DI는 됩니다. 메인 함수나 조립용 객체에서 직접 연결해도 이미 DI입니다. 이 지점을 놓치면 DI를 프레임워크 기능으로만 오해하게 됩니다.
public class Main {
public static void main(String[] args) {
PaymentGateway paymentGateway = new KakaoPayGateway();
Notifier notifier = new EmailNotifier();
OrderService orderService = new OrderService(paymentGateway, notifier);
orderService.placeOrder(15000);
}
}이 예시에서 조립자 역할은 Main이 맡습니다. 스프링이나 .NET의 DI 컨테이너는 이 조립 작업이 커졌을 때 등록, 생성, 연결을 자동화해 주는 도구입니다. 즉, DI는 방식이고 컨테이너는 그 방식을 편하게 운영하는 도구입니다.
참고: Spring Framework Reference – Dependency Injection, Microsoft Learn – Dependency injection in .NET
서비스 로케이터는 무엇이 다를까
서비스 로케이터 방식에서는 객체가 의존성을 선언적으로 받기보다, 로케이터에게 가서 직접 꺼내 옵니다. 둘 다 직접 구현체를 붙잡지 않는 효과는 있을 수 있지만, 의존성을 누가 찾는가가 다릅니다.
class ServiceLocator {
public static PaymentGateway paymentGateway() {
return new KakaoPayGateway();
}
public static Notifier notifier() {
return new EmailNotifier();
}
}
class OrderService {
private final PaymentGateway paymentGateway;
private final Notifier notifier;
public OrderService() {
this.paymentGateway = ServiceLocator.paymentGateway();
this.notifier = ServiceLocator.notifier();
}
public void placeOrder(int amount) {
paymentGateway.approve(amount);
notifier.send("주문이 완료되었습니다.");
}
}이 구조에서는 생성자만 봐서는 OrderService가 무엇을 필요로 하는지 잘 드러나지 않습니다. 내부 구현을 열어봐야 알 수 있습니다. 반면 DI 코드에서는 생성자 시그니처만 봐도 필요한 협력 객체가 보입니다. 이 차이는 읽기, 테스트 세팅, 변경 추적에서 꽤 크게 작용합니다.
참고: Martin Fowler – Inversion of Control Containers and the Dependency Injection pattern
테스트가 쉬워지는 이유
DI를 쓰면 테스트가 쉬워진다는 말은 단순히 mock 프레임워크를 쓰기 편하다는 뜻이 아닙니다. 더 중요한 것은 핵심 로직을 바깥 기술에서 분리해 검증할 수 있다는 점입니다. 외부 결제와 알림 대신 가짜 객체를 넣으면 비즈니스 규칙만 가볍게 확인할 수 있습니다.
class FakePaymentGateway implements PaymentGateway {
private int approvedAmount;
@Override
public void approve(int amount) {
this.approvedAmount = amount;
}
public int getApprovedAmount() {
return approvedAmount;
}
}
class FakeNotifier implements Notifier {
private String lastMessage;
@Override
public void send(String message) {
this.lastMessage = message;
}
public String getLastMessage() {
return lastMessage;
}
}OrderService가 협력 객체를 직접 만들지 않기 때문에 이런 테스트 대역이 자연스럽게 들어갑니다. 그래서 테스트 이점은 DI의 부가 효과가 아니라, 생성 책임 분리에서 따라오는 직접적인 결과라고 보는 편이 정확합니다.
생성자 주입이 기본값인 이유
생성자 주입이 자주 권장되는 이유는 필수 의존성이 가장 잘 드러나기 때문입니다. 객체가 살아가기 위해 꼭 필요한 것이 생성자에 모이면, 빠뜨리기 어렵고 코드 읽기도 쉬워집니다. 세터 주입이 완전히 틀렸다는 뜻은 아니지만, 필수 협력 관계를 선명하게 보여준다는 점에서 생성자 주입이 기본값이 되기 쉽습니다.
언제 DI가 정말 도움이 될까
- 외부 시스템 연동처럼 구현 교체 가능성이 있다
- 테스트에서 가짜 객체로 바꾸는 이점이 크다
- 도메인 로직과 인프라 조립 책임을 분리할 가치가 있다
- 바뀌는 축이 분명해서 조립 지점을 좁게 모으는 편이 유리하다
- 객체 그래프가 커져서 수동 조립 관리 비용이 높아지고 있다
이런 경우라면 DI는 꽤 큰 이득을 줍니다. 결제, 저장소, 알림, 외부 API, 로깅처럼 기술 경계가 선명한 영역에서는 특히 효과가 큽니다.
언제 과해질까
반대로 모든 클래스에 인터페이스를 붙이고, 구현체가 하나뿐인데도 무조건 주입 대상으로 만들면 구조가 금방 무거워집니다. 작은 계산기나 단순한 내부 헬퍼처럼 바뀔 가능성이 거의 없고 테스트 대역도 의미 없는 코드라면 굳이 분리하지 않아도 됩니다.
class PriceCalculator {
public int total(int price, int quantity) {
return price * quantity;
}
}이런 코드까지 PriceCalculator, PriceCalculatorImpl, 설정 클래스, 등록 코드로 부풀리면 오히려 읽는 비용만 커질 수 있습니다. 중요한 것은 DI를 썼다는 사실이 아니라, 그 분리가 실제로 변경 비용과 테스트 비용을 낮추는가입니다.
실무에서 기억할 기준
DI는 객체가 의존성을 직접 만들거나 찾지 않고 외부에서 받는 방식입니다. DI 컨테이너는 그 조립을 자동화하는 도구입니다. 서비스 로케이터는 객체가 필요한 의존성을 로케이터에서 직접 찾는 방식입니다. 이 셋을 섞지 않고 보면 개념이 꽤 깔끔해집니다.
업무 객체가 협력 객체를 직접 만들거나 찾아야 하는가, 아니면 그 책임을 바깥 조립 지점으로 밀어내는 편이 더 나은가라는 질문으로 돌아오면 대부분의 혼란은 정리됩니다.
같이 읽으면 좋은 글은 객체지향 설계에서 결합도가 중요한 이유, 인터페이스는 왜 필요할까, 객체지향에서 책임을 잘 나누는 기준, SOLID 원칙, 실무에서는 어떻게 봐야 할까입니다.