Dependency Injection 딜레마: 왜 나는 이제 필드에서 @Autowired를 무시하고 있는가

발행: (2025년 12월 19일 오후 08:41 GMT+9)
20 min read
원문: Dev.to

Source: Dev.to

의존성 주입 딜레마: 왜 이제는 @Autowired 필드 주입을 피하게 되었는가

Spring을 사용하면서 가장 흔히 마주치는 선택 중 하나는 필드 주입(@Autowired를 필드에 직접 붙이는 방식)과 생성자 주입(생성자를 통해 의존성을 주입하는 방식) 사이에서 어느 쪽을 택할 것인가 하는 점이다.
몇 년 전만 해도 필드 주입이 “편리하고 깔끔하다”는 이유로 팀 내에서 기본 선택이었지만, 시간이 지나면서 그 단점이 점점 더 명확해졌다. 이번 글에서는 필드 주입이 왜 문제가 되는지, 그리고 생성자 주입이 제공하는 장점들을 정리하고, 실제 코드 예시를 통해 전환 방법을 보여준다.


📌 필드 주입이 문제인 이유

문제점설명
테스트가 어려워진다필드가 private이기 때문에 리플렉션이나 @TestConfiguration 없이 목 객체를 주입하기 힘들다.
불변성 보장이 안 된다Spring 컨테이너가 객체를 생성한 뒤에 필드를 채우기 때문에, 객체가 완전히 초기화되기 전까지는 일관된 상태를 보장할 수 없다.
순환 의존성 감지가 어려워진다순환 의존성이 발생했을 때, Spring은 필드 주입에서는 BeanCurrentlyInCreationException을 덜 명확하게 표시한다.
가시성이 떨어진다클래스 내부에서 어떤 의존성이 필요한지 한눈에 파악하기 어렵다. 생성자 파라미터만 보면 바로 알 수 있다.
IDE 지원이 제한적자동 완성, 리팩터링 등 IDE가 제공하는 편리함을 충분히 활용하지 못한다.

✅ 생성자 주입이 제공하는 장점

  1. 불변성 보장
    모든 의존성이 생성자 파라미터로 전달되므로 객체가 완전히 초기화된 뒤에만 사용 가능하다.

  2. 테스트 용이
    테스트 코드에서 단순히 생성자를 호출해 목 객체를 전달하면 되므로, 별도의 스프링 컨텍스트가 필요 없다.

  3. 순환 의존성 조기 감지
    Spring이 애플리케이션 시작 단계에서 순환 의존성을 발견하고 명확한 예외를 발생시킨다.

  4. 가독성 향상
    클래스 선언부만 보면 어떤 의존성이 필요한지 한눈에 파악할 수 있다.

  5. IDE와 Lombok 연계
    Lombok의 @RequiredArgsConstructor와 같은 어노테이션을 사용하면 보일러플레이트 코드를 거의 없앨 수 있다.


📦 실제 코드 예시

아래 예시는 필드 주입생성자 주입을 각각 보여준다. 코드 블록 자체는 번역하지 않는다.

필드 주입 (기존 방식)

@Service
public class OrderService {

    @Autowired
    private PaymentProcessor paymentProcessor;

    @Autowired
    private NotificationService notificationService;

    public void placeOrder(Order order) {
        paymentProcessor.process(order);
        notificationService.notify(order);
    }
}

생성자 주입 (추천 방식)

@Service
public class OrderService {

    private final PaymentProcessor paymentProcessor;
    private final NotificationService notificationService;

    @Autowired
    public OrderService(PaymentProcessor paymentProcessor,
                       NotificationService notificationService) {
        this.paymentProcessor = paymentProcessor;
        this.notificationService = notificationService;
    }

    public void placeOrder(Order order) {
        paymentProcessor.process(order);
        notificationService.notify(order);
    }
}

Tip: Lombok을 사용한다면 위 코드를 더 간결하게 만들 수 있다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final PaymentProcessor paymentProcessor;
    private final NotificationService notificationService;

    public void placeOrder(Order order) {
        paymentProcessor.process(order);
        notificationService.notify(order);
    }
}

🧪 테스트 예시

필드 주입을 테스트할 때

@RunWith(SpringRunner.class)
@SpringBootTest
public class OrderServiceFieldInjectionTest {

    @Autowired
    private OrderService orderService;

