jongkwan.dev
개발 · Essay №056

Spring DI와 IoC 컨테이너

제어의 역전과 의존관계 주입, 스프링 컨테이너의 빈 등록과 조회, 주입 방식 4가지를 정리한다

이종관2026년 3월 3일13 min read
Contents

객체 생성과 조립의 제어권을 코드 밖으로 옮기는 것이 제어의 역전(IoC)이고, 그 연결을 외부 주입으로 구현한 것이 의존관계 주입(DI)입니다.

누가 객체를 생성하고 연결하는가

스프링을 쓰기 전 코드에서는 구현 객체가 스스로 의존 객체를 만들고 골랐습니다. 예를 들어 주문 서비스가 직접 할인 정책 구현체를 new로 생성했습니다. 이러면 구현 객체가 프로그램의 제어 흐름을 직접 조종하게 됩니다.

이 구조의 문제는 클라이언트 코드가 추상화가 아니라 구현체에 묶인다는 점입니다. 할인 정책을 고정 금액에서 정률로 바꾸면 그 객체를 생성하던 코드까지 함께 고쳐야 합니다. 객체를 쓰는 책임과 생성하는 책임이 한곳에 섞여 있기 때문입니다.

그래서 생성과 조립만 담당하는 구성 객체를 분리합니다. 이 객체를 흔히 AppConfig라 부릅니다. AppConfig가 등장한 뒤로 구현 객체는 자신의 로직만 실행하고, 어떤 구현체가 주입될지는 모릅니다.

프로그램의 흐름을 직접 다루지 않고 외부에 맡기는 것을 제어의 역전(Inversion of Control, IoC)이라 합니다. 비슷한 구분이 프레임워크와 라이브러리에도 있습니다. 개발자가 작성한 코드를 프레임워크가 대신 호출하면 프레임워크이고(JUnit이 대표 예), 그 코드가 흐름을 직접 잡으면 라이브러리입니다.

의존관계 주입의 의미

의존관계 주입(Dependency Injection, DI)은 IoC를 구현하는 대표 방식입니다. 필요한 의존 객체를 내부에서 만들지 않고 외부에서 참조값으로 받습니다. 객체 인스턴스를 생성한 뒤 그 참조를 전달해 연결하는 동작입니다.

여기서 정적 의존관계와 동적 의존관계를 구분하면 주입의 효과가 분명해집니다. 클래스 수준에서 OrderServiceImplDiscountPolicy 인터페이스에 의존한다는 사실은 컴파일 시점에 고정됩니다. 반면 실행 시점에 어떤 구현체가 연결되는지는 주입으로 바꿀 수 있습니다.

이 덕분에 클라이언트 코드를 건드리지 않고 구현체만 교체할 수 있습니다. 추상화에만 의존하므로 의존관계 역전 원칙(Dependency Inversion Principle, DIP)을 지킵니다. 확장에는 열고 변경에는 닫는 개방-폐쇄 원칙(Open-Closed Principle, OCP)도 함께 따릅니다. 조립 영역과 사용 영역이 나뉘기 때문입니다.

스프링 컨테이너와 빈

AppConfig처럼 객체를 생성하고 의존관계를 연결해 주는 것을 IoC 컨테이너 또는 DI 컨테이너라 합니다. 스프링에서는 이 컨테이너를 ApplicationContext라는 인터페이스로 다룹니다. 자바 설정 클래스로 컨테이너를 만들 때는 그 구현체인 AnnotationConfigApplicationContext를 씁니다.

설정 클래스에는 @Configuration을 붙이고, 빈으로 등록할 객체는 @Bean 메서드로 반환합니다. 컨테이너는 @Bean 메서드를 모두 호출해 반환 객체를 등록하며, 메서드 이름을 빈 이름으로 씁니다.

