본문 바로가기
Tech Stack/Backend

🍃자동 의존관계 주입 방법과 컴포넌트 스캔

by 박수무당벌레 2026. 6. 17.

1. 의존성(Dependency)과 주입(Injection)이란?

  • 스프링 프레임워크의 DI: 사용할 객체를 개발자가 직접 만들지 않고, 스프링에게 만들어서 주입하는 것을 위임한다. 이로 인해 객체의 제어권이 개발자에서 스프링으로 넘어가게 되는데, 이를 IoC(제어의 역전)라고 부르며, DI는 이 IoC를 구현하는 대표적인 방법.
  • 의존성 (Dependency): 한 클래스가 작동하기 위해 다른 클래스를 필요로 하는 상태를 말함(어떤 클래스가 내부 필드(멤버 변수)로 다른 클래스의 인스턴스를 가지는 것). 
    • 예: Car 클래스가 작동하려면 Engine 클래스가 필요함 (Car는 Engine에 의존한다)
  • 주입 (Injection): 외부에서 객체를 직접 생성하여 필요한 곳에 전달해 주는 행위.

📊 과거 vs 현재

  • 과거의 방식 (수동 주입): 개발자가 직접 코드로 연결해 줌
    class Car {
        private Engine engine;
    
        public Car() {
            this.engine = new GasEngine(); // 자동차가 직접 GasEngine을 선택하고 생성함
        }
    }
  • 자동 주입 방식: @Autowired라는 애노테이션을 붙여두면, 스프링이 알아서 스프링 컨테이너를 뒤져 맞는 의존성을 꽂아줌.

💡 자동 의존관계 주입의 핵심 축: 컴포넌트 스캔

자동 주입이 가능하려면 스프링이 "어떤 객체들이 존재하는지" 알아야 함.

  1. @Component 스캔: 클래스 위에 @Component(또는 @Service, @Repository, @Controller)를 붙여두면, 스프링이 구동될 때 이 클래스들을 찾아 스프링 컨테이너에 객체(빈)로 자동 등록.
  2. @Autowired 주입: 등록된 객체들 사이에서 의존관계(누가 누구를 필요로 하는지)를 찾아 자동으로 연결(주입)해 줌.
    • 스프링 컨테이너가 형태(타입)가 일치하는 스프링 빈을 찾아 자동으로 주입해 줌.
    • 주입 받으려는 객체들이 먼저 스프링 빈으로 등록되어 있어야 작동한다.

2. DI 자동 주입 방식 (@Autowired 사용 위치)

① 생성자 주입 (권장)

  • ⭐ 현재 스프링에서 가장 권장하는 방식이다. 클래스의 생성자를 통해 의존 관계를 주입받는 방법.
  • 특징:
    • 불변성 (Immutability) 보장: 객체 생성 시점에 딱 1번만 호출되므로, 의존성 객체(주입받은 객체)가 변하지 않도록 필드에 final 키워드를 사용할 수 있음.
      • 한 번 주입된 의존관계는 애플리케이션 종료 시점까지 변하지 않아야 안전함. 
    • 누락 방지 (NullPointerException 예방): 컴파일 시점에 의존관계 누락을 잡아낼 수 있어 안전하다
      • 프레임워크 없이도 순수한 자바 코드로 테스트 코드를 작성할 때, 의존성 주입을 누락하면 컴파일 에러가 발생하여 실수를 곧바로 잡아줌.
    • 순환 참조 방지: 애플리케이션 구동 시점에 순환 참조(A  B, B  A) 에러를 발생시켜 문제를 조기에 발견할 수 있음.
@Component
public class OrderService {

    private final MemberRepository memberRepository;

    // 스프링 4.3부터 생성자가 1개만 있으면 @Autowired 생략 가능 (자동 주입)
    @Autowired 
    public OrderService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

💡스프링이 자동으로 객체를 생성하고 주입하는 과정 (①▶️②)

  1. 컴포넌트 스캔 또는 설정 파일 확인: 등록해야 할 빈(@Component, @Service 등)의 목록을 확인
  2. 의존 관계 생성 순서 결정: A라는 객체가 B를 필요로 한다면(생성자 주입), 스프링은 B를 먼저 생성한 후에 B를 생성자에 넣어서 A를 생성.

② 수정자 주입 (Setter  주입)

  • setXxx() 메서드를 통해 의존관계를 주입하는 방식.
  • 특징:
    • 선택, 변경 가능성이 있을때 사용: 주입받는 객체가 필수적이지 않거나(@Autowired(required = false)), 실행 도중에 의존관계를 변경해야 할 때 사용. (실제 개발에서는 의존성이 런타임에 바뀔 일이 거의 없어 자주 쓰이지 않음.)
      • final 키워드를 사용할 수 없음.
    • 한 번에 하나의 필드 주입: 보통 하나의 필드당 하나의 메서드를 가짐. (JavaBeans)
@Component
public class OrderService {