    @MockBean
    private PaymentProcessor paymentProcessor;

    @MockBean
    private NotificationService notificationService;

    @Test
    public void testPlaceOrder() {
        // given
        Order order = new Order(...);
        // when
        orderService.placeOrder(order);
        // then
        verify(paymentProcessor).process(order);
        verify(notificationService).notify(order);
    }
}

생성자 주입을 테스트할 때 (스프링 없이)

public class OrderServiceConstructorInjectionTest {

    private PaymentProcessor paymentProcessor = mock(PaymentProcessor.class);
    private NotificationService notificationService = mock(NotificationService.class);
    private OrderService orderService = new OrderService(paymentProcessor, notificationService);

    @Test
    public void testPlaceOrder() {
        // given
        Order order = new Order(...);
        // when
        orderService.placeOrder(order);
        // then
        verify(paymentProcessor).process(order);
        verify(notificationService).notify(order);
    }
}

위와 같이 생성자 주입은 스프링 컨텍스트 없이도 순수 JUnit + Mockito만으로 충분히 테스트할 수 있다.


📚 마무리

  • 필드 주입은 편리하지만, 장기적인 유지보수와 테스트 관점에서 큰 비용을 초래한다.
  • 생성자 주입을 기본 선택으로 삼고, 필요에 따라 @Autowired를 생략(단일 생성자일 경우)하거나 Lombok을 활용하면 코드가 훨씬 깔끔해진다.
  • 프로젝트 초기 단계에서라도 생성자 주입을 도입해 두면, 나중에 발생할 수 있는 순환 의존성, 테스트 난이도 상승, 가독성 저하 문제를 미연에 방지할 수 있다.

결론: 이제는 @Autowired를 필드에 붙이는 대신, 생성자를 통해 의존성을 주입하는 방식을 적극적으로 채택하자. 이렇게 하면 코드가 더 안전하고, 테스트가 쉬워지며, 팀 전체의 생산성도 향상된다.


이 글은 원본 Dev.to 포스트를 한국어로 번역한 것이며, 내용상의 정확성을 위해 원문을 참고해 주세요.

미학적 함정: 왜 우리는 필드 인젝션에 빠졌는가

우리가 이를 해체하기 전에, 처음에 왜 사용했는지 인정해야 합니다. 다음 코드 스니펫을 살펴보세요:

@Service
public class OrderService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private InventoryClient inventoryClient;

    // Business Logic...
}

틀림없이 깔끔합니다. 화면 절반을 차지하는 거대한 생성자가 없습니다. 마치 “Spring 방식”처럼 느껴집니다. 수년간 이것이 튜토리얼과 Stack Overflow 답변에서 표준이었습니다. 한 줄의 코드만으로 의존성을 추가할 수 있게 해줬죠.

하지만 이 “청결함”은 시각적인 착각에 불과합니다. 모든 것을 옷장에 집어넣어 어지러운 방을 숨기는 것과 같습니다. 방은 깨끗해 보이지만, 실제로는 시스템을 관리하기 더 어렵게 만든 것이죠.

불변성에 대한 주장

엔지니어로서 우리는 불변성을 추구해야 합니다. 생성된 후 변경될 수 없는 객체는 본질적으로 더 안전하고, 예측 가능하며, 다중 스레드 환경에서 이해하기 쉽습니다.

필드 주입을 사용할 경우 의존성을 final로 선언할 수 없습니다. Spring은 생성자가 실행된 후 리플렉션을 통해 해당 필드에 접근하여 주입해야 하기 때문입니다. 이는 의존성이 기술적으로 가변적임을 의미합니다.

생성자 주입으로 전환하면 final 키워드를 사용할 수 있는 능력을 되찾게 됩니다:

@Service
public class OrderService {
    private final UserRepository userRepository;
    private final PaymentService paymentService;
    private final InventoryClient inventoryClient;

    public OrderService(
            UserRepository userRepository,
            PaymentService paymentService,
            InventoryClient inventoryClient) {
        this.userRepository = userRepository;
        this.paymentService = paymentService;
        this.inventoryClient = inventoryClient;
    }
}

이제 클래스가 “Born Ready.” 상태가 됩니다. OrderService가 존재하는 순간 userRepository가 확실히 존재하며, 어떤 악의적인 프로세스에 의해 변경되거나 null로 설정되는 일이 절대 없다는 100 % 보장을 갖게 됩니다. 이것이 스레드 안전성과 방어적 프로그래밍의 기반입니다.


