new를 직접 부르는 코드가 테스트하기 어려운 이유 — IoC와 DI가 해결하는 문제
// Spring 없는 세계
public class OrderService {
private final PaymentGateway gateway = new StripePaymentGateway(); // 직접 생성
private final MailSender mailer = new SmtpMailSender(); // 직접 생성
public void process(Order order) {
gateway.charge(order.getTotal());
mailer.send(order.getCustomerEmail(), "주문 완료");
}
}
이 코드는 동작한다. 하지만 StripePaymentGateway를 다른 것으로 바꾸려면 이 파일을 열고 수정해야 한다. 테스트에서 가짜 결제 게이트웨이를 넣을 수도 없다 — new가 클래스에 묶여 있으니까. 이것이 제어가 "개발자에게 있는" 구조다.
비유: 직접 렌터카를 사서 타는 것과 같다. 차가 고장 나면 직접 고치고, 다른 차를 타려면 팔고 새로 사야 한다. IoC는 카셰어링이다 — 필요할 때 원하는 차를 빌려 쓴다. 차를 바꾸려면 앱에서 선택만 바꾸면 된다. Spring 컨테이너가 이 "카셰어링 앱" 역할을 한다.
하지만 StripePaymentGateway를 TossPaymentGateway로 바꾸려면 소스 코드를 수정해야 한다. 테스트에서 가짜 결제 게이트웨이를 넣을 수도 없다 — new가 클래스에 단단히 묶여 있으므로. 이것이 제어가 개발자에게 있는 전통적 구조다.
Spring의 제어 역전(Inversion of Control, IoC)은 이 화살을 뒤집는다 — 개발자가 new를 부르는 것이 아니라, 컨테이너가 객체를 만들고 필요한 곳에 주입한다. (Spring Framework Reference - IoC Container) 이 글은 IoC와 의존성 주입(DI)이 무엇인지, 왜 이 설계가 테스트와 유연성을 혁신적으로 개선하는지를 풀어간다.
IoC — "누가 new를 부르는가"의 전환
제어 역전은 객체 생성·연결의 책임을 개발자에서 프레임워크로 넘기는 설계 원칙이다. (Spring Framework Reference - IoC)
flowchart LR
subgraph before["전통적 (제어가 개발자에게)"]
A1["OrderService"] -->|"new StripePaymentGateway()"| G1["StripePaymentGateway"]
A1 -->|"new SmtpMailSender()"| M1["SmtpMailSender"]
end
subgraph after["IoC (제어가 컨테이너에게)"]
C["Spring Container"] -->|"주입"| A2["OrderService"]
C -->|"생성·관리"| G2["PaymentGateway"]
C -->|"생성·관리"| M2["MailSender"]
A2 -.->|"인터페이스에만 의존"| G2
A2 -.->|"인터페이스에만 의존"| M2
end
| 항목 | 전통적 | IoC |
|---|---|---|
| 객체 생성 | 개발자가 new |
컨테이너 |
| 의존성 연결 | 코드 안에서 직접 | 컨테이너가 주입 |
| 결합도 | 높음 (구체 클래스) | 낮음 (인터페이스) |
| 교체 가능성 | 소스 수정 필요 | 설정만 변경 |
| 테스트 | 어려움 (mock 불가) | 쉬움 (주입으로 mock) |
DI — IoC의 구현 방식
의존성 주입(Dependency Injection, DI)은 IoC를 구현하는 구체적 기법이다 — 객체가 자신의 의존성을 직접 만들지 않고, 외부에서 받는다. (Spring Framework Reference - DI)
// Spring 7 / Java 25 — DI 패턴
public class OrderService {
private final PaymentGateway gateway; // 인터페이스에만 의존
private final MailSender mailer;
// 생성자로 주입 — new 없이 외부에서 받음
public OrderService(PaymentGateway gateway, MailSender mailer) {
this.gateway = gateway;
this.mailer = mailer;
}
public void process(Order order) {
gateway.charge(order.getTotal());
mailer.send(order.getCustomerEmail(), "주문 완료");
}
}
OrderService는 StripePaymentGateway인지 TossPaymentGateway인지 모른다 — PaymentGateway 인터페이스에만 의존한다. 실제 구현체는 Spring 컨테이너가 결정하여 주입한다.
왜 생성자 주입이 권장되는가
// Spring 7 / Java 25 — 생성자 주입 (권장)
@Service
public class OrderService {
private final PaymentGateway gateway; // final: 불변 보장
private final MailSender mailer;
// 생성자가 1개면 @Autowired 생략 가능 (Spring 4.3+)
public OrderService(PaymentGateway gateway, MailSender mailer) {
this.gateway = gateway;
this.mailer = mailer;
}
}
생성자 주입이 권장되는 이유:
final필드 — 주입 후 변경 불가 (thread safety)- 컴파일 타임 검증 — 의존성 누락 시 컴파일 에러가 아니라 애플리케이션 시작 실패로 즉시 발견
- 순환 의존성 즉시 감지 — 생성자 주입에서 순환 참조는
BeanCurrentlyInCreationException으로 시작 시점에 발각됨 (Spring Reference - Circular Dependencies) - 테스트 용이 — 생성자로 mock을 직접 전달
Spring 4.3+부터 생성자가 하나뿐이면
@Autowired를 생략할 수 있다. (Spring Framework 7 Reference) 대부분의 경우@Autowired없이 생성자만 정의하면 자동 주입된다.
왜 생성자 주입이 권장되는가 — 내부 메커니즘: Spring이 OrderService bean을 생성할 때, 리플렉션으로 생성자의 매개변수 타입을 검사한다 — PaymentGateway, MailSender. 그리고 컨테이너에서 이 타입의 bean을 찾아 생성자에 전달한다. 이 과정이 실패하면 (해당 타입의 bean이 없으면) 애플리케이션이 시작되지 않는다. 이것이 장점이다 — 런타임에 NullPointerException으로 발견되는 것이 아니라, 시작 시점에 즉시 실패하여 문제를 빨리 발견할 수 있다.
비유: 생성자 주입은 건물 입주 시 필요한 것을 입주 전에 모두 확인하는 방식이다. 이사하려면 "전기, 수도, 가스"가 모두 있어야 한다고 계약서에 적는다. 하나라도 없으면 이사를 거부한다 — 나중에 "전기가 없네?"라고 당황하지 않아도 된다. 필드 주입은 "일단 입주하고, 필요할 때 전기 신청하자"는 방식이다 — 나중에 전기가 안 들어와야 문제를 알게 된다.
Bean — Spring 컨테이너가 관리하는 객체
Spring 컨테이너가 생성·관리하는 객체를 bean이라고 한다. bean은 ApplicationContext에 등록되고, 필요한 곳에 주입된다.
// Spring 7 / Java 25 — bean 등록 방법 1: 컴포넌트 스캔
@Service
public class OrderService { ... }
@Repository
public class OrderRepository { ... }
@Component
public class EmailNotifier { ... }
// Spring 7 / Java 25 — bean 등록 방법 2: @Bean 메서드
@Configuration
public class AppConfig {
@Bean
public PaymentGateway paymentGateway() {
return new StripePaymentGateway("secret-key-123");
}
@Bean
public MailSender mailSender() {
return new SmtpMailSender("smtp.example.com", 587);
}
}
| 등록 방식 | 어노테이션 | 용도 |
|---|---|---|
| 컴포넌트 스캔 | @Component, @Service, @Repository, @Controller |
개발자가 직접 작성한 클래스 |
@Bean 메서드 |
@Configuration + @Bean |
외부 라이브러리 객체, 복잡한 생성 로직 |
@Service,@Repository,@Controller는 모두@Component의 특수화다 — 역할을 명시할 뿐, 기능적으로는 동일하다. Spring은 이 어노테이션들을 스캔하여 bean으로 등록한다.
ApplicationContext — Spring 컨테이너의 진입점
ApplicationContext는 bean을 관리하는 컨테이너의 핵심 인터페이스다. (Spring Framework Reference - ApplicationContext)
// Spring 7 / Java 25 — ApplicationContext 사용
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
OrderService service = context.getBean(OrderService.class);
service.process(order);
}
ApplicationContext가 제공하는 기능:
- bean 등록 및 검색 —
getBean(Class),getBean(String) - 의존성 주입 — bean 간 참조를 자동으로 연결
- 생명주기 관리 — 초기화(
@PostConstruct), 소멸(@PreDestroy) 콜백 - 이벤트 발행 —
ApplicationEvent기반 이벤트 전파 - 국제화(i18n) —
MessageSource통합
실제 애플리케이션에서는
getBean()을 직접 부르지 않는다 — 컨테이너가 의존성을 자동으로 주입하기 때문이다.getBean()은 테스트나 프레임워크 코드에서만 사용한다.
인터페이스에 의존하라 — DIP(의존성 역전 원칙)
DI의 핵심 전제: 상위 모듈이 하위 모듈에 의존하지 않고, 둘 다 추상화(인터페이스)에 의존한다.
// 좋은 설계: 인터페이스에만 의존
public interface PaymentGateway {
void charge(long amount);
}
@Service
public class OrderService {
private final PaymentGateway gateway; // 구체 클래스가 아닌 인터페이스
// → StripeGateway, TossGateway, MockGateway 모두 주입 가능
}
// 나쁜 설계: 구체 클래스에 의존
@Service
public class OrderService {
private final StripePaymentGateway gateway; // 교체 불가
}
실습 — Spring 없는 코드와 Spring DI 비교
// Spring 7 / Java 25 — DiDemo.java (Spring 없이 실행하는 간단한 DI)
import java.util.*;
// 인터페이스
interface PaymentGateway { void charge(long amount); }
interface MailSender { void send(String to, String body); }
// 구현체
class StripeGateway implements PaymentGateway {
public void charge(long amount) { System.out.println("Stripe: " + amount + "원 결제"); }
}
class ConsoleMailer implements MailSender {
public void send(String to, String body) { System.out.println("메일 → " + to + ": " + body); }
}
// 서비스 (DI 패턴 — 생성자로 주입)
class OrderService {
private final PaymentGateway gateway;
private final MailSender mailer;
public OrderService(PaymentGateway gateway, MailSender mailer) {
this.gateway = gateway;
this.mailer = mailer;
}
public void process(String email, long amount) {
gateway.charge(amount);
mailer.send(email, "주문 완료 (" + amount + "원)");
}
}
public class DiDemo {
public static void main(String[] args) {
// 수동 조립 (Spring이 자동으로 하는 일을 직접 시뮬레이션)
PaymentGateway gateway = new StripeGateway();
MailSender mailer = new ConsoleMailer();
OrderService service = new OrderService(gateway, mailer);
service.process("customer@example.com", 50000);
}
}
java DiDemo.java
Stripe: 50000원 결제
메일 → customer@example.com: 주문 완료 (50000원)
확인할 것: OrderService는 StripeGateway나 ConsoleMailer를 모른다 — 생성자로 주입받을 뿐. 테스트에서 가짜 구현체를 전달하면 실제 결제/메일 없이 테스트할 수 있다. Spring 컨테이너가 이 "조립"을 자동으로 수행한다.
Spring Framework 7.0에서 달라진 것
Spring Framework 7.0(2025-11)의 핵심 변화:
| 항목 | Spring 6 | Spring 7 |
|---|---|---|
| 최소 Java | Java 17 | Java 17 유지 (Java 25 권장) |
| EE 기준 | Jakarta EE 9+ | Jakarta EE 11 |
| Servlet | 6.0 (Tomcat 10) | 6.1 (Tomcat 11) |
| JPA | 3.1 (Hibernate 6) | 3.2 (Hibernate 7) |
| Bean Validation | 3.0 | 3.1 (Hibernate Validator 9) |
| 테스트 | JUnit 5 | JUnit 6 |
| GraalVM | 23 | GraalVM 25 (새 reachability metadata) |
Spring 7에서도
jakarta.*네임스페이스는 Spring 6과 동일하다 —javax.*→jakarta.*전환은 Spring 6에서 이미 완료됐다. Spring 7은 Jakarta EE 버전만 올린 것. (Spring Framework 7.0 Release Notes)
Spring 없이 DI 구현 — 수동 vs 프레임워크
DI는 Spring 없이도 할 수 있다. 차이는 "누가 조립하느냐"다:
// Spring 7 / Java 25 — 수동 DI (Spring 없이)
// 조립 코드 (main 또는 별도 팩토리)
PaymentGateway gateway = new StripeGateway("secret-key");
MailSender mailer = new ConsoleMailer();
OrderService service = new OrderService(gateway, mailer);
// 문제: 객체가 수십 개로 늘면 조립 코드가 폭발한다
// → Spring이 이 조립을 자동화한다 (컴포넌트 스캔 + @Autowired)
수동 DI의 한계:
- 객체가 많아지면 조립 코드가 복잡해진다 (수십 개의
new와 연결) - 생명주기 관리(초기화, 소멸)를 수동으로 해야 함
- 스코프(singleton, prototype)를 직접 구현해야 함
- AOP(트랜잭션, 로깅)를 직접 구현해야 함
Spring 컨테이너가 이 모든 것을 자동화한다 — 개발자는 비즈니스 로직에만 집중한다.
요약 — 이 글의 결론
- IoC는 "누가 new를 부르는가"의 전환이다. 개발자가 객체를 직접 생성·연결하는 대신, 컨테이너가 관리하고 주입한다. 이것이 결합도를 낮추고 테스트를 쉽게 만든다.
- 생성자 주입이 권장된다.
final필드로 불변성 보장, 순환 의존성 즉시 감지, 테스트 용이. 생성자가 1개면@Autowired생략 가능. - 인터페이스에 의존하라(DIP). 구체 클래스가 아닌 인터페이스에 의존하면, 구현체 교체가 설정만으로 가능해진다. Spring이 이 교체를 자동화한다.
- Spring 7은 Jakarta EE 11 기반이다.
jakarta.*는 Spring 6과 동일하지만, Servlet/JPA/Validation 버전이 올라갔다. 최소 Java 17, 권장 Java 25.
생각해 볼 문제
- 생성자 주입에서 순환 의존성이 발생하면 Spring은 어떤 예외를 던지는가? 세터 주입에서는 왜 다르게 동작하는가?
@Component,@Service,@Repository가 기능적으로 동일한데 왜 구분해서 쓰는가?- Spring 없이 DI 패턴을 직접 구현하면 어떤 장단점이 있는가? (수동 조립의 한계)
ApplicationContext.getBean()을 애플리케이션 코드에서 직접 호출하면 안 되는 이유는?- Spring 7에서 Servlet 6.1로 올라간 것이 애플리케이션 코드에 미치는 영향은 무엇인가?
참고
- Spring Framework 7.0 Reference - IoC Container - 접근 2026-07-10
- Spring Framework 7.0 Reference - Dependencies - 접근 2026-07-10
- Spring Framework 7.0 Release Notes - 접근 2026-07-10
- Spring Framework 7.0 - @Autowired - 접근 2026-07-10
'Develop Artifacts > Spring' 카테고리의 다른 글
| Spring - 06. aop (0) | 2026.07.12 |
|---|---|
| Spring - 05. bean scope profile (0) | 2026.07.12 |
| Spring - 04. configuration component-scan (0) | 2026.07.12 |
| Spring - 03. dependency-injection (0) | 2026.07.12 |
| Spring - 02. container bean lifecycle (0) | 2026.07.12 |