    private MemberRepository memberRepository;

    @Autowired
    public void setMemberRepository(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

③ 필드 주입 (과거에 자주 쓰였으나 현재는 안 좋은 방법으로 판명)

  • 필드에 @Autowired 어노테이션을 바로 붙여서 주입하는 방식. 권장하지 않음.
  • 특징:
    • 장점: 코드가 가장 간결, 깔끔해 보이는 장점이 있음.
    • 단점: 외부에서 변경하기가 어렵고 내부적으로 결합도가 높아져 테스트 코드를 작성하기 어렵다는 치명적인 단점이 있어 비권장.
      • 반드시 스프링 컨테이너가 구동되는 환경이어야만 작동하기 때문. (애플리케이션의 실제 코드와 상관없는 @SpringBootTest 환경 등에서만 제한적으로 사용된다.) 
@Component
public class OrderService {

    @Autowired
    private MemberRepository memberRepository;
}

④ 일반 메서드 주입 (거의 안 씀)

  • 수정자 주입과 유사하게 아무 일반 메서드를 통해서 의존관계를 주입받는 방식. 실제로 거의 사용되지 않음.
  • 특징:
    • 불변성을 보장하지 못함(변경 가능성): 객체가 생성된 이후에도 일반 메서드를 아무나 호출할 수 있게 열려 있음. (의존 관계가 도중에 변경될 위험 있음)
    • 역할이 모호함: 수정자 주입과 단점을 공유하면서, 직관성은 더 떨어짐. 
    • 한 번에 여러 필드를 주입: 한 번에 여러 개 파라마터를 받아 여러 필드를 동시에 초기화할 때 사용
@Component
public class OrderService {

    private MemberRepository memberRepository;

    @Autowired
    public void init(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

3. 롬복(@RequiredArgsConstructor)을 통한 깔끔한 생성자 주입

생성자 주입이 좋지만, 필드가 늘어날 때마다 생성자를 매번 새로 쓰고 수정하는 게 꽤 귀찮음. 이때 롬복(Lombok) 라이브러리의 @RequiredArgsConstructor를 사용하면 코드가 획기적으로 줄어든다. 롬복 자세한 설명은 여기서 참고.

📊 롬복 적용 전 vs 후

  • 롬복 미적용 (전형적인 생성자 주입)
@Component
public class OrderService {
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderService(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}
  • 롬복 적용 (@RequiredArgsConstructor)
  @Component
  @RequiredArgsConstructor // final이 붙은 필드를 모아서 생성자를 자동으로 만들어줍니다!
  public class OrderService {
      private final MemberRepository memberRepository;
      private final DiscountPolicy discountPolicy;
  }

💡 핵심 요약
현재 모던 스프링 개발에서는 @Component 계열 어노테이션으로 빈을 자동 등록하고, 의존성 주입은 final 필드 + 롬복의 @RequiredArgsConstructor 조합으로 생성자 주입을 완성하는 것이 표준. (코드 깔끔 + 객체 지향적으로도 가장 안전한 설계 방식)


4. 주입 시 발생하는 충돌 해결 방안

자바 코드로 직접 주입하거나(과거 xml 방식 포함), 개발자가 객체를 생성할 때는 어떤 객체(인스턴스)를 넣을지 명확하게 집어서 고정하기 때문에 충돌이 날 일이 없는 반면, 자동 주입(@Autowired)으로는 충돌이 발생한다.

1) 충돌이 발생하는 이유 (@Autowired)

자동 주입은 스프링이 컨테이너를 뒤져서 "타입(Type)"을 기준으로 빈을 찾아 꽂아주기 때문.

  • 문제 상황: 만약 DiscountPolicy라는 인터페이스가 있고, 이를 구현한 FixDiscountPolicy와 RateDiscountPolicy가 둘 다 @Component로 등록되어 있다면?
    • 두 클래스는 이름(빈 이름)이 서로 다르기 때문에, 스프링 컨테이너에 둘 다 아무런 충돌 없이 정상적으로 등록될 수 있음.
    • 등록된 빈들을 OrderService 같은 다른 클래스에 주입할 때 문제 발생
  • 자동 주입 시점에서의 작동:
    1. 스프링은 @Autowired를 만나면 컨테이너에서 DiscountPolicy라는 '타입(인터페이스)'을 가진 빈을 찾음
    2. DiscountPolicy 타입을 주입하려고 할 때 둘 다 똑같은 타입인데 어떤 걸 넣어줘야 할지 결정을 내리지 못하고 컴파일/구동 에러(NoUniqueBeanDefinitionException)를 던짐.
  • 결론: @Primary나 @Qualifier 같은 해결 방안이 필요한 것.
@Component
public class OrderService {
    private final DiscountPolicy discountPolicy;

    @Autowired // 💥 여기서 스프링의 고민과 충돌이 시작
    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

2) 대표적인 해결방안 3가지

진영별 어노테이션 분류는 여기서 확인.

① @Primary 사용하기 (+ Fallback)

  • 동일한 타입의 빈이 여러 개 있을 때, 우선순위를 지정하는 방법. 여러 후보 중 하나의 클래스에 @Primary를 붙여두면, 스프링은 고민하지 않고 해당 빈을 먼저 주입.
  • 우선순위: @Qualifier가 지정되면 해당 빈이 가장 먼저 주입되며, 지정되지 않았을 때는 @Primary가 붙은 빈이 우선권을 가짐.
  • 장점(가장 추천/편리): 주입받는 코드(OrderService 등)를 전혀 손대지 않고, 구현체 클래스에 어노테이션만 붙이면 되므로 매우 깔끔.
  • * Fallback: 어노테이션 아님. 주입할 수 있는 빈이 없을 때 대안으로 작동하는 기능이지만, 실무에서 사용도는 떨어지는 편
@Component
@Primary // 의존성 주입 시 이 빈이 최우선권을 가집니다!
public class RateDiscountPolicy implements DiscountPolicy {}
더보기

✨ 스프링 자동 주입의 빈 선택 서열 정리 (우선순위)

 

1) 어노테이션 우선 순위 단계

동일한 타입의 빈이 여러 개 있을 때, 스프링은 아래와 같은 우선순위 단계(Resolution Process)를 거쳐 주입할 빈을 결정한다.

  • 1순위: @Qualifier가 지정된 빈
    • 주입받는 곳과 빈 등록 양쪽에 @Qualifier("이름")으로 매칭되는 빈이 있다면 무조건 최우선으로 주입.
  • 2순위: @Primary가 붙은 빈
    • @Qualifier로 명확히 집어 지정하지 않았다면, 후보들 중 @Primary가 선언된 빈이 대장(메인) 자격으로 주입.
  • 3순위: Fallback (대안/예비 빈)
    • @Qualifier도 없고, @Primary가 붙은 메인 빈도 없을 때, 남은 후보 빈들 중에서 우선순위가 밀려나 있던 예비 빈(Fallback Bean)이 선택됨.
      • 암시적 예비 빈: 어노테이션이 붙지 않은 나머지 빈들이 자동으로 Fallback(예비) 빈이 됨.
      • 명시적 예비 빈: 스프링 부트에서 @ConditionalOnMissingBean을 사용해서 명확하게 선언 (주로 라이브러리/프레임워크 설계 시)
      • @Resource 에서 Fallback 방식 (아래 설명)도 있음.
    • 만약 이 단계에서도 명확한 빈을 찾지 못하면(동등한 후보가 여러 개 남으면) 그제야 NoUniqueBeanDefinitionException 에러가 발생.

2) 왜 Fallback은 실무에서 사용도가 떨어질까? (자동 주입 매커니즘)

  • 자동 주입을 쓸 때 개발자는 보통 A가 주입될 것이라고 명확히 예상하고 코드를 작성. 그런데 메인 빈 설정이 꼬여서 의도치 않게 Fallback(예비) 빈이 자동으로 주입되어 버리면, 프로그램은 에러 없이 굴러가지만 내부적으로는 잘못된 로직(예: 할인 정책이 적용 안 됨)이 실행될 수 있음.
  • 따라서 실무에서는 차라리 Fallback이 작동하기 전에 에러를 명확하게 터트려 주는 설계를 선호하기 때문에 의도적인 Fallback 빈 구성은 잘 쓰이지 않는 것.

② @Qualifier로 이름 지정하기

빈에 별칭(이름)을 부여하고, 주입받는 곳에서 해당 이름의 빈을 넣어달라고 명확하게 콕 집어 지정하는 방법.

  • 1단계: 빈 등록 시 이름 지정
    @Component
    @Qualifier("mainDiscountPolicy")
    public class RateDiscountPolicy implements DiscountPolicy {}
    
  • 2단계: 주입받을 때 이름 명시
    @Component
    public class OrderService {
        private final DiscountPolicy discountPolicy;
    
        // 생성자 주입 시 파라미터 앞에 @Qualifier를 붙여줌.
        public OrderService(@Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
            this.discountPolicy = discountPolicy;
        }
    }

💡 실무 Tip: @Primary vs @Qualifier 무엇을 쓸까? 보통 메인으로 사용하는 빈에는 @Primary를 붙여서 편하게 자동 주입을 쓰고, 가끔씩만 쓰는 서브(Sub) 빈을 불러올 때 필요한 곳에만 @Qualifier를 명시하는 방식으로 조합하여 자주 사용한다. (스프링에서는 항상 좁은 범위인 @Qualifier가 높은 우선순위를 가짐.)

③ 이름으로 찾는 @Resource (자바 표준)

  • JSR-250 표준 기반의 주입 어노테이션으로, 스프링 IoC 프레임워크에 종속되지 않는 코드를 작성할 수 있음.
  • 특징: 필드 주입이나 Setter 주입 형태로 주로 사용되며, 스프링의 생성자 주입 표준 체계와는 약간 결이 달라 현대 스프링 개발에서는 @Primary와 @Qualifier 조합을 조금 더 권장하는 편.
    • @Autowired가 타입(Type) 중심이라면, @Resource는 이름(Name)을 우선순위로 두고 빈을 찾음.
    • Fallback 매커니즘 작동: 지정된 name을 1순위로 찾지만, 만약 그 이름의 빈이 없으면 에러를 내기 전에 Fallback으로 '타입'을 기준으로 매칭되는 빈이 있는지 한 번 더 검색
  • 💡 Tip!: @Resource를 통해 ApplicationContext 자기 자신을 빈으로 주입받아 사용할 수도 있음.
@Component
public class OrderService {
    
    // 빈의 등록 이름(rateDiscountPolicy)과 필드명을 맞춰주면 알아서 찾아옵니다.
    @Resource(name = "rateDiscountPolicy")
    private DiscountPolicy discountPolicy;
}

(참고) 커스텀 Qualifier 어노테이션

  • @Qualifier("mainDiscountPolicy")처럼 문자를 직접 적으면 오타가 나도 컴파일러가 잡아주지 못한다는 단점이 있음. 이럴 때는 나만의 어노테이션을 만들어 안전하게 관리할 수 있다.
    • @Genre("Action")이나 값 없는 @Offline처럼, @Qualifier를 메타 어노테이션으로 가지는 어노테이션을 만들어 가독성을 높일 수 있음. (사실상 특정 이름을 지정한 Qualifier와 등가)
  • 이렇게 만들어 두면, 주입받을 때 문자가 아니라 @MainDiscountPolicy라는 어노테이션만 깔끔하게 붙여서 오타 실수를 원천 차단할 수 있음.
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Qualifier("mainDiscountPolicy") // Qualifier를 내포함
public @interface MainDiscountPolicy { }

5. 왜 자동 주입을 써야 할까? (장점)

  • 낮은 결합도 (유연성): OrderService는 어떤 구체적인 할인 정책(Fix인지 Rate인지)이 들어올지 몰라도 됨. 인터페이스만 바라보고 있으면 스프링이 알아서 넣어주기 때문에 코드가 유연해짐.
  • 생산성 향상: 수백, 수천 개의 클래스가 있는 대형 프로젝트에서 일일이 조립 코드를 적지 않아도 되므로 개발 속도가 압도적으로 빨라짐.
  • 비즈니스 로직 집중: 객체를 생성하고 연결하는 귀찮은 작업은 스프링에게 맡기고, 개발자는 핵심 기능 개발에만 집중할 수 있음.
ℹ️ DI 자체의 장점
결합도 감소 (Loose Coupling): 클래스 간의 관계가 느슨해져서, 하나의 컴포넌트를 변경해도 다른 컴포넌트에 미치는 영향이 줄어듦.
재사용성과 유지보수성 향상: 코드 수정이 줄어들고 모듈화가 잘 되어 관리가 쉬워짐.
테스트 용이성: 진짜 객체 대신 가짜 객체(Mock Object)를 주입하기 쉬워지므로, 단위 테스트(Unit Test)를 작성하기가 매우 수월해잠.
더보기

ℹ️ 전통적인 방식과 비교

 

1) DI가 없는 경우 (전통적인 방식)

  • 설명: 자동차 인스턴스를 만들 때, 자동차 내부에서 직접 엔진을 생성한다.
  • 문제점: 만약 엔진을 ElectricEngine으로 바꾸고 싶다면, Car 클래스의 코드 자체를 수정해야 함. → 결합도(Coupling)가 높아서 유연성이 떨어짐.
class Car {
    private Engine engine;

    public Car() {
        this.engine = new GasEngine(); // 자동차가 직접 GasEngine을 선택하고 생성함
    }
}

 

2) DI가 적용된 경우 (스프링 방식)

  • 설명: 자동차가 직접 엔진을 만들지 않고, 외부(스프링 컨테이너)에서 만들어진 엔진을 주입받음.
  • 장점: Car 코드를 전혀 손대지 않고도 외부 설정만 변경하여 GasEngine이나 ElectricEngine을 마음대로 갈아끼울 수 있음.
class Car {
    private Engine engine;

    // 외부에서 엔진을 주입받음 (생성자 주입)
    public Car(Engine engine) {
        this.engine = engine;
    }
}