단위 테스트의 필요성

아키텍처가 얼마나 좋은지 알고 싶다면 단위 테스트를 살펴보세요. 테스트 설정이 의식적인 제물처럼 보인다면, 아키텍처가 깨진 것입니다.

필드 주입은 단위 테스트를 불필요하게 어렵게 만듭니다. 필드가 private이고 Spring이 뒤에서 무거운 작업을 수행하기 때문에, 테스트에서 클래스를 단순히 인스턴스화할 수 없습니다. 선택할 수 있는 두 가지 나쁜 옵션이 있습니다:

  1. 테스트에서 Spring 사용 – 예: @SpringBootTest 또는 @MockBean.
    이제 “단위” 테스트가 축소된 Spring 컨텍스트를 시작합니다. 느리고 무겁고, 더 이상 단위 테스트가 아니라 통합 테스트가 됩니다.

  2. 리플렉션 사용 – 예: ReflectionTestUtils를 이용해 private 필드에 직접 모크를 “넣어” 주는 방법.
    이것은 깨지기 쉽습니다. 필드 이름을 바꾸면 테스트가 깨지지만, 컴파일러가 이유를 알려주지 않습니다.

생성자 주입을 사용하면 테스트가 아주 쉬워집니다. 생성자가 객체를 만들 수 있는 유일한 방법이므로, 모크를 직접 전달하면 됩니다:

@Test
void shouldProcessOrder() {
    UserRepository mockUserRepo = mock(UserRepository.class);
    PaymentService mockPaymentService = mock(PaymentService.class);
    InventoryClient mockInventoryClient = mock(InventoryClient.class);

    // Standard Java. No magic. No Spring. Fast.
    OrderService service = new OrderService(mockUserRepo, mockPaymentService, mockInventoryClient);

    service.process(new Order());
}

빠르게 실패하기: 새벽 2시 프로덕션 버그

우리는 모두 그런 경험을 해봤습니다. 변경 사항을 배포하고, 애플리케이션이 정상적으로 시작되며 모든 것이 초록색으로 표시됩니다. 그런데 새벽 2시에 특정 사용자가 엣지 케이스 API 엔드포인트를 호출하면 로그가 NullPointerException으로 폭발합니다.

왜 그럴까요? 필드 주입을 사용하면 Spring은 의존성이 없거나 순환 의존성이 있더라도 애플리케이션이 시작될 수 있게 허용합니다. 필드는 단순히 null 상태로 남게 됩니다. 실제로 해당 필드를 사용하려고 할 때까지 문제를 알 수 없습니다.

생성자 주입은 초기 경고 시스템과 같습니다. Spring이 빈을 생성하기 위해 반드시 생성자를 호출해야 하므로 모든 의존성을 즉시 만족시켜야 합니다. 빈이 누락되면 ApplicationContext가 로드에 실패하고, 애플리케이션은 로컬 머신에서도 시작되지 않으며, 프로덕션에서도 절대 시작되지 않습니다.

나는 밤중에 결제 서비스가 다운된 이유를 이해관계자에게 설명하는 데 다섯 시간을 쓰기보다, 로컬에서 시작 오류를 해결하는 데 다섯 분을 투자하는 것이 훨씬 낫다고 생각합니다.

단일 책임 원칙

단일 책임 원칙 (SRP) 은 클래스가 하나의, 그리고 오직 하나의 변경 이유만을 가져야 한다고 명시합니다.

필드 주입은 이를 위반하기 너무 쉽습니다. 각 의존성이 @Autowired 한 줄로 표현되기 때문에, 개발자들은 응집성을 고려하지 않고 클래스에 추가 협력자를 흩뿌리기 쉽습니다. 생성자 주입은 클래스가 실제로 필요로 하는 무엇을 명시하도록 강제하여, 관련 없는 책임이 누적되는 것을 어렵게 만듭니다.

TL;DR

AspectField InjectionConstructor Injection
Visibilityprivate 필드와 숨겨진 의존성명시적인 생성자 매개변수
Immutability의존성을 final 로 선언할 수 없음의존성을 final 로 선언 가능
TestabilitySpring 컨텍스트 또는 리플렉션 해킹 필요목업을 사용한 순수 Java 인스턴스화
Fail‑fast런타임에 null 발생빈이 없으면 시작 시 실패
SRP enforcement숨겨진 협력자를 쉽게 추가 가능명시적인 계약, 응집도 향상

