IoC와 DI는 어디서 갈리고 스프링은 무엇을 맡나

IoC와 DI의 관계, 스프링 컨테이너가 대신하는 일, 생성자 주입을 권하는 이유를 실행 결과로 보여 줍니다.

동딩
2026.10.08·5분 읽기·

IoC와 DI는 어디서 갈리나

의존성은 한 클래스가 일을 하려고 쓰는 다른 객체입니다(OrderService에게는 OrderRepository).

IoC(제어의 역전)는 내 코드가 라이브러리를 부르는 대신 프레임워크가 정해 둔 때에 내 코드를 부르는 구조입니다. Martin Fowler는 이를 할리우드 원칙 "전화하지 마세요, 우리가 연락합니다"로 설명합니다. 요청이 올 때 스프링 MVC가 내 컨트롤러 메서드를 부르는 것도 IoC입니다.

DI(의존성 주입)는 객체가 쓸 객체를 직접 new 하지 않고 생성자 같은 통로로 밖에서 받는 방식입니다. 의존성을 만드는 제어가 객체 밖으로 넘어가기 때문에 스프링 문서는 DI를 IoC의 특수한 형태라고 씁니다.

IoC프레임워크가 내 코드를 부른다L1
DI쓸 객체를 밖에서 받는다L2
생성자 주입스프링이 권하는 방식L3
IoC 안에 DI, DI 안에 생성자 주입

DI라는 이름도 이 구분에서 나왔습니다. Fowler는 2004년 글에서 IoC가 모든 프레임워크의 공통점이라 너무 넓다며 이 방식에 Dependency Injection이라는 좁은 이름을 붙였습니다.

직접 new 하는 코드와 주입받는 코드

같은 서비스를 두 방식으로 쓰면 이렇습니다.

java
// 직접 new: 어떤 저장소를 쓸지 OrderService가 정합니다
class OrderService {
private final OrderRepository repo = new JdbcOrderRepository();
}
// 주입받기: 생성자로 받기만 합니다
class OrderService {
private final OrderRepository repo;
OrderService(OrderRepository repo) {
this.repo = repo;
}
}

직접 new 하면 어떤 저장소를 쓸지 OrderService가 정해서, 구현을 바꾸거나 테스트에서 가짜 저장소를 넣으려면 OrderService를 고쳐야 합니다. 주입받으면 OrderService는 인터페이스만 알고, 넘기는 객체만 바꾸면 됩니다.

스프링이 대신해 주는 일

주입받는 코드가 늘면 누군가는 객체를 순서대로 만들어 이어 줘야 합니다. 스프링에서는 IoC 컨테이너(ApplicationContext)가 이 일을 합니다. 컨테이너가 만들고 관리하는 객체를 빈이라고 부르며, @Component를 붙인 클래스가 빈이 됩니다.

컨테이너가 저장소를 만들어 생성자에 넣는 순서
컨테이너ApplicationContext
@Component 둘 찾음
OrderService 생성자 인자OrderRepository
직접 new 한 필드 주입 repo = null
MemoryRepository
빈 찾기

컨테이너(ApplicationContext)가 @Component가 붙은 클래스 둘을 찾습니다.

저장소 먼저

OrderService 생성자가 OrderRepository를 원하니 컨테이너가 저장소를 먼저 만듭니다.

생성자에 넣기

만든 저장소를 생성자에 넣어야 OrderService가 완성됩니다. 서비스는 넣어 준 것만 씁니다.

스프링 없이 new 하면

필드 주입 서비스를 직접 new 하면 채워 줄 컨테이너가 없어 필드가 빈 채로 남습니다.

생성자 주입, 필드 주입, 순환 참조(A와 B가 서로를 필요로 하는 상태)를 스프링 부트 없이 Spring Framework 7.0.9로 돌려 봤습니다.

