의존관계 주입 (Dependency Injection)

의존관계 주입(Dependency Injection, DI)은 객체가 필요로 하는 의존성(다른 객체)을 직접 생성하지 않고 외부에서 주입받는 객체지향 디자인 패턴입니다.

전통적인 방식에서는 객체가 다른 객체를 new 키워드를 이용해 직접 생성했지만, DI는 객체 외부에서 의존 객체를 생성하고 주입해주는 방식으로 설계됩니다. 이러한 역할을 수행하는 것이 바로 스프링의 IoC(Inversion of Control) 컨테이너, 또는 DI 컨테이너입니다.

✅ DI의 핵심 원칙 및 이점

  • 의존성 분리 (Decoupling)
    객체 간 의존성을 외부로 분리해 느슨한 결합(loose coupling)을 실현합니다.
  • 코드 재사용성 향상
    주입받는 의존성을 변경하기만 하면 여러 환경에서 재사용할 수 있습니다.
  • 테스트 용이성 증가
    의존 객체를 Mock이나 Stub 등으로 교체하여 단위 테스트가 쉬워집니다.
  • 가독성 및 유지보수성 향상
    객체 생성과 의존성 관리를 외부에 맡기므로 핵심 비즈니스 로직에 집중할 수 있습니다.
  • OCP (개방-폐쇄 원칙) 준수 용이
    기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있습니다.
  • DIP (의존관계 역전 원칙) 준수
    구체화가 아닌 추상화(인터페이스)에 의존하도록 설계할 수 있습니다.

✅ 의존성 주입 원칙

"상위 모듈은 하위 모듈에서 어떠한 것도 가져오지 않아야 한다. 또한, 둘 다 추상화에 의존해야 하며, 이때 추상화는 세부 사항에 의존하지 말아야 한다"

🧩 스프링에서의 의존관계 주입 방식

스프링에서는 다음 네 가지 방식으로 의존관계를 주입할 수 있습니다.


1. 생성자 주입 (Constructor Injection)

@Component
public class OrderService {
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

장점

  • 생성자 호출 시점에 단 한 번만 주입되어 불변성을 보장합니다.
  • 필수 의존관계를 설정하는 데 적합하며, 의존성이 누락되면 컴파일 시점에 오류를 유추할 수 있습니다.
  • final 키워드를 활용할 수 있어 설계의 안정성과 명확성이 높아집니다.
  • 프레임워크에 의존하지 않고도 순수한 자바 코드로 테스트하기 용이합니다.

단점

  • 주입해야 할 의존성이 많아질 경우 생성자 매개변수가 길어지고 복잡해질 수 있습니다.

스프링과 대부분의 DI 프레임워크에서 가장 권장하는 방식입니다.


2. 수정자 주입 (Setter Injection)

@Component
public class OrderService {
    private DiscountPolicy discountPolicy;

    @Autowired
    public void setDiscountPolicy(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

장점

  • 선택적(optional) 의존관계 주입에 적합합니다.
  • 실행 중에도 의존 객체를 변경할 수 있는 유연성을 가집니다.

단점

  • 불변성을 보장할 수 없습니다.
  • 외부에서 setXxx() 메서드를 통해 의존 객체가 변경될 수 있어 설계가 취약해질 수 있습니다.
  • 주입이 누락되어도 컴파일 오류가 발생하지 않아, 런타임 오류 위험이 있습니다.
  • final 키워드를 사용할 수 없습니다.
  • 테스트 시 setter를 명시적으로 호출해야 하므로 불편할 수 있습니다.

3. 필드 주입 (Field Injection)

@Component
public class OrderService {
    @Autowired
    private DiscountPolicy discountPolicy;
}

장점

  • 코드가 가장 간결합니다.

단점

  • 외부에서 의존 객체를 주입하거나 대체할 수 없어 테스트가 어렵습니다.
  • DI 프레임워크 없이는 동작하지 않으며, 강한 프레임워크 의존성을 가집니다.
  • final을 사용할 수 없어 객체의 불변성을 확보하기 어렵습니다.
  • 테스트를 위해 실제 코드와 관계없는 설정이나 접근자 작성이 필요할 수 있습니다.

⚠️ 실무에서는 사용을 지양하는 방식입니다. 스프링 설정 클래스 등 특수한 경우에만 사용하세요.


4. 일반 메서드 주입 (General Method Injection)

@Autowired
public void init(DiscountPolicy discountPolicy, MemberRepository memberRepository) {
    this.discountPolicy = discountPolicy;
    this.memberRepository = memberRepository;
}
  • 여러 의존성을 한 번에 주입할 수 있다는 점에서 생성자/수정자 주입과 유사하게 동작합니다.
  • 사용 빈도는 낮고, 주로 설정이나 특수한 상황에서 활용됩니다.

✅ 결론

  • 기본적으로 생성자 주입을 사용하는 것이 가장 이상적입니다.
    • 불변성, 필수 의존성 보장, 테스트 용이성 등의 측면에서 가장 유리합니다.
  • 선택적인 의존성이 필요한 경우에만 수정자 주입을 사용하세요.
  • 필드 주입은 테스트 어려움, 유지보수성 저하, 프레임워크 종속성 등의 이유로 지양해야 합니다.

🚨 의존성 자동 주입 시 발생하는 문제와 해결 방안

문제: 타입 기반 자동 주입 시 빈이 여러 개 존재하면 충돌 발생

예외: NoUniqueBeanDefinitionException

해결 방법

1. 필드/파라미터명 매칭

@Autowired
private DiscountPolicy fixDiscountPolicy;

2. @Qualifier 사용

@Autowired
@Qualifier("rateDiscountPolicy")
private DiscountPolicy discountPolicy;
@Bean
@Qualifier("rateDiscountPolicy")
public DiscountPolicy rateDiscountPolicy() {
    return new RateDiscountPolicy();
}

3. @Primary 사용

@Bean
@Primary
public DiscountPolicy rateDiscountPolicy() {
    return new RateDiscountPolicy();
}

📝 정리

  • DI는 객체 간 의존성을 외부에 위임하여 유연성, 재사용성, 테스트 용이성을 극대화합니다.
  • 스프링은 DI를 강력하게 지원하여 확장 가능한 아키텍처를 구현할 수 있도록 돕습니다.