Backend

의존성 주입(DI)이란? 직접 만들지 않고 받아 쓰는 이유


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가 그 역할을 한다.

  1. @Component, @Service, @Repository 등이 붙은 클래스를 찾아 객체(빈, Bean)로 등록한다.
  2. 각 빈의 생성자를 보고 필요한 타입의 빈을 찾아 넣어 준다.
  3. 만든 빈은 기본적으로 하나만 만들어(싱글톤) 여러 곳에서 공유한다.
@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 자체는 프레임워크 없이도 쓸 수 있다.

참고