코드 네 조각과 실행 결과
DiDemo.java컨테이너
// 생성자가 자기 이름을 찍는다
@Component
class MemoryRepository
implements OrderRepository {}
@Component
class OrderService {
OrderService(OrderRepository r)
{ ... }
}
실행 결과
new MemoryRepository()
new OrderService(MemoryRepository)
// 생성자 주입: 가짜를 넘긴다
new OrderService(
new FakeRepository()).show(7);
// 필드 주입: 직접 new 한다
class FieldOrderService {
@Autowired OrderRepository repo;
}
new FieldOrderService().show(7);
실행 결과
생성자 주입 → fake-7
필드 주입 → NullPointerException
@Component
class A { A(B b) { } }
@Component
class B { B(A a) { } }
실행 결과
BeanCurrentlyInCreationException
Requested bean is currently in creation
@Component
class C { @Autowired D d; }
@Component
class D { @Autowired C c; }
실행 결과
started: c.d.c == c -> true
// C 안의 D 안의 C가 처음 C와 같다
컨테이너에 맡기면

OrderService 생성자가 OrderRepository를 원하니, 컨테이너가 저장소를 먼저 만들어 넣습니다.

스프링 없이 new 하면

생성자 주입은 가짜 저장소를 넘기면 바로 돌고, @Autowired 필드는 채워 줄 컨테이너가 없어 NullPointerException이 납니다.

생성자끼리 서로 원하면

A를 만들려면 B가, B를 만들려면 A가 먼저 있어야 해서 시작할 때 실패합니다.

필드끼리 서로 원하면

같은 순환인데 필드 주입이면 그대로 뜹니다. 꼬인 설계를 모른 채 넘어갑니다.

생성자 주입을 권하는 이유

스프링 DI 문서는 필수 의존성은 생성자로, 기본값이 있는 선택 의존성은 세터로 받으라고 합니다. 생성자 주입이면 필드를 final로 두어 바꿀 수 없게 만들 수 있고, 의존성이 빠진 채로 객체가 만들어지지 않습니다.

생성자세터필드
스프링 없이 new 해서 테스트바로 됨세터 호출 필요NPE
순환 참조시작 때 실패통과통과
스프링 문서가 권하는 곳필수 의존성선택 의존성따로 없음
주입 방식 셋이 갈리는 지점

순환 참조에서 시작부터 실패하는 것은 설계가 꼬였다는 신호를 가장 일찍 받는 셈입니다. 세터·필드 주입은 한쪽이 덜 만들어진 채로 상대에게 넘어가는데, Spring Boot 2.6부터는 이것도 기본으로 막습니다(spring.main.allow-circular-references=true로 다시 허용).

자주 하는 오해 세 가지

"IoC와 DI는 같은 말이다"는 Fowler가 직접 짚은 혼동입니다. JUnit이 내 @Test 메서드를 부르는 것도 IoC지만, 메서드를 부르는 일 자체는 의존성 주입이 아닙니다.

"DI를 하려면 스프링이 필요하다"도 틀렸습니다. Stack Overflow 최고 득표 답변은 객체를 생성자에 직접 넘기는 것도 프레임워크만큼 좋은 주입이라고 씁니다. 스프링은 넘기는 일을 대신할 뿐입니다.

"@Autowired를 빼면 주입이 안 된다"는 생성자 주입에서는 생성자가 둘 이상일 때만 맞습니다. 위 실행에서도 생성자가 하나뿐인 OrderService는 애너테이션 없이 주입됐습니다(스프링 문서).

언제 무엇을 쓰나

반드시 있어야 하는 의존성은 생성자로 받고 필드를 final로 둡니다. 기본값이 있는 선택 의존성만 세터로 받습니다. 필드 주입은 스프링 테스트 클래스처럼 내가 new 할 일이 없는 곳에만 남겨 둡니다. 생성자 인자가 계속 늘어나면 주입 방식을 바꿀 때가 아니라 클래스를 나눌 때입니다.

Comments

댓글

powered by giscus ↗