스프링에서 의존성을 주입하는 방법이 세 가지나 있는데, 왜 하필 생성자 주입만 유독 권장하는 걸까요? 나머지 방식은 어떤 문제가 있을까요?

개념 정의

의존성 주입(Dependency Injection) 은 객체가 필요로 하는 의존성을 외부(스프링 컨테이너)에서 넣어주는 것입니다. 주입 방식은 크게 세 가지입니다: **필드 주입 **, ** 세터 주입 **, ** 생성자 주입 **.

왜 필요한가

의존성 주입이 없으면 객체가 자기 의존성을 직접 생성해야 합니다.

JAVA
// DI 없이 직접 생성
public class OrderService {
    private final OrderRepository repository = new JdbcOrderRepository();
    // JdbcOrderRepository에 강하게 결합 → 테스트, 교체 어려움
}

DI를 사용하면 구현체를 외부에서 결정하므로 결합도가 낮아지고, 테스트 시 Mock을 넣을 수 있습니다.

내부 동작

세 가지 주입 방식 비교

1. 필드 주입

JAVA
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private PaymentService paymentService;
}

2. 세터 주입

JAVA
@Service
public class OrderService {
    private OrderRepository orderRepository;
    private PaymentService paymentService;

    @Autowired
    public void setOrderRepository(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Autowired
    public void setPaymentService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

3. 생성자 주입

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    // 생성자가 하나면 @Autowired 생략 가능 (Spring 4.3+)
    public OrderService(OrderRepository orderRepository,
                        PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
}

왜 생성자 주입인가

기준필드 주입세터 주입생성자 주입
불변성(final)불가불가** 가능**
순환 참조 조기 발견불가불가** 가능**
테스트 용이성낮음보통** 높음**
필수 의존성 강제불가불가** 가능**
코드 양적음많음보통

이 차이가 발생하는 근본적인 이유는 ** 주입 시점 **입니다.

  1. 생성자 주입은 ** 객체 생성 시점 **에 모든 의존성이 필요합니다.
  2. 생성 시점에 의존성이 결정되므로 final로 선언할 수 있고, 이후 변경이 불가능합니다.
  3. A가 B를 필요로 하고 B가 A를 필요로 하면, 어느 쪽도 생성할 수 없어 ** 즉시 에러 **가 발생합니다.
  4. 필드/세터 주입은 생성 후에 주입하므로, 순환 참조가 런타임까지 숨어 있을 수 있습니다.

불변성 보장

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository; // final → 변경 불가

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // 생성 이후 orderRepository를 바꿀 수 없음 → 안전
}

final 키워드를 사용할 수 있는 건 생성자 주입뿐입니다. 한 번 주입된 의존성이 런타임에 변경될 수 없으므로, 예측 가능하고 스레드 안전합니다.

순환 참조 조기 발견

JAVA
@Service
public class A {
    private final B b;
    public A(B b) { this.b = b; }
}

@Service
public class B {
    private final A a;
    public B(A a) { this.a = a; }
}

생성자 주입에서는 A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하므로 ** 애플리케이션 시작 시점에 즉시 에러 **가 발생합니다. 필드 주입이나 세터 주입에서는 이 순환이 런타임에 발견될 수 있어 더 위험합니다.

PLAINTEXT
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
|  a defined in ...
↑     ↓
|  b defined in ...
└─────┘

테스트 용이성

JAVA
// 필드 주입 → 테스트 시 리플렉션이 필요
@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;
}

// 테스트 (불편)
class OrderServiceTest {
    @Test
    void test() {
        OrderService service = new OrderService();
        // orderRepository가 null! 리플렉션으로 넣어야 함
    }
}
JAVA
// 생성자 주입 → 테스트 시 그냥 생성자에 넣으면 됨
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

// 테스트 (간편)
class OrderServiceTest {
    @Test
    void test() {
        OrderRepository mockRepo = mock(OrderRepository.class);
        OrderService service = new OrderService(mockRepo); // 끝!
    }
}

코드 예제

Lombok으로 생성자 주입 간소화

JAVA
@Service
@RequiredArgsConstructor // final 필드에 대한 생성자 자동 생성
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final EventPublisher eventPublisher;

    // 생성자를 직접 쓸 필요 없음
}

@RequiredArgsConstructorfinal 필드만으로 생성자를 만들어주므로, 생성자 주입의 장점은 유지하면서 코드는 간결해집니다.

같은 타입 빈이 여러 개일 때

JAVA
public interface NotificationSender {
    void send(String message);
}

@Component
public class EmailSender implements NotificationSender { ... }

