백엔드 개발, 특히 스프링(Spring) 프레임워크를 학습하다 보면 의존성 주입(Dependency Injection, 이하 DI)이라는 단어를 반드시 마주하게 됩니다. 객체지향 프로그래밍에서 '유연한 설계'를 하기 위해 필수적인 개념입니다.
DI의 핵심 개념과 다양한 주입 방식, 그리고 왜 생성자 주입이 권장되는지에 대해 정리해 보겠습니다.
프로그래밍에서 의존성이란 무엇일까요?
아주 간단하게 말해 "A 객체가 어떤 작업을 수행하기 위해 B 객체를 필요로 하는 상황"을 말합니다.
"A는 B에 의존한다."
코드로 보면 A 클래스 내부에서 B 클래스의 메서드를 호출하거나 사용하고 있는 상태입니다. 이때 가장 흔히 발생하는 문제는 A가 B를 직접 생성(new)할 때 발생합니다.
public class MemberService {
// MemberService가 MemoryRepository를 직접 생성 (의존)
private final MemoryRepository repository = new MemoryRepository();
public void join() {
repository.save();
}
}
위 코드의 문제점은 무엇일까요?
만약 의존하고 있는 객체를 다른 객체로 바꿔야 한다면, 이를 사용하고 있는 A 객체의 코드도 직접 수정해야 합니다. 이를 강한 결합(Tight Coupling)이라고 합니다. 유연성이 떨어지고 유지보수가 힘든 구조입니다.
의존성 주입(DI)은 객체(A)가 의존하는 다른 객체(B)를 직접 생성하지 않고, 외부(C)에서 생성해서 넘겨주는(주입하는) 방식입니다.
이때 '외부의 제3자'는 보통 프레임워크(예: 스프링 컨테이너)가 됩니다. 이를 통해 A 객체는 B가 어떻게 생성되는지 알 필요 없이, 자신의 역할에만 집중할 수 있게 됩니다. 이를 제어의 역전(IoC, Inversion of Control)이라고도 부릅니다.
public class MemberService {
// 구체적인 클래스(MemoryRepository)가 아닌 인터페이스(Repository)에 의존
private final Repository repository;
// 외부에서 생성된 객체를 생성자를 통해 주입받음
public MemberService(Repository repository) {
this.repository = repository;
}
public void join() {
repository.save();
}
}
이제 이 객체는 어떤 구현체가 오든 상관없습니다. 외부에서 무엇을 주입해 주느냐에 따라 동작이 달라집니다. 코드 변경 없이 다양한 실행 구조를 만들 수 있게 된 것이죠.
의존성 주입은 주입을 받는 위치에 따라 크게 세 가지로 나뉩니다.
생성자를 통해 의존성을 주입받는 방식입니다.
public class OrderService {
private final DiscountPolicy discountPolicy;
public OrderService(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
Setter 메서드(수정자)를 통해 의존성을 주입받는 방식입니다.
public class OrderService {
private DiscountPolicy discountPolicy;
public void setDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
메서드 실행 시 인자로 주입받거나, 일반 메서드를 통해 주입받는 방식입니다. 실행할 때마다 의존 대상이 변하는 특수한 경우에 사용되나, 자주 사용되지는 않습니다.
참고:
@Autowired를 필드에 바로 붙이는 필드 주입도 있지만, 외부에서 변경이 불가능해 테스트하기 어렵다는 단점 때문에 최근에는 지양하는 추세입니다.
생성자 주입을 강력하게 권장합니다. 다음과 같은 장점들이 있기 때문입니다.
대부분의 의존 관계는 애플리케이션 종료 시점까지 변하면 안 됩니다.
public으로 열어두어야 하므로, 누군가 실수로 변경할 위험이 있습니다.생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있습니다. 이 덕분에 생성자에서 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 바로 막아줍니다
private final Repository repository;
순수한 자바 코드로 단위 테스트를 작성할 때, 생성자 주입을 사용하면 컴파일러가 필요한 의존성을 알려줍니다.
NullPointerException이 발생할 수 있습니다.A가 B를 참조하고, B가 다시 A를 참조하는 순환 참조가 발생했을 때, 생성자 주입은 서버 구동 시점에 에러를 발생시켜 애플리케이션이 실행되지 않도록 막아줍니다. (문제를 조기에 발견 가능)
의존성 주입(DI)은 객체 간의 결합도를 낮춰 유연하고 변경에 용이한 설계를 가능하게 합니다.
new 하지 말고, 외부에서 주입받자.