의존성 주입(Dependency Injection, DI)은 클래스가 필요한 객체를 직접 만들지 않고 외부에서 받는 방식이다. 언뜻 코드 한두 줄 줄이는 기능처럼 보이지만, 실제 가치는 구현을 교체하고 테스트하기 쉬워진다는 데 있다.
public class CheckoutService {
private final CardPaymentGateway gateway = new CardPaymentGateway();
}
현재 서비스는 카드 결제 구현체를 직접 생성한다. 결제 방식을 바꾸거나, 네트워크 호출 없이 테스트하려면 CheckoutService를 수정하거나 복잡한 우회 방법을 만들어야 한다.
먼저 인터페이스를 도입해 의존성의 방향을 바꿔보자.
public interface PaymentGateway {
boolean authorize(long amount);
}
import org.springframework.stereotype.Service;
@Service
public class CheckoutService {
private final PaymentGateway gateway;
public CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
public boolean checkout(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("금액은 양수여야 합니다.");
}
return gateway.authorize(amount);
}
}
여기서 CheckoutService는 구체적인 결제 방식이 아니라 PaymentGateway라는 계약에 의존한다. 실제 구현체는 별도 스프링 빈으로 등록하면 된다.
import org.springframework.stereotype.Component;
@Component
public class DemoPaymentGateway implements PaymentGateway {
@Override
public boolean authorize(long amount) {
return amount <= 1_000_000;
}
}
스프링 컨테이너는 CheckoutService를 생성할 때 PaymentGateway 타입의 빈을 찾아 생성자에 전달한다. 생성자가 하나인 경우에는 @Autowired를 생략할 수 있다. 이는 Spring Framework 4.3부터 지원하는 방식이다.
주입 방법은 크게 세 가지다.
| 방식 | 특징 | 권장 사용 상황 |
|---|---|---|
| 생성자 주입 | 객체 생성 시 의존성이 결정됨 | 대부분의 필수 의존성 |
| 세터 주입 | 생성 후 값을 변경할 수 있음 | 의도적으로 변경 가능한 선택적 의존성 |
| 필드 주입 | 필드에 직접 주입함 | 제한적인 프레임워크 연동·레거시 코드 |
// 필드 주입: 의존성이 생성자에 드러나지 않음
@Autowired
private PaymentGateway gateway;
필드 주입이 작동하지 않는 것은 아니다. 다만 순수 자바 코드에서 객체를 직접 만들기 어렵고, 의존성의 필수 여부가 덜 명확하며, 불변 필드인 final로 선언할 수도 없다.
생성자 주입은 객체를 만들 때 필요한 의존성을 빠뜨리기 어렵고, final 필드로 고정하기 쉬우며, Spring 컨테이너 없이도 직접 객체를 생성해 테스트할 수 있다.
PaymentGateway fake = amount -> true;
CheckoutService service = new CheckoutService(fake);
System.out.println(service.checkout(12000)); // true
이 코드에는 Spring 애노테이션이나 ApplicationContext가 필요 없다. 설계가 테스트 편의성을 만들어낸 것이다.
JUnit 5로 작성하는 단위 테스트 예시를 보자. CheckoutService, PaymentGateway 클래스가 앞의 예제와 같은 패키지에서 제공된다고 가정한다.
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class CheckoutServiceTest {
@Test
void 결제가_승인되면_true를_반환한다() {
PaymentGateway fake = amount -> amount < 50000;
CheckoutService service = new CheckoutService(fake);
assertTrue(service.checkout(30000));
assertFalse(service.checkout(90000));
}
@Test
void 잘못된_금액은_거부한다() {
CheckoutService service = new CheckoutService(amount -> true);
assertThrows(IllegalArgumentException.class, () -> service.checkout(0));
}
}
가짜 구현체를 이용하면 외부 결제 API나 데이터베이스 없이 핵심 업무 규칙을 검증할 수 있다. 반면 스프링이 생성한 빈의 연결 자체를 확인하려면 컨텍스트를 띄우는 통합 테스트가 필요할 수 있다.
@MockitoBean이전의 예제에서 @MockBean을 봤을 수 있다. 최근 Spring Framework는 테스트 컨텍스트의 빈을 대체할 때 @MockitoBean을 제공한다. Spring Boot 4.x 또는 Spring Framework 6.2 이상을 활용하는 환경에서는 이 애노테이션을 기준으로 학습하는 편이 적절하다.
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
class CheckoutServiceIntegrationTest {
@Autowired
CheckoutService service;
@MockitoBean
PaymentGateway gateway;
@Test
void 외부_결제_연결을_가짜로_대체한다() {
when(gateway.authorize(anyLong())).thenReturn(true);
assertTrue(service.checkout(15000));
}
}
@MockitoBean은 테스트용 ApplicationContext 안의 특정 빈을 Mockito Mock으로 대체하거나 생성한다. 실제 외부 결제 시스템을 부르지 않고 빈 연결과 서비스 실행을 검증할 수 있다.
일반 단위 테스트에는 @SpringBootTest가 없어도 된다. 컨테이너 동작까지 테스트해야 하는지에 따라 테스트 범위를 선택하자.
No qualifying beanNo qualifying bean of type 'PaymentGateway' available
생성자에 필요한 타입의 스프링 빈이 없는 경우다. 다음을 확인하자.
@Component 등이 있는가?@Bean으로 등록했다면 설정 클래스가 스캔 범위에 있는가?@Profile) 또는 조건부 설정 때문에 제외된 것은 아닌가?반대로 같은 타입의 빈이 둘 이상이면 어떤 구현체를 넣을지 모호해질 수 있다.
public CheckoutService(@Qualifier("demoPaymentGateway") PaymentGateway gateway) {
this.gateway = gateway;
}
이처럼 @Qualifier로 선택할 수 있고, 전체 기본 우선순위가 필요하면 @Primary를 사용할 수 있다. 하지만 먼저 동일 인터페이스 구현체가 왜 여러 개 필요한지 판단하는 게 좋다.
OrderService → StockService → OrderService
두 객체가 서로를 생성자에서 요구한다면 어떤 객체를 먼저 생성해야 할까? 컨테이너도 쉽게 해결할 수 없다. Spring Boot는 기본적으로 이러한 순환 참조를 허용하지 않는 방향으로 구성된다.
세터 주입이나 지연 주입으로 우회할 수 있는 경우도 있지만, 그보다 역할이 지나치게 엮인 설계를 분리하는 것이 우선이다.
예를 들어 OrderService와 StockService가 서로 의존한다면 주문과 재고 조정을 조율하는 OrderCoordinator를 새로 두어 의존성 방향을 정리할 수 있다.
OrderCoordinator
├── OrderService
└── StockService
이렇게 하면 기능을 테스트할 때도 어떤 객체를 준비해야 하는지 명확해진다.
@Autowired를 생략할 수 있다.@MockitoBean을 활용해 빈을 대체할 수 있다.