JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

Spring

[2편] 스프링 DI의 3가지 방식 : 생성자 주입을 써야만 하는 이유

lamarcK 2025. 6. 3. 12:56

의존성 주입의 3가지 방식과 권장 사항

그렇다면 실제로 스프링에는 의존성 주입을 어떻게 할까? 크게 3가지 주입 방식이 있다.

주입 방식 특징 권장 여부
생성자 주입 객체 생성 시점에 모든 의존성을 주입. final 키워드 사용 가능, 불변성 보장. *️⃣ 강력 권장 (표준)
수정자(Setter) 주입 Setter 메서드를 통해 주입. 선택적인 의존성이나 변경 가능성이 있을 때 사용. △ 제한적 사용
필드 주입 필드에 @Autowired를 직접 선언. 코드가 간결하지만 테스트가 어렵고 여러 단점이 있음. ❌ 비권장

의존성 주입을 위한 전제조건 : @Autowired와 스프링 빈

위에서 의존성 주입의 3가지 방식을 살펴봤는데, 실제로 이것이 동작하려면 두 가지 핵심 전제조건이 필요하다.

스프링 빈(Bean) 등록: "부품 상자에 부품 넣기"

의존성 주입이 가능하려면, 주입받을 객체들이 먼저 스프링 컨테이너에 빈으로 등록되어 있어야 한다. 스프링 컨테이너를 거대한 '부품 상자'라고 생각하면, 그 안에 미리 부품들이 들어있어야 꺼내서 조립할 수 있는 것과 같다.

// 1. 부품들을 스프링 컨테이너에 등록 (빈 등록)
@Service  // "PaymentService 타입의 부품을 부품 상자에 넣어줘"
public class CreditCardPaymentService implements PaymentService {
    @Override
    public void processPayment(int amount) {
        System.out.println("신용카드로 " + amount + "원 결제 처리");
    }
}

@Repository  // "InventoryService 타입의 부품을 부품 상자에 넣어줘"  
public class DatabaseInventoryService implements InventoryService {
    @Override
    public boolean checkStock(String productId) {
        return true; // 재고 확인 로직
    }
}

주요 빈 등록 애너테이션

  • @Component: 일반적인 스프링 빈
  • @Service: 비즈니스 로직 계층
  • @Repository: 데이터 접근 계층
  • @Controller: 웹 요청 처리 계층

@Autowired: "부품 상자에서 부품 꺼내기"

빈이 등록되었다면, 이제 @Autowired를 사용해서 필요한 곳에 의존성을 주입받을 수 있다. 다른 모든 프로그래밍 언어나 라이브러리, 프레임워크들과 마찬가지로 스프링에서도 특정 기능을 사용하기 위해서 사용하는 문법이 존재하는데 의존성 주입에서는 그것이 바로 @Autowired 애너테이션이라고 할 수 있다. @Autowired는 스프링에게 "이 타입에 맞는 부품을 부품 상자에서 찾아서 여기에 꽂아줘!"라고 요청하는 신호다.

@Service
public class OrderService {
    // "PaymentService 타입의 부품을 찾아서 여기에 주입해줘!"
    @Autowired 
    private PaymentService paymentService;
    
    // "InventoryService 타입의 부품을 찾아서 여기에 주입해줘!"
    @Autowired
    private InventoryService inventoryService;
    
    public void processOrder(String productId, int amount) {
        if (inventoryService.checkStock(productId)) {
            paymentService.processPayment(amount);
        }
    }
}

@Autowired의 매칭 원리

스프링이 @Autowired를 만나면 다음 순서로 동작한다. 마치 도서관에서 책을 찾는 과정과 비슷하다.

타입 확인 (Type Matching)

스프링은 먼저 @Autowired가 붙은 필드나 매개변수의 타입을 확인한다. 이는 도서관에서 "소설책을 찾고 있어요"라고 말하는 것과 같다.

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // 1. "PaymentService 타입이 필요하구나"
    
    @Autowired  
    private InventoryService inventoryService;  // 1. "InventoryService 타입이 필요하구나"
}

빈 검색 (Bean Searching)

타입을 확인한 후, 스프링 컨테이너(부품 상자)에서 해당 타입과 일치하는 빈을 검색한다. 이때 인터페이스 타입으로 요청했다면, 그 인터페이스를 구현한 구현체를 찾는다.

// 컨테이너에 등록된 빈들
@Service
public class CreditCardPaymentService implements PaymentService { }  // PaymentService 구현체