오늘 바로 생성자 주입으로 전환하세요. 코드가 더 깔끔하고 안전하며 테스트하기 쉬워지고, 다음 프로덕션 버그가 새벽 2시쯤 몰래 들어오려 할 때 팀이 고마워할 것입니다.

Constructor Injection vs. Field Injection


“멸망의 생성자”

필드 주입(@Autowired)에 의존하면 클래스가 너무 많은 일을 하는 시점을 놓치기 쉽습니다. 화면에서는 깔끔해 보이지만, 15개의 @Autowired 필드를 가진 서비스를 본 적이 있습니다.

생성자 주입으로 전환하면 15개의 의존성을 가진 클래스가 거대한 괴물처럼 보입니다. 생성자가 방대해지고 읽기 힘들며 보기 싫어집니다.

바로 그게 핵심입니다.
그 “멸망의 생성자”는 신호입니다 – 코드가 다음과 같이 말하고 있습니다:

“이봐, 내가 너무 많은 일을 하고 있어. 나를 더 작고 집중된 서비스들로 리팩터링해줘.”

필드 주입은 피부 감염을 가리는 메이크업과 같습니다; 생성자 주입은 문제를 직접 보고 치료하도록 강요합니다.

순환 의존성: 무한 루프

순환 의존성(서비스 A가 B를 필요로 하고, B가 A를 필요로 함)은 보통 설계가 좋지 않다는 신호입니다. 필드 주입은 이를 거의 눈치채지 못하게 허용합니다. 스프링은 프록시를 사용해 이를 해결하려고 시도하는데, 종종 혼란스러운 동작을 초래합니다.

생성자 주입은 기본적으로 순환 의존성을 허용하지 않습니다. 만약 시도하면 스프링은 BeanCurrentlyInCreationException을 발생시킵니다.

이는 번거롭게 보일 수 있지만 실제로는 방어 장치입니다. 서비스 경계를 다시 생각하게 만들죠. 일반적으로 순환 의존성은 공유 로직을 담을 제3의 서비스(서비스 C)가 필요하다는 의미이거나, 이벤트‑드리븐 접근 방식으로 전환해야 함을 의미합니다.

Lombok 치트 코드

가장 흔히 듣는 반론은 다음과 같습니다:

“하지만 200개의 서비스에 대해 생성자를 일일이 작성하고 유지하고 싶지 않아요!”

동의합니다. 저는 프로그래머이니 작업을 자동화할 수 있다면 그렇게 합니다. 여기서 Project Lombok이 최고의 친구가 됩니다.

@RequiredArgsConstructor 어노테이션을 사용하면 두 가지 장점을 모두 얻을 수 있습니다: 필드를 private final로 선언하고, Lombok이 컴파일 시점에 생성자를 자동으로 만들어 줍니다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserRepository userRepository;
    private final PaymentService paymentService;
    private final InventoryClient inventoryClient;

    // No manual constructor needed!
}

전문성은 디테일에 있다

궁극적으로 생성자 주입을 사용하는 것은 의도적인 선택에 관한 것입니다. 이는 다음과 같은 코드를 작성한다는 의미입니다:

  • 프레임워크에 독립적
  • 테스트가 쉬움
  • 아키텍처적으로 건전

이는 “Spring Magic”에서 “Java Excellence”로 이동하게 합니다.

레거시 코드베이스에 @Autowired 필드가 가득하다면 당황하지 마세요. 오늘 밤에 모든 것을 리팩터링할 필요는 없습니다. 하지만 새 서비스를 만들 때마다 생성자 방식을 시도해 보세요. 테스트가 더 간단해지고 클래스가 작아지는 것을 느낄 수 있을 겁니다.

코드는 여러분의 장인 정신을 반영합니다. 필드 주입 같은 단축키가 이를 약화시키지 않도록 하세요.

여러분은 어떻게 생각하시나요?

@Autowired를 고집하는 팬인가요, 아니면 생성자를 받아들였나요? 댓글로 토론해 주세요. 도움이 되었다면 아직 “필드 주입 함정”에 빠져 있는 주니어 개발자와 공유해 보세요.

Back to Blog

관련 글

더 보기 »