java
@Configuration
public class AppConfig {
 
    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
 
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

등록된 빈은 컨테이너에서 이름과 타입으로 조회합니다. 개발자가 직접 객체를 조회하던 코드가 컨테이너 조회로 바뀝니다.

java
ApplicationContext ac =
        new AnnotationConfigApplicationContext(AppConfig.class);
 
MemberService memberService =
        ac.getBean("memberService", MemberService.class);

ApplicationContext는 빈 관리 기능을 가진 BeanFactory를 상속하고, 메시지 처리나 리소스 조회 같은 부가 기능을 더합니다. BeanFactory를 직접 쓸 일은 거의 없으므로, 보통 ApplicationContext를 스프링 컨테이너라 부릅니다.

수동 등록과 컴포넌트 스캔

빈을 일일이 @Bean으로 적으면 수가 늘수록 설정이 커지고 누락도 생깁니다. 스프링은 설정에 직접 나열하지 않아도 빈을 자동 등록하는 컴포넌트 스캔을 제공합니다. 설정 클래스에 @ComponentScan을 붙이면 @Component가 달린 클래스를 찾아 빈으로 등록합니다.

java
@Configuration
@ComponentScan(basePackages = "hello.core")
public class AutoAppConfig {
}

이때 빈 이름은 클래스 이름에서 첫 글자만 소문자로 바꾼 값입니다. MemberServiceImplmemberServiceImpl이 됩니다. basePackages로 탐색 시작 위치를 지정하면 불필요한 패키지를 훑지 않습니다.

자동 등록과 함께 의존관계도 @Autowired로 자동 연결합니다. 생성자에 @Autowired를 붙이면 컨테이너가 타입이 같은 빈을 찾아 주입하며, 이는 getBean을 타입으로 호출하는 것과 같은 동작입니다. 실무에서는 계층에 맞춘 @Controller, @Service, @Repository를 주로 쓰는데, 이들 안에 @Component가 포함돼 스캔 대상이 됩니다.

구분수동 등록(@Bean)컴포넌트 스캔(@Component)
등록 방식설정에 메서드로 직접 나열애노테이션을 보고 자동 등록
빈 이름메서드 이름클래스명 첫 글자 소문자
적합한 상황기술 인프라, 의도 드러낼 소수 빈일반 애플리케이션 로직 다수

일반 애플리케이션 로직은 스캔으로 자동 등록하고, 경계가 중요한 소수 빈만 수동 등록하는 방향이 보통입니다.

주입 방식 4가지

의존관계 주입은 생성자 주입, 수정자(setter) 주입, 필드 주입, 일반 메서드 주입으로 나뉩니다. 차이는 주입 시점과 변경 가능성, 테스트 용이성에서 드러납니다.

주입 방법주입 시점final 필드권장
생성자 주입객체 생성 시 1회가능기본 권장
수정자 주입생성 후 setter 호출불가선택 의존관계에 한정
필드 주입생성 후 리플렉션으로 주입불가비권장
일반 메서드 주입생성 후 메서드 호출불가거의 안 씀

기본값은 생성자 주입입니다. 생성자는 객체를 만들 때 한 번만 호출되므로 불변·필수 의존관계에 맞습니다. 생성자가 하나뿐이면 @Autowired를 생략해도 자동 주입됩니다.

java
@Component
public class OrderServiceImpl implements OrderService {
 
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;
 
    @Autowired // 생성자가 하나면 생략 가능
    public OrderServiceImpl(MemberRepository memberRepository,
                            DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

생성자 주입을 기본으로 두는 근거는 세 가지입니다. 필드에 final을 붙일 수 있어 주입 누락을 컴파일 시점에 잡습니다. 프레임워크에 의존하지 않는 순수 자바 코드로 테스트할 때 생성자에 값만 넣으면 됩니다. 필수 의존관계가 생성자 시그니처에 그대로 드러나 의도가 명확합니다.

수정자 주입은 선택적이거나 나중에 바뀔 수 있는 의존관계에 제한적으로 씁니다. @Autowired는 주입 대상이 없으면 오류를 내므로, 없어도 동작하게 하려면 @Autowired(required = false)로 둡니다. 필드 주입은 코드가 짧지만 외부에서 값을 바꿀 길이 없어 테스트와 변경에 약하므로 피하는 편이 낫습니다.

같은 타입 빈이 둘 이상일 때

@Autowired의 기본 조회는 타입 기준입니다. 따라서 같은 타입의 빈이 둘 이상이면 어느 것을 주입할지 정할 수 없어 충돌이 납니다. 이때는 의도를 명시적으로 표현해야 합니다.

@Qualifier는 주입 지점과 빈에 같은 이름표를 붙여 짝을 지정합니다. @Primary는 같은 타입 중 우선순위를 부여합니다. 해당 타입의 빈이 모두 필요하면 ListMap으로 받아 한 번에 주입받습니다.

java
@Component
public class OrderServiceImpl implements OrderService {
 
    private final DiscountPolicy discountPolicy;
 
    public OrderServiceImpl(@Qualifier("rateDiscountPolicy")
                            DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}

빈의 기본 스코프가 싱글톤이라는 점도 주입 설계에 영향을 줍니다. 스프링 공식 문서 기준 빈 스코프 기본값은 싱글톤이며, 컨테이너는 하나의 인스턴스를 공유해 재사용합니다. 그래서 요청별 상태를 빈 필드에 두면 동시성 문제가 생기므로 서비스 빈은 무상태로 설계합니다.

정리

IoC는 객체 생성과 조립의 제어권을 코드 밖으로 옮기는 개념이고, DI는 그 연결을 외부 주입으로 구현하는 방식입니다. 스프링 컨테이너인 ApplicationContext@Bean 수동 등록과 @ComponentScan 자동 등록으로 빈을 모읍니다. 모인 빈은 타입을 기준으로 자동 주입됩니다. 주입 방식 중에서는 불변·필수 의존관계를 드러내고 누락을 컴파일 시점에 잡는 생성자 주입을 기본으로 둡니다. 같은 타입 빈이 충돌하면 @Qualifier@Primary로 의도를 명시하고, 기본 스코프가 싱글톤이므로 빈은 무상태로 설계합니다.