Spring DI 제대로 이해하기: 생성자 주입, 테스트, 순환 의존성

Alchemist·5일 전

KT AIVLE

목록 보기
93/130

의존성 주입(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나 데이터베이스 없이 핵심 업무 규칙을 검증할 수 있다. 반면 스프링이 생성한 빈의 연결 자체를 확인하려면 컨텍스트를 띄우는 통합 테스트가 필요할 수 있다.

Spring 컨텍스트 테스트에서는 @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 bean

No qualifying bean of type 'PaymentGateway' available

생성자에 필요한 타입의 스프링 빈이 없는 경우다. 다음을 확인하자.

  1. 구현 클래스에 @Component 등이 있는가?
  2. @Bean으로 등록했다면 설정 클래스가 스캔 범위에 있는가?
  3. 관련 Spring Data 저장소나 외부 라이브러리 자동 설정이 활성화됐는가?
  4. 프로필(@Profile) 또는 조건부 설정 때문에 제외된 것은 아닌가?

반대로 같은 타입의 빈이 둘 이상이면 어떤 구현체를 넣을지 모호해질 수 있다.

public CheckoutService(@Qualifier("demoPaymentGateway") PaymentGateway gateway) {
    this.gateway = gateway;
}

이처럼 @Qualifier로 선택할 수 있고, 전체 기본 우선순위가 필요하면 @Primary를 사용할 수 있다. 하지만 먼저 동일 인터페이스 구현체가 왜 여러 개 필요한지 판단하는 게 좋다.

순환 의존성은 왜 피해야 할까?

OrderService → StockService → OrderService

두 객체가 서로를 생성자에서 요구한다면 어떤 객체를 먼저 생성해야 할까? 컨테이너도 쉽게 해결할 수 없다. Spring Boot는 기본적으로 이러한 순환 참조를 허용하지 않는 방향으로 구성된다.

세터 주입이나 지연 주입으로 우회할 수 있는 경우도 있지만, 그보다 역할이 지나치게 엮인 설계를 분리하는 것이 우선이다.

예를 들어 OrderService와 StockService가 서로 의존한다면 주문과 재고 조정을 조율하는 OrderCoordinator를 새로 두어 의존성 방향을 정리할 수 있다.

OrderCoordinator
  ├── OrderService
  └── StockService

이렇게 하면 기능을 테스트할 때도 어떤 객체를 준비해야 하는지 명확해진다.

핵심 정리

  • DI는 객체를 직접 생성하지 않고 필요한 의존성을 외부에서 전달받는 설계다.
  • 인터페이스에 의존하면 실제 구현체와 테스트 대역을 교체하기 쉽다.
  • 필수 의존성은 대부분 생성자 주입이 적합하다.
  • 생성자가 하나라면 일반적으로 @Autowired를 생략할 수 있다.
  • 순수 단위 테스트는 Spring 컨텍스트 없이 작성할 수 있다.
  • 현재 Spring 테스트 환경에서는 @MockitoBean을 활용해 빈을 대체할 수 있다.
  • 빈이 없거나 여러 개여서 모호한 경우 등록 상태와 스캔 범위를 확인한다.
  • 순환 의존성은 주입 방식으로 우회하기보다 설계를 분리하는 편이 좋다.
profile
html_programming_language

0개의 댓글