@Repository  
public class DatabaseInventoryService implements InventoryService { }  // InventoryService 구현체

// 스프링의 내부 검색 과정 (의사코드)
if (@Autowired가 PaymentService 타입을 요청) {
    // "PaymentService를 구현한 빈을 찾아보자..."
    List<Object> candidates = 컨테이너.findAllByType(PaymentService.class);
    // 결과: [CreditCardPaymentService 인스턴스] 발견!
}

자동 주입 (Dependency Injection)

적절한 빈을 찾았다면, 스프링은 그 빈을 해당 위치에 자동으로 주입한다. 이는 도서관 직원이 찾은 책을 당신 손에 건네주는 것과 같다.

// 주입 전 상태
@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // null 상태
    
    // 이 시점에서 paymentService.processPayment() 호출하면 NullPointerException
}

// 스프링이 주입한 후 상태  
@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService;  // CreditCardPaymentService 인스턴스가 주입됨
    
    public void processOrder() {
        paymentService.processPayment(1000);  // ✅ 정상 동작!
        // 실제로는 CreditCardPaymentService의 processPayment()가 호출됨
    }
}

실제 동작 예시

전체 과정을 하나의 완전한 예시로 살펴보자:

// 1단계: 빈 등록 (부품 상자에 부품 넣기)
@Service
public class EmailService {
    public void sendEmail(String message) {
        System.out.println("이메일 발송: " + message);
    }
}

@Service  
public class SmsService {
    public void sendSms(String message) {
        System.out.println("SMS 발송: " + message);
    }
}

// 2단계: 의존성 주입 요청
@Service
public class NotificationService {
    @Autowired
    private EmailService emailService;  // "EmailService 타입 주세요!"
    
    @Autowired
    private SmsService smsService;      // "SmsService 타입 주세요!"
    
    public void notifyUser(String message) {
        emailService.sendEmail(message);  // 주입받은 EmailService 사용
        smsService.sendSms(message);      // 주입받은 SmsService 사용  
    }
}

// 3단계: 스프링의 내부 처리 과정
// 애플리케이션 시작 시점에...
// 1. EmailService 인스턴스 생성 → 컨테이너에 보관
// 2. SmsService 인스턴스 생성 → 컨테이너에 보관  
// 3. NotificationService 생성 시점에:
//    - @Autowired EmailService 발견 → 컨테이너에서 EmailService 인스턴스를 찾아 주입
//    - @Autowired SmsService 발견 → 컨테이너에서 SmsService 인스턴스를 찾아 주입
// 4. 완전한 NotificationService 객체 완성!

이 과정은 애플리케이션이 시작될 때 자동으로 이루어지며, 개발자는 단지 @Autowired만 써주면 된다. 마치 자동차의 엔진이 복잡한 내부 동작을 숨기고 운전자에게는 단순한 인터페이스(핸들, 페달)만 제공하는 것과 같다.

같은 타입의 빈이 여러 개라면?

만약 같은 인터페이스의 구현체가 여러 개 있다면 어떻게 할까? 이는 도서관에서 "소설책 주세요"라고 했는데 소설책이 100권이나 있는 상황과 비슷하다.

@Service
public class CreditCardPaymentService implements PaymentService { 
    public void processPayment(int amount) {
        System.out.println("신용카드로 결제: " + amount);
    }
}

@Service  
public class PaypalPaymentService implements PaymentService { 
    public void processPayment(int amount) {
        System.out.println("페이팔로 결제: " + amount);
    }
}

// 이런 상황에서 @Autowired는 혼란스럽다!
@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService; // ??? 둘 중 어떤 걸 주입해야 하지?
    
    // 스프링이 이 코드를 만나면:
    // "PaymentService 타입을 찾아보니 CreditCardPaymentService와 PaypalPaymentService가 둘 다 있네?"
    // "어떤 걸 선택해야 할지 모르겠어!" 
    // → NoUniqueBeanDefinitionException 발생!
}

방법 1 : @Qualifier로 명시적 지정

특정 구현체를 이름으로 지정하는 방법이다. 도서관에서 "홍길동이 쓴 소설책 주세요"라고 구체적으로 요청하는 것과 같다.

@Service
public class OrderService {
    // "creditCardPaymentService"라는 이름의 빈을 주입해줘!
    @Autowired
    @Qualifier("creditCardPaymentService")  // 빈 이름은 클래스명의 첫 글자를 소문자로
    private PaymentService creditCardPayment;
    
