Spring 코드를 보면 @RequiredArgsConstructor와 private final 필드가 거의 모든 클래스에 붙어 있다. 그리고 “의존성 주입을 받는다”는 말을 자주 듣는다. 의존성 주입이 무엇이고 왜 이렇게 쓰는지, Spring 없이 순수 Java 코드부터 정리한다.
의존성이란
A가 일하려면 B가 꼭 있어야 할 때 A는 B에 의존한다고 한다.
비즈니스 로직 글에서 만든 OrderService를 다시 보자. 주문을 처리하려면 VIP 할인을 계산해야 하고 주문을 저장해야 한다.
OrderService ──▶ DiscountPolicy (할인 계산)
└─▶ OrderRepository (주문 저장)
OrderService 입장에서 DiscountPolicy와 OrderRepository가 **의존성(dependency)**이다. 의존성 자체는 나쁜 게 아니다. 객체끼리 협력하려면 의존 관계는 당연히 생긴다. 문제는 의존하는 객체를 누가 만드느냐다.
직접 만들면 생기는 문제
가장 먼저 떠오르는 방법은 필요한 객체를 클래스 안에서 직접 new로 만드는 것이다.
public class OrderService {
private final DiscountPolicy discountPolicy = new RateDiscountPolicy(); // VIP 10% 할인
private final OrderRepository orderRepository = new JpaOrderRepository();
public Order placeOrder(Member member, Product product, int quantity) {
int price = product.getPrice() * quantity;
int discount = discountPolicy.discount(member, price);
return orderRepository.save(new Order(member, product, quantity, price - discount));
}
}
잘 동작하지만 두 가지 상황에서 막힌다.
1. 정책을 바꾸려면 OrderService를 고쳐야 한다
기획자가 “VIP 할인을 10% 대신 3,000원 정액으로 바꿔 주세요”라고 요청했다고 하자.
private final DiscountPolicy discountPolicy = new FixedDiscountPolicy(); // 이 줄을 고쳐야 함
할인 정책만 바뀌었는데 주문 흐름을 담당하는 OrderService를 열어 고쳐야 한다. DiscountPolicy를 쓰는 클래스가 열 개라면 열 곳을 모두 고쳐야 한다.
2. 테스트에서 가짜로 바꿀 수 없다
OrderService의 할인 계산만 테스트하고 싶어도 new JpaOrderRepository()가 클래스 안에 박혀 있다. 테스트를 실행하면 실제 DB 연결이 필요해진다. 가짜 저장소(Mock)로 바꿔 끼울 틈이 없다.
두 문제의 원인은 같다. OrderService가 “무엇을 쓸지”까지 직접 결정하고 있기 때문이다.
의존성 주입: 밖에서 넣어 준다
의존성 주입(Dependency Injection, DI)은 필요한 객체를 직접 만들지 않고 외부에서 받아 쓰는 방식이다.
public class OrderService {
private final DiscountPolicy discountPolicy;
private final OrderRepository orderRepository;
// 필요한 것을 생성자로 받는다
public OrderService(DiscountPolicy discountPolicy, OrderRepository orderRepository) {
this.discountPolicy = discountPolicy;
this.orderRepository = orderRepository;
}
public Order placeOrder(Member member, Product product, int quantity) {
int price = product.getPrice() * quantity;
int discount = discountPolicy.discount(member, price);
return orderRepository.save(new Order(member, product, quantity, price - discount));
}
}
new가 사라졌다. OrderService는 이제 “할인 정책 하나와 저장소 하나가 필요하다”고만 선언하고, 어떤 구현을 쓸지는 바깥에서 정한다.
// 운영 코드: 조립하는 곳이 따로 있다
OrderService orderService = new OrderService(
new RateDiscountPolicy(),
new JpaOrderRepository()
);
이제 앞의 두 문제가 해결된다.
- 정책 변경: 조립하는 한 줄만
new FixedDiscountPolicy()로 바꾸면 된다.OrderService코드는 그대로다. - 테스트: 테스트에서는 가짜 객체를 넣으면 된다.
@Test
void VIP_회원은_10퍼센트_할인된다() {
OrderRepository fakeRepository = mock(OrderRepository.class);
given(fakeRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
OrderService orderService = new OrderService(new RateDiscountPolicy(), fakeRepository);
Member vip = new Member("kim", true);
Product product = new Product("키보드", 30_000, 10);
Order order = orderService.placeOrder(vip, product, 2);
assertThat(order.getTotalPrice()).isEqualTo(54_000);
}
DB도, Spring도 없이 순수 Java만으로 테스트가 돈다. 여기까지가 의존성 주입의 전부다. 어려운 기술이 아니라 “생성자 파라미터로 받자”는 습관에 가깝다.
인터페이스와 함께 쓰는 이유
위 예제에서 DiscountPolicy는 인터페이스다.
public interface DiscountPolicy {
int discount(Member member, int price);
}
public class RateDiscountPolicy implements DiscountPolicy {
public int discount(Member member, int price) {
return member.isVip() ? price * 10 / 100 : 0;
}
}
public class FixedDiscountPolicy implements DiscountPolicy {
public int discount(Member member, int price) {
return member.isVip() ? 3_000 : 0;
}
}
OrderService가 구체 클래스(RateDiscountPolicy)가 아니라 인터페이스(DiscountPolicy)에 의존하기 때문에 구현을 자유롭게 갈아 끼울 수 있다. 이 원칙을 **의존 역전 원칙(DIP, Dependency Inversion Principle)**이라고 부른다.
| 개념 | 무엇을 말하나 |
|---|---|
| DIP (의존 역전 원칙) | 구체 클래스가 아니라 추상(인터페이스)에 의존하라는 설계 원칙 |
| DI (의존성 주입) | 의존하는 객체를 밖에서 넣어 주는 구현 방법 |
DIP로 “인터페이스에 의존”하게 만들고, DI로 “실제 구현을 밖에서 넣어 주는” 식으로 둘이 짝을 이룬다.
주입하는 세 가지 방법
의존성을 넣어 주는 통로는 크게 세 가지다.
생성자 주입
@Service
public class OrderService {
private final DiscountPolicy discountPolicy;
public OrderService(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
세터 주입
@Service
public class OrderService {
private DiscountPolicy discountPolicy;
@Autowired
public void setDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
필드 주입
@Service
public class OrderService {
@Autowired
private DiscountPolicy discountPolicy;
}
필드 주입이 가장 짧아서 예전 코드에 많이 보이지만, 지금은 생성자 주입이 사실상 표준이다.
| 방식 | final 가능 |
누락 시 | Spring 없이 테스트 | 쓰는 경우 |
|---|---|---|---|---|
| 생성자 주입 | O | 컴파일 에러 | 쉬움 (new로 생성) |
기본값 |
| 세터 주입 | X | 실행 중 NullPointerException |
가능 | 선택적이거나 나중에 바꿔야 하는 의존성 |
| 필드 주입 | X | 실행 중 NullPointerException |
어려움 (리플렉션 필요) | 테스트 코드 정도 |
생성자 주입을 권장하는 이유를 정리하면 다음과 같다.
- 불변:
final로 선언할 수 있어 생성 후 다른 객체로 바뀔 일이 없다. - 누락 방지: 필요한 의존성을 빼먹으면 객체를 만들 수조차 없다.
- 테스트 용이: 앞의 테스트처럼
new OrderService(...)로 바로 만들 수 있다. - 설계 신호: 생성자 파라미터가 7~8개로 늘어나면 “이 클래스가 일을 너무 많이 한다”는 신호가 눈에 바로 보인다.
Spring에서는 생성자가 하나뿐이면 @Autowired를 생략해도 되고(Spring 4.3부터), Lombok의 @RequiredArgsConstructor를 쓰면 final 필드로 생성자를 자동으로 만들어 준다. Spring 코드에서 흔히 보이는 아래 형태가 바로 생성자 주입이다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final DiscountPolicy discountPolicy;
private final OrderRepository orderRepository;
}
누가 조립하나: DI 컨테이너
생성자로 받기로 했다면 누군가는 new를 해서 넣어 줘야 한다. 클래스가 몇 개 안 되면 main에서 직접 조립해도 된다.
public class App {
public static void main(String[] args) {
DiscountPolicy discountPolicy = new RateDiscountPolicy();
OrderRepository orderRepository = new JpaOrderRepository();
OrderService orderService = new OrderService(discountPolicy, orderRepository);
OrderController orderController = new OrderController(orderService);
// ...
}
}
클래스가 수백 개가 되면 이 조립 코드를 직접 관리하기 어렵다. 이 일을 대신 해 주는 것이 DI 컨테이너이고, Spring에서는 ApplicationContext가 그 역할을 한다.
@Component,@Service,@Repository등이 붙은 클래스를 찾아 객체(빈, Bean)로 등록한다.- 각 빈의 생성자를 보고 필요한 타입의 빈을 찾아 넣어 준다.
- 만든 빈은 기본적으로 하나만 만들어(싱글톤) 여러 곳에서 공유한다.
@Component
public class RateDiscountPolicy implements DiscountPolicy { ... }
@Service
@RequiredArgsConstructor
public class OrderService {
private final DiscountPolicy discountPolicy; // Spring이 RateDiscountPolicy를 찾아 넣어 준다
}
할인 정책을 바꾸고 싶으면 RateDiscountPolicy의 @Component를 빼고 FixedDiscountPolicy에 붙이면 된다. OrderService는 손대지 않는다.
같은 타입이 여러 개면
DiscountPolicy 구현체 두 개에 모두 @Component를 붙이면 Spring은 무엇을 넣을지 몰라 시작할 때 에러를 낸다.
NoUniqueBeanDefinitionException: No qualifying bean of type 'DiscountPolicy' available:
expected single matching bean but found 2: fixedDiscountPolicy,rateDiscountPolicy
이때는 기본으로 쓸 쪽에 @Primary를 붙이거나, 받는 쪽에서 @Qualifier로 이름을 지정한다.
@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy { ... }
IoC와의 관계
의존성 주입을 설명할 때 **제어의 역전(IoC, Inversion of Control)**이라는 말도 같이 나온다.
- 직접 생성할 때는
OrderService가 무엇을 쓸지 스스로 결정했다. - 주입을 받으면 그 결정권이 바깥(조립 코드나 컨테이너)으로 넘어간다.
이렇게 제어 흐름의 주도권이 내 코드에서 외부로 넘어가는 것을 IoC라고 한다. IoC는 더 넓은 개념이라 프레임워크가 내 메서드를 호출해 주는 것(Controller 메서드를 내가 직접 호출하지 않는 것)도 IoC다. DI는 IoC를 “의존 객체 생성”에 적용한 한 가지 방식이다. Martin Fowler가 “IoC는 너무 넓은 말이라 이 패턴에는 Dependency Injection이라는 이름을 붙이자”고 제안한 것이 DI라는 용어의 시작이다.
흔한 오해
“DI는 Spring 기능이다”
아니다. 앞에서 본 것처럼 생성자로 받기만 하면 DI다. Spring은 그 조립을 자동으로 해 주는 도구일 뿐이다. 다른 프레임워크도 대부분 DI 컨테이너를 갖고 있다.
| 프레임워크 | DI 방식 |
|---|---|
| Spring (Java) | ApplicationContext, 생성자 주입 |
| Laravel (PHP) | Service Container, 생성자 타입 힌트로 자동 주입 |
| NestJS (TypeScript) | @Injectable() 프로바이더, 생성자 주입 |
| ASP.NET Core (C#) | 내장 IServiceCollection, 생성자 주입 |
“모든 것에 인터페이스를 만들어야 한다”
구현이 하나뿐이고 바뀔 일도 없다면 구체 클래스를 그대로 주입받아도 된다. 생성자로 받기만 해도 테스트에서 Mock으로 바꿀 수 있다(Mockito는 클래스도 Mock으로 만들 수 있다). 인터페이스는 실제로 구현을 갈아 끼울 일이 생길 때 만들어도 늦지 않다. DiscountPolicy처럼 정책이 바뀔 것이 예상되는 곳이 대표적이다.
“컨테이너에서 꺼내 쓰면 DI다”
public class OrderService {
private final DiscountPolicy discountPolicy =
applicationContext.getBean(DiscountPolicy.class); // 직접 꺼내 옴
}
컨테이너를 쓰긴 했지만 OrderService가 스스로 찾아 오고 있다. 이런 방식은 서비스 로케이터(Service Locator) 패턴이라고 부르며 DI와 구분한다. 이 클래스는 테스트할 때도 컨테이너가 있어야 하고, 생성자만 봐서는 무엇에 의존하는지 알 수 없다. 받아 쓰는 것이 DI의 핵심이다.
정리
- 의존성은 어떤 객체가 일하는 데 필요한 다른 객체다.
- 의존 객체를 클래스 안에서 직접
new로 만들면, 구현을 바꿀 때마다 그 클래스를 고쳐야 하고 테스트에서 가짜로 바꿀 수 없다. - 의존성 주입은 필요한 객체를 밖에서 받아 쓰는 방식이다. 핵심은 “생성자 파라미터로 받자”다.
- 주입 방법 중에는
final, 누락 방지, 테스트 용이성 때문에 생성자 주입이 기본이다. - Spring 같은 DI 컨테이너는 그 조립을 자동으로 해 줄 뿐이고, DI 자체는 프레임워크 없이도 쓸 수 있다.