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 컨테이너가 이 "카셰어링 앱" 역할을 한다.

하지만 StripePaymentGatewayTossPaymentGateway로 바꾸려면 소스 코드를 수정해야 한다. 테스트에서 가짜 결제 게이트웨이를 넣을 수도 없다 — 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(), "주문 완료");
    }
}

OrderServiceStripePaymentGateway인지 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;
    }
}

생성자 주입이 권장되는 이유:

  1. final 필드 — 주입 후 변경 불가 (thread safety)
  2. 컴파일 타임 검증 — 의존성 누락 시 컴파일 에러가 아니라 애플리케이션 시작 실패로 즉시 발견
  3. 순환 의존성 즉시 감지 — 생성자 주입에서 순환 참조는 BeanCurrentlyInCreationException으로 시작 시점에 발각됨 (Spring Reference - Circular Dependencies)
  4. 테스트 용이 — 생성자로 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원)

확인할 것: OrderServiceStripeGatewayConsoleMailer를 모른다 — 생성자로 주입받을 뿐. 테스트에서 가짜 구현체를 전달하면 실제 결제/메일 없이 테스트할 수 있다. 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.

생각해 볼 문제

  1. 생성자 주입에서 순환 의존성이 발생하면 Spring은 어떤 예외를 던지는가? 세터 주입에서는 왜 다르게 동작하는가?
  2. @Component, @Service, @Repository가 기능적으로 동일한데 왜 구분해서 쓰는가?
  3. Spring 없이 DI 패턴을 직접 구현하면 어떤 장단점이 있는가? (수동 조립의 한계)
  4. ApplicationContext.getBean()을 애플리케이션 코드에서 직접 호출하면 안 되는 이유는?
  5. Spring 7에서 Servlet 6.1로 올라간 것이 애플리케이션 코드에 미치는 영향은 무엇인가?

참고

'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