    // "paypalPaymentService"라는 이름의 빈을 주입해줘!
    @Autowired  
    @Qualifier("paypalPaymentService")
    private PaymentService paypalPayment;
    
    public void processOrder(String paymentMethod, int amount) {
        if ("CARD".equals(paymentMethod)) {
            creditCardPayment.processPayment(amount);
        } else if ("PAYPAL".equals(paymentMethod)) {
            paypalPayment.processPayment(amount);
        }
    }
}

방법 2 : @Primary로 기본 선택

여러 구현체 중에서 기본으로 사용할 것을 미리 지정하는 방법이다. 도서관에서 "소설책 달라고 하면 기본적으로는 베스트셀러를 주자"라고 정해두는 것과 같다.

@Service
@Primary  // "PaymentService가 필요하면 기본적으로 나를 선택해!"
public class CreditCardPaymentService implements PaymentService { 
    public void processPayment(int amount) {
        System.out.println("신용카드로 결제: " + amount);
    }
}

@Service  
public class PaypalPaymentService implements PaymentService { 
    public void processPayment(int amount) {
        System.out.println("페이팔로 결제: " + amount);
    }
}

@Service
public class OrderService {
    @Autowired
    private PaymentService paymentService; // @Primary가 붙은 CreditCardPaymentService가 주입됨!
    
    public void processOrder(int amount) {
        paymentService.processPayment(amount); // "신용카드로 결제: 1000" 출력
    }
}

방법 3 : List나 Map으로 모든 구현체 받기

모든 구현체를 한 번에 받아서 동적으로 선택하는 방법이다.

@Service
public class PaymentManager {
    @Autowired
    private List<PaymentService> allPaymentServices; // 모든 PaymentService 구현체들
    
    @Autowired
    private Map<String, PaymentService> paymentServiceMap; // 빈 이름을 키로 하는 맵
    
    public void processPayment(String method, int amount) {
        // 모든 결제 수단으로 결제 (예: 백업 결제)
        for (PaymentService service : allPaymentServices) {
            service.processPayment(amount);
        }
        
        // 또는 특정 결제 수단 선택
        PaymentService selected = paymentServiceMap.get(method + "PaymentService");
        if (selected != null) {
            selected.processPayment(amount);
        }
    }
}

어떤 방법을 선택할까?

  • @Primary: 대부분의 경우에 사용할 기본 구현체가 명확할 때
  • @Qualifier: 여러 구현체를 각각 다른 용도로 명시적으로 사용할 때
  • List/Map: 런타임에 동적으로 선택하거나 모든 구현체를 사용할 때

중요한 주의사항 : 스프링 빈에서만 동작

@Autowired는 스프링이 관리하는 빈에서만 동작한다. 이는 매우 중요한 개념으로, 많은 초보 개발자들이 혼동하는 부분이다.

동작하는 경우 ✅

스프링이 직접 객체를 생성하고 관리하는 경우:

@Service  // 스프링에게 "이 클래스를 빈으로 관리해줘"라고 요청
public class OrderService {
    @Autowired
    private PaymentService paymentService; // ✅ 정상 동작!
    
    public void processOrder() {
        paymentService.processPayment(1000); // 정상적으로 주입되어 동작
    }
}

// 애플리케이션 시작 시:
// 1. 스프링이 OrderService 클래스를 발견
// 2. OrderService 인스턴스를 생성  
// 3. @Autowired를 발견하고 PaymentService를 찾아 주입
// 4. 완전한 OrderService 빈이 컨테이너에 등록됨

동작하지 않는 경우 ❌

개발자가 직접 new 키워드로 객체를 생성하는 경우

public class ManualOrderService {  // @Service 애너테이션 없음!
    @Autowired  
    private PaymentService paymentService; // ❌ 동작하지 않음!
    
    public void processOrder() {
        // paymentService는 null 상태!
        paymentService.processPayment(1000); // NullPointerException 발생!
    }
}

public class TestMain {
    public static void main(String[] args) {
        // 개발자가 직접 객체 생성
        ManualOrderService service = new ManualOrderService(); 
        
        // 이 시점에서:
        // 1. 자바는 단순히 ManualOrderService 인스턴스만 생성
        // 2. 스프링은 이 객체의 존재를 모름  
        // 3. @Autowired는 그냥 무시됨
        // 4. paymentService 필드는 null 상태로 남음
        
        service.processOrder(); // 💥 NullPointerException!
    }
}