@Component
public class SmsSender implements NotificationSender { ... }
JAVA
@Service
public class NotificationService {
    private final NotificationSender sender;

    // 에러! NotificationSender 타입의 빈이 두 개
    public NotificationService(NotificationSender sender) {
        this.sender = sender;
    }
}

해결 방법은 여러 가지입니다.

JAVA
// 방법 1: @Primary
@Primary
@Component
public class EmailSender implements NotificationSender { ... }

// 방법 2: @Qualifier
@Service
public class NotificationService {
    private final NotificationSender sender;

    public NotificationService(@Qualifier("smsSender") NotificationSender sender) {
        this.sender = sender;
    }
}

// 방법 3: 파라미터 이름 매칭
@Service
public class NotificationService {
    private final NotificationSender emailSender; // 빈 이름과 매칭

이어서 나머지 구현 부분입니다.

JAVA
    public NotificationService(NotificationSender emailSender) {
        this.emailSender = emailSender;
    }
}

// 방법 4: 모든 구현체를 List로 주입
@Service
public class NotificationService {
    private final List<NotificationSender> senders;

    public NotificationService(List<NotificationSender> senders) {
        this.senders = senders; // [EmailSender, SmsSender]
    }

    public void notifyAll(String message) {
        senders.forEach(s -> s.send(message));
    }
}

선택적 의존성

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final CacheManager cacheManager; // 없을 수도 있음

    // 필수 의존성은 생성자로
    public OrderService(OrderRepository orderRepository,
                        @Autowired(required = false) CacheManager cacheManager) {
        this.orderRepository = orderRepository;
        this.cacheManager = cacheManager;
    }

    // 또는 Optional 사용
    public OrderService(OrderRepository orderRepository,
                        Optional<CacheManager> cacheManager) {
        this.orderRepository = orderRepository;
        this.cacheManager = cacheManager.orElse(null);
    }
}

필수 의존성은 생성자로, 선택적 의존성은 required = falseOptional로 처리하면 세터 주입을 쓸 필요가 없습니다.

@Autowired 생략 조건

JAVA
@Service
public class OrderService {
    private final OrderRepository orderRepository;

    // 생성자가 딱 하나 → @Autowired 생략 가능 (Spring 4.3+)
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

이어서 나머지 구현 부분입니다.

JAVA
    // 생성자가 두 개면 → 어떤 생성자를 쓸지 @Autowired로 지정해야 함
    public OrderService(OrderRepository orderRepository) {
        this(orderRepository, null);
    }

    @Autowired
    public OrderService(OrderRepository orderRepository,
                        PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
}

주의할 점

1. 필드 주입을 사용하면 스프링 없이 테스트할 수 없다

@Autowired private OrderRepository repo;로 필드 주입을 하면, 순수 Java로 단위 테스트할 때 의존성을 넣을 방법이 없습니다. 리플렉션을 사용하거나 @SpringBootTest로 전체 컨텍스트를 띄워야 하므로, 테스트가 느려지고 복잡해집니다. 생성자 주입이면 new OrderService(mockRepo)로 끝납니다.

2. 생성자 파라미터가 너무 많으면 설계 문제의 신호다

생성자 주입으로 전환했더니 파라미터가 8~10개가 되었다면, 이는 주입 방식의 문제가 아니라 클래스가 너무 많은 책임을 지고 있다는 신호입니다. 필드 주입에서는 의존성 수가 눈에 잘 띄지 않아 이 문제를 놓치기 쉽습니다. 생성자 주입이 자연스럽게 "이 클래스를 분리해야 하지 않나?"라는 리팩토링 신호를 줍니다.

3. 같은 타입 빈이 여러 개일 때 @Qualifier를 빠뜨리면 NoUniqueBeanDefinitionException이 발생한다

인터페이스 구현체가 2개 이상이면 스프링이 어떤 빈을 주입할지 결정하지 못해 NoUniqueBeanDefinitionException이 발생합니다. 특히 테스트용 Mock 빈과 실제 빈이 동시에 등록되는 상황에서 자주 발생합니다. @Primary, @Qualifier, 또는 파라미터 이름 매칭으로 명시적으로 지정해야 합니다.

정리

항목설명
권장 방식생성자 주입 (스프링 공식 권장)
핵심 이유final 불변성, 순환 참조 조기 발견, 스프링 없이 테스트 가능
필드 주입 단점final 불가, 리플렉션 없이 테스트 불가
Lombok 활용@RequiredArgsConstructor로 생성자 자동 생성
동일 타입 빈 충돌@Qualifier, @Primary, List<T> 주입으로 해결
@Autowired 생략생성자가 하나면 생략 가능 (Spring 4.3+)
댓글 로딩 중...