왜 이런 차이가 생길까?

스프링 관리 객체 (빈)의 생성 과정:

// 스프링의 빈 생성 과정 (의사코드)
public Object createBean(Class<?> clazz) {
    // 1. 객체 생성
    Object instance = clazz.getDeclaredConstructor().newInstance();
    
    // 2. @Autowired 필드들을 스캔
    Field[] fields = clazz.getDeclaredFields();
    for (Field field : fields) {
        if (field.isAnnotationPresent(Autowired.class)) {
            // 3. 해당 타입의 빈을 컨테이너에서 찾기
            Object dependency = findBeanByType(field.getType());
            // 4. 리플렉션으로 필드에 주입
            field.setAccessible(true);
            field.set(instance, dependency);
        }
    }
    
    return instance; // 완전히 조립된 객체 반환
}

일반 객체의 생성 과정:

// 일반 자바의 객체 생성 과정
ManualOrderService service = new ManualOrderService();
// 1. 객체 생성 끝!
// 2. @Autowired? 그게 뭔지 모르겠는데... (무시)
// 3. paymentService 필드는 null 상태로 남음

실무에서 자주 하는 실수

@Service
public class UserService {
    @Autowired
    private EmailService emailService;
    
    public void sendWelcomeEmail(String email) {
        // 이렇게 직접 생성하면 @Autowired가 동작하지 않음!
        UserService newService = new UserService(); // ❌ 잘못된 사용
        newService.emailService.sendEmail(email); // NullPointerException!
    }
}

올바른 사용법:

@Service  
public class UserService {
    @Autowired
    private EmailService emailService;
    
    // 다른 빈에서 이 서비스를 주입받아 사용
    public void sendWelcomeEmail(String email) {
        emailService.sendEmail(email); // ✅ 정상 동작
    }
}

@Controller
public class UserController {
    @Autowired
    private UserService userService; // 스프링이 완전히 조립된 UserService를 주입
    
    public void registerUser(String email) {
        userService.sendWelcomeEmail(email); // ✅ 정상 동작
    }
}

핵심 포인트

  • 스프링 빈 = 스프링이 생성하고 관리하는 객체
  • @Autowired = 스프링 빈에서만 동작하는 마법
  • 직접 new로 생성 = 스프링의 관리 범위를 벗어남

이것이 바로 "스프링 프레임워크"라는 이름의 의미다. 스프링이라는 '틀(Framework)' 안에서 동작할 때만 의존성 주입이라는 편리한 기능을 사용할 수 있는 것이다.


이제 전제조건을 이해했으니, @Autowired를 어떤 방식으로 사용하느냐에 따라 생성자 주입, 수정자 주입, 필드 주입으로 나뉘는 것을 구체적으로 알아보자.

생성자 주입(Constructor Injection)은 무엇인가?

객체를 생성하는 시점에 생성자의 매개변수를 통해 다른 객체에 대한 의존성을 외부에서 전달받는 방식을 말한다.

public class OrderService {
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    
    // 생성자 주입: 다른 객체(의존성)를 외부에서 주입받음
    public OrderService(PaymentService paymentService, InventoryService inventoryService) {
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
    }
}

예를들면 이런 형태인데 여기서 클래스 타입 + 매개 변수 이름 식으로 잠시동안 생성자 안에 저장된다. 여기서 매개 변수의 이름을 사용해서 변수에 할당하거나 특정 계산 작업 등을 수행하거나 할 수 있다.

가장 중요한 포인트는 주입 대상이 무엇(What)이냐가 아니라, '어떤 방식(How)'으로 주입하느냐이며, 그 방식이 바로 '생성자를 통하는 것'이기 때문에 '생성자 주입'이라고 부른다.

 

주입된다고 해서 외부의 값을 그대로 할당만 하는 것은 아니다. 생성자는 객체 초기화를 위해 능동적인 계산을 수행하고, 코드가 복잡해질 경우 메서드를 호출하는 중요한 역할도 담당한다.

계산 및 가공

public class OrderItem {
    private final int totalPrice;
    // ...

    public OrderItem(String itemName, int price, int quantity) {
        // 유효성 검사
        if (price <= 0 || quantity <= 0) {
            throw new IllegalArgumentException("가격과 수량은 0보다 커야 합니다.");
        }
        
        // 받은 값으로 새로운 값 계산 후 할당
        this.totalPrice = price * quantity; 
        this.itemName = itemName;
        // ...
    }
}

private 헬퍼 메서드 호출

생성자 내부의 초기화 로직이 복잡해지면, 가독성과 유지보수를 위해 각 로직을 private 헬퍼(Helper) 메서드로 분리하여 호출하는 것이 권장된다. 이를 통해 생성자는 '초기화 과정의 목차'처럼 간결해진다.

public class Order {
    private final List<OrderItem> items;
    private final int totalAmount;

    public Order(List<OrderItem> items) {
        // 복잡한 로직을 private 메서드에 위임하여 호출
        validateItems(items);
        this.totalAmount = calculateTotalAmount(items);
        this.items = items;
    }

    private void validateItems(List<OrderItem> items) {
        // ... 유효성 검사 로직 ...
    }

    private int calculateTotalAmount(List<OrderItem> items) {
        // ... 총액 계산 로직 ...
    }
}

 

private 헬퍼 메서드란 무엇인가?

말그대로 헬퍼(도와주는 역할)을 하는 private 메서드다. 여기서 private는 자바의 접근 제어자(Access Modifier)를 사용한 것으로 private 메서드는 외부에서는 접근할 수 없고, 오직 같은 클래스 내부에서만 사용할 수 있는 메서드를 말한다. 즉 생성자 내부의 코드가 너무 길어지면 가독성의 문제가 생기니 복잡한 로직을 의미 있는 단위로 나누어 별도 메서드로 만들고 호출해서 생성자를 깔끔하게 구성하는 용도라고 보면 된다. 또한 같은 로직이 필요한 생성자가 여러개라면 재사용이 가능하단 장점도 있다.

수정자(Setter) 주입이란 무엇인가?

이름 그대로, 객체가 생성된 이후에 set으로 시작하는 수정자(Setter) 메서드를 통해 의존성을 주입받는 방식이다. 생성자 주입이 '객체의 탄생과 동시에 필수 부품을 장착하는 것'이라면, 수정자 주입은 '일단 출고하고, 나중에 필요한 옵션 부품을 추가로 장착하는 것' 에 비유할 수 있다.

  • 생성자 주입 (엔진) : 자동차는 '엔진' 없이는 아예 만들어질 수 없다. 필수 부품이다.
  • 수정자 주입 (내비게이션): 자동차는 '내비게이션'이 없어도 운행이 가능하다. 있으면 편리한 선택적(Optional) 부품이다. 이 내비게이션을 출고 후에 추가로 장착하는 과정이 바로 수정자 주입이다.
// 내비게이션 인터페이스와 구현체
public interface Navigation {
    void findRoute();
}

@Component
public class GpsNavigation implements Navigation {
    @Override
    public void findRoute() {
        System.out.println("GPS 경로를 탐색합니다.");
    }
}

// 자동차 클래스
@Component
public class Car {
    
    // 1. 의존성을 담을 필드. final로 선언할 수 없음!
    private Navigation navigation;

    // 2. 기본 생성자: 일단 자동차 객체부터 생성한다.
    //    이 시점에는 navigation 필드는 null 이다.
    public Car() {
        System.out.println("Car 객체 생성 완료! (아직 내비게이션 없음)");
    }

    // 3. @Autowired가 붙은 Setter 메서드
    //    스프링이 이 메서드를 호출하여 의존성을 주입한다.
    @Autowired
    public void setNavigation(Navigation navigation) {
        System.out.println("내비게이션 부품 장착!");
        this.navigation = navigation;
    }

    public void drive() {
        System.out.println("자동차가 주행합니다.");
        if (navigation != null) {
            // 4. 주입받았다면(null이 아니라면) 기능 사용
            navigation.findRoute();
        } else {
            System.out.println("내비게이션이 없어 경로 탐색을 할 수 없습니다.");
        }
    }
}

언제 사용하는가?

  1. 선택적(Optional) 의존성: 해당 의존성이 없어도 객체의 핵심 기능이 동작하는 경우
  2. 런타임 의존성 교체: 애플리케이션 실행 중에 의존성을 동적으로 변경해야 하는 경우
    • A/B 테스트: 사용자 그룹에 따라 다른 알고리즘이나 서비스를 제공
    • 기능 토글(Feature Toggle): 특정 기능을 켜고 끌 수 있는 스위치 역할
    • 환경별 전략 변경: 개발/운영 환경에 따라 다른 구현체 사용
@Service
public class PaymentService {
    private PaymentProcessor processor;
    
    public PaymentService() {
        // 기본 프로세서로 시작
        this.processor = new BasicPaymentProcessor();
    }
    
    // 런타임에 프로세서 교체 가능
    @Autowired(required = false)
    public void setPaymentProcessor(PaymentProcessor processor) {
        this.processor = processor;
    }
    
    public void processPayment(Order order) {
        // 관리자가 설정한 전략에 따라 다른 처리 방식 사용
        processor.process(order);
    }
}

주의사항과 한계

불변성 포기

수정자 주입을 사용하면 필드를 final로 선언할 수 없어 객체의 불변성이 깨진다. 이는 멀티스레드 환경에서 예상치 못한 문제를 일으킬 수 있다.

의존성 누락 위험

객체 생성 시점에 의존성이 주입되지 않으므로, 개발자가 실수로 의존성 주입을 빠뜨릴 가능성이 있다.

결론: 제한적이지만 명확한 용도

수정자 주입은 생성자 주입에 비해 위험요소가 있지만, 명확한 목적이 있을 때는 여전히 유용한 도구다.

  • 일반적인 의존성: 생성자 주입 사용 (권장)
  • 선택적이고 변경 가능한 의존성: 수정자 주입 고려
  • 런타임 교체가 필요한 의존성: 수정자 주입이 적절할 수 있음

핵심은 "왜 이 방식을 선택했는가?"에 대한 명확한 이유가 있어야 한다는 것이다. 단순히 코드 작성의 편의를 위해서라면 생성자 주입을 선택하는 것이 더 안전하고 바람직하다.

필드 주입이란 무엇인가?

이름 그대로, 클래스의 필드(멤버 변수)에 @Autowired 애너테이션을 직접 붙여서 의존성을 주입하는 방식이다. 생성자도, Setter 메서드도 필요 없다. 가장 간결해 보이지만 가장 위험하여 강력하게 비권장되는 방식이다.

필드 주입은 어떻게 동작하는가? (매개변수 없이?)

필드 주입은 생성자나 수정자(Setter)처럼 매개변수를 통해 의존성을 전달받지 않는다.

@Service
public class MemberService {
    @Autowired
    private MemberRepository memberRepository;
    public void join(Member member) {
        memberRepository.save(member);
    }
}

@Repository
public class MemberRepository {
    public void save(Member member) {
        // 데이터베이스에 회원을 저장하는 로직
        System.out.println(member.getName() + " 회원 저장 성공!");
    }
}

// 회원 정보를 담는 간단한 Member 클래스
public class Member {
    private String name;
    public Member(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
}
  • 코드 작성 시점 : MemberRepository memberRepository; 코드는 선언만 되어 있을 뿐, 값이 없는 null 상태의 일반 필드 변수와 같다. @Autowired는 스프링에게 보내는 '신호' 또는 '꼬리표'에 불과하다.
  • 스프링 실행 시점 (런타임) : 스프링이 객체를 생성한 후, 이 @Autowired 꼬리표를 발견한다. 그리고 리플렉션(Reflection)이라는 강력한 기술('마스터 키'나 '백도어'에 비유)을 사용해, 객체의 private 필드를 강제로 열고 준비된 MemberRepository  Bean을 직접 꽂아 넣는다.

여기서 동작은 크게 선언, 요청, 실행 3가지 부분으로 나눌 수 있다.

  • 선언: MemberRepository memberRepository;는 MemberService 클래스 내부에 공간(필드)을 선언한 것일 뿐, 처음에는 비어있다. (null 상태). 이 시점에서 해당 필드는 아무 의미를 가지지 못한다.
  • 요청: @Autowired는 스프링에게 "이 memberRepository 필드는 내가 직접 채우지 않을 테니, 나중에 네가 알아서 채워줘!"라고 표시하는 것과 같다. 스프링에게 의존성 주입을 위임하는 단계다.
  • 실행: MemberService의 인스턴스가 생성된 후, 스프링은 @Autowired 표시를 보고 자신이 관리하는 부품 상자(스프링 컨테이너)에서 MemberRepository 타입의 부품(빈)을 찾아 그 자리에 정확하게 끼워 넣어준다. 즉, 이전에 선언만 된 MemberRepository memberRepository;라는 필드에 실질적인 값을 찾아서 할당한다. 

무엇이 문제인가 : 3가지 핵심 위험

위험 1 : 순환 참조라는 시한폭탄

  • 순환 참조라는 시한폭탄 : A <-> B처럼 서로가 서로를 주입하는 '설계 실수'가 있을 때, 생성자 주입은 애플리케이션 시작 시점에 즉시 오류를 발생시켜 실수를 알려준다(Fail-Fast). 반면, 필드 주입은 이 문제를 숨긴 채 실행을 성공시키고, 실제 코드가 호출될 때 StackOverflowError를 터뜨려 디버깅을 매우 어렵게 만든다.

위험 2 : 객체지향 원칙의 파괴

'필드 변수를 매개변수로 받는 방식(생성자/수정자 주입)'과의 근본적인 차이는 '객체의 자율성과 캡슐화' 를 파괴하는 데 있다.

  • 생성자 주입 : 객체가 생성자를 통해 "나는 이런 부품이 필요하다"고 세상에 능동적으로 명시하고, 공개된 통로(현관문)를 통해 안전하게 전달받는다. 객체는 자신의 필요를 스스로 제어하며, 스프링이 없어도 순수 자바 코드로 객체를 만들 수 있는 독립적인 부품(POJO)이다.
  • 필드 주입 : 객체는 가만히 있는데 외부(스프링)에서 벽을 뚫고(리플렉션) 내부 상태를 강제로 수정한다. 객체는 자신의 의존성을 제어할 수 없으며, 스프링의 '마법' 없이는 절대로 제대로 동작할 수 없는 스프링 전용 부품이 되어 버린다.

위험 3 : 테스트의 어려움과 '기만적인 성공'

이렇게 생각할 수 있다. 어차피 빈을 생성자로 주입하든 필드로 주입든 모두 스프링이 대신해주는 것인데 어째서 한쪽은 위험하고 한쪽은 권장되는가? 필드 주입은 겉보기에는 정상 동작하지만, '완벽한 개발자'도 피할 수 없는 치명적인 문제들이 숨어있다.

  • '기만적인 성공' : 필드 주입의 가장 큰 문제는, 필드가 null이라 본질적으로 불완전한 객체가 생성되었음에도 불구하고, 스프링이 뒤에서 '땜질'을 해주기 때문에 마치 문제가 없는 것처럼 착각하게 만든다는 점이다.

기본적으로 생성자는 '객체를 완전히 사용할 수 있는 상태로 만들어 탄생시키는 것'이 책임이자 계약이다. 즉 필드 값을 null로 한다면 그 필드는 존재할 이유 자체가 없는 것이다.(선택적 의존성을 위해 일부러 비워두는 경우를 제외한다면)

  • 동작 : new MemberService(memberRepository)를 호출하면, 자바는 MemberService 생성자에게 memberRepository라는 매개변수로 값을 전달한다. 생성자는 이 값을 받아 자신의 필드를 채우고, 완벽한 상태의 객체를 반환한다.
  • 실패 : 만약 memberRepository 값을 주지 않고 new MemberService()를 호출하면 (해당 생성자가 없다면), 자바는 "계약 위반이야! 이 객체는 이렇게 만들 수 없어!" 라며 컴파일 시점이나 런타임 시점에 즉시 오류를 발생시킨다. 이것은 정직하고 명확한 실패다.

그런데 필드 주입은 이 '생성 계약'을 완전히 무시한다.

  • 동작 (두 단계로 분리):
    1. 성공처럼 보이는 1단계 (객체 생성): new MemberService() (기본 생성자)를 호출하면, 자바는 객체를 성공적으로 생성한다. 하지만 이 객체 내부의 memberRepository 필드는 null이다. 즉, 객체는 태어났지만, 막상 필드의 값은 비어있어 제 기능을 전혀 할 수 없는 '속 빈 강정' 상태다.
    2. 보이지 않는 2단계 ('땜질'): 그 후에, 스프링이 리플렉션이라는 특수 기술로 이 '속 빈 강정' 객체에 접근해서 memberRepository 필드에 실제 빈을 슬쩍 넣어준다.
  • 기만(Deception)의 핵심 : 바로 1단계가 성공한다는 점이다. 개발자나 자바 시스템 입장에서는 new MemberService()가 성공했으니, 당연히 완전한 객체가 만들어졌다고 착각하기 쉽다. 하지만 실상은 불완전한 객체가 생성된 후, 외부의 힘(스프링)에 의해 '사후 처리'가 이루어지는 것이다. 본질적인 실패(불완전한 생성)를 교묘하게 감추고 성공으로 포장했기 때문에 '기만적인 성공'이라고 부르는 것이다.

흔한 반론과 그에 대한 답변

반론 1 : "코드가 잘 작동하는데, 굳이 고칠 필요가 있나?"

프로그래밍과 엔지니어링에서 사용하는 격언 중에 "If it ain't broke, don't fix it" (잘 동작하고 있다면 건드리지말라) 같은 말이  있긴하다. 하지만 이 격언을 실제  필드 주입에 적용하기엔 무리가 있다.

필드 주입은 이미 잘 동작하는 것을 건드리는 문제가 아니라, 설계 단계부터 잠재적 위험을 가진 편법을 사용하여 '일단 동작하는 것처럼' 보이게 만드는 방법이기 때문이다.

간단하게 말하면 객체지향 설계 원칙상 불안정하고 의존적인 구조를 가졌음에도 불구하고, 프레임워크의 강력한 '마법' 덕분에 문제가 없는 것처럼 동작해버리는 것이며 이것이 잠재적인 위협으로 작용할 수 있어서 사용 자체를 꺼리게 되는 것이다.

반론 2 : "그렇다면 이 기능은 왜 존재하는가?"

만약 필드 주입이 정말로 '절대악'이었다면 프레임워크 차원에서 기능을 막았을 것이다. 그럼에도 기능이 남아있는 이유는 ①테스트 코드 작성 시의 간결함, ②아주 단순한 애플리케이션에서의 편의성, ③오래된 레거시 코드와의 호환성 등 제한적인 상황에서의 이점을 무시할 수 없었기 때문이다. 하지만 이러한 특정 상황에서의 편의성은 앞서 설명한 수많은 위험을 감수해야 하는 것에 비해 얻을 수 있는 것이 적다. 즉 High Risk Row Return 구조라는 것이다. 기본적으로 스프링이라는 것은 엔터프라이즈급, 즉 기업 수준의 사용을 염두에 두고 프레임워크를 만들었는데 이런식으로 거대한 위험성을 감수하는 코드를 사용할 이유가 없는 것이다.

최종 결론 : 필드 주입은 '기술 부채'를 예약하는 행위다

필드 주입은 '자동차 불법 튜닝' 과 같다. 코드 몇 줄을 줄여주는 사소한 편리함(이점)을 얻는 대가로, 객체 지향의 핵심 원칙(안전장치)들을 모두 해제하고, 개발자의 실수를 유발하며, 순환 참조나 테스트의 어려움 같은 치명적인 위험(결함)을 떠안는 방식이다.

마치 자동차를 불법 튜닝하는 것과 같다.

  • 유혹적인 편리함 (겉으로 보이는 이점)
    • 불법 튜닝 : 당장의 출력 향상이나 멋진 외관, 배기음 등 즉각적이고 자극적인 만족감을 준다.
    • 필드 주입: 코드 몇 줄이 줄어들고, 생성자를 신경 쓸 필요가 없어 당장 코딩하기에 매우 편리해 보인다.
  • 안전장치 해제 (설계 원칙 위반)
    • 불법 튜닝 : 배기가스 저감 장치나 소음기 같은 안전 및 환경 규제(규칙)를 무시하거나 제거한다.
    • 필드 주입: 캡슐화, 불변성, 의존성 명시 등 객체 지향의 핵심적인 설계 원칙(규칙)을 무시하고 우회한다. (private을 뚫고, final을 포기한다.)
  • 숨겨진 위험과 책임 (치명적인 단점)
    • 불법 튜닝 : 엔진 수명이 단축되거나, 주행 안정성이 떨어지거나, 사고 시 더 큰 위험을 초래할 수 있다. 결정적으로 정기 검사 때 100% 문제가 된다.
    • 필드 주입 : 순환 참조, 테스트의 어려움 등 소프트웨어의 안정성과 유지보수성을 저해하는 심각한 문제가 숨어있다. 결정적으로 단위 테스트나 리팩토링 때 100% 문제가 된다.
  • '정석'이 아닌 '우회로'
    • 불법 튜닝 : 자동차 제조사(스프링 팀)가 보증하고 권장하는 방식이 아닌, 편법적인 '우회로'다.
    • 필드 주입 : 프레임워크가 제공하는 정석적인 방법(생성자 주입)이 아닌, 리플렉션이라는 강력한 기술을 남용하는 '우회로'다.