Spring

[Spring] IoC / DI 정리

tudamoa 2025. 3. 22. 19:23

우리가 Java로 프로그래밍을 할 때는, 보통 개발자가 직접 클래스를 정의하고 객체를 생성하며, 그 생명주기까지 직접 관리한다.

하지만 애플리케이션 규모가 커지고 객체 간의 의존성이 복잡해질수록, 이러한 직접적인 제어 방식은 코드의 유지보수성과 생산성을 떨어뜨리는 험난한 프로그래밍이 될 것이다.

 

여기서 Spring 프레임워크를 사용한다면, 개발자가 해야 했던 객체의 생성, 생명주기 관리, 프로그램의 일부에 대한 제어의 책임을 Spring(컨테이너)에게 위임하여 개발자에게 편의성을 제공한다.

이처럼 객체의 대한 제어권을 개발자가 아닌 컨테이너가 가지는 것을 IoC(Inversion of Control, 제어의 역전)이라고 한다.

 

그리고 IoC의 핵심 구현 방식 중 하나가 바로 DI(Dependency Injection, 의존성 주입)이다.

IoC (Inversion Of Control, 제어의 역전)

전통적인 프로그래밍에선 개발자가 코드의 흐름을 직접 제어하고 필요한 객체를 직접 생성하여 라이브러리를 호출했다.
하지만 IoC에서는 이러한 제어권이 프레임워크로 넘어가며, 프레임워크가 애플리케이션의 흐름을 제어하면서 필요에 따라 개발자가 작성한 코드를 호출한다.
이처럼 제어의 주체가 개발자에서 프레임워크로 바뀌는 구조를 제어의 역전(Inversion of Control)이라고 한다.

 

IoC를 구현함으로써 여러 가지 장점을 얻을 수 있다.

  • 객체 간의 결합도를 낮출 수 있다. (강한 결합 -> 느슨한 결합)
  • 결합도를 낮춤으로써 유연하고 확장이 수월한 코드 작성이 가능하다.
  • 제어 권한을 넘김으로써 개발자는 비즈니스 로직에 집중할 수 있다.
  • 코드를 처음 보는 입장에서 구조화가 되어 있어 이해하기에 용이하다.
  • 결론적으로 유지 보수 / 테스트도 용이해진다.

IoC는 '설계 원칙'이다.

IoC는 특정 기술이나 라이브러리가 아닌 설계 원칙이며, 다양한 방식으로 구현될 수 있다.

대표적인 IoC 구현 방식에는 아래 패턴들이 있다.

  1. 전략 패턴 (Strategy Pattern)
  2. 템플릿 메서드 (Template Method)
  3. 팩토리 패턴 (Factory Pattern)
  4. 서비스 로케이터 패턴 (Service Locator Pattern)
  5. 의존성 주입 (Dependency Injection, DI)

여기서 2가지를 알아보도록 하자.

1. 템플릿 메서드 (Template Method)

템플릿 메서드 패턴은 알고리즘의 전체 흐름을 상위 클래스에서 정의하고, 그중 일부 단계의 세부 구현은 하위 클래스에서 담당하도록 위임하는 디자인 패턴이다.

전체 구조는 변하지 않지만, 일부 동작만 다르게 정의해야 할 때 유용하다.

 

예를 들어, 커피와 차를 만드는 과정을 생각해 보자.

단계 커피
물 끓이기 공통
우려내기 커피 가루 티백
물 붓기 공통
첨가물 설탕 찻잎

 

물을 끓이고 물을 붓는 과정은 공통적이지만,
커피는 커피 가루를 준비하고 설탕을 넣어야 하지만 차는 티백을 준비하고 찻잎을 넣어야 한다.

 

이렇게 일부 동작만 다르게 정의해야 할 때 템플릿 메서드 패턴이 유용하다.

 

// 템플릿 메서드 패턴을 적용한 상위 클래스
// 상위 클래스에서 전체적인 흐름(템플릿)을 정의
abstract class Beverage {
    public final void prepareRecipe() {
        boilWater();  // 공통                        (물 끓이기)
        brew();       // 다름 → 하위 클래스에서 구현    (커피 가루 <-> 티백)
        pourInCup();  // 공통                        (컵에 물 넣기)
        addItem(); // 다름 → 하위 클래스에서 구현 (설탕 <-> 찻잎)
    }

    // 공통 메서드 (변경 필요 없음)
    private void boilWater() {
        System.out.println("물을 끓인다.");
    }

    private void pourInCup() {
        System.out.println("물을 컵에 따른다.");
    }

    // 변경이 필요한 부분 → 추상 메서드 (하위 클래스에서 반드시 구현)
    // 하위 클래스에서 필요한 부분을 구현 (공통되지 않은 부분)
    abstract void brew();
    abstract void addItem();
}

 

// 커피 만들기 (추상 메서드 구현)
class Coffee extends Beverage {
    @Override
    void brew() {
        System.out.println("커피 가루를 준비한다.");
    }

    @Override
    void addItem() {
        System.out.println("설탕을 추가한다."); 
    }
}

// 차 만들기 (추상 메서드 구현)
class Tea extends Beverage {
    @Override
    void brew() {
        System.out.println("티백을 준비한다.");
    }

    @Override
    void addItem() {
        System.out.println("찻잎을 추가한다.");
    }
}

 

public class Main {
    public static void main(String[] args) {
        System.out.println("커피 준비:");
        Beverage coffee = new Coffee(); // 하위 클래스로 객체 생성
        coffee.prepareRecipe(); // 상위 클래스 호출

        System.out.println("차 준비:");
        Beverage tea = new Tea(); // 하위 클래스로 객체 생성
        tea.prepareRecipe(); // 상위 클래스 호출
    }
}
//커피 준비:
//물을 끓인다.
//커피 가루를 준비한다.
//물을 컵에 따른다.
//설탕을 추가한다.
//차 준비:
//물을 끓인다.
//티백을 준비한다.
//물을 컵에 따른다.
//찻잎을 추가한다.

 

하위 클래스에서 메서드의 실제 구현을 하지만, 해당 메서드의 호출 제어권은 상위의 추상 클래스에 있기 때문에

IoC(제어의 역전)이 일어난다. 

 

2. 의존성 주입 (DI, Dependency Injection) 

DI(의존성 주입)는 IoC(제어의 역전)를 구현하기 위한 대표적인 방법이다.

DI의 핵심은 객체가 직접 의존 대상을 생성하는 것이 아니라 외부에서 생성된 의존 객체를 주입을 받는 방식이다.


기존의 방식은 new 키워드를 이용하여 클래스 내에서 직접 객체를 생성한다.

public class Cafe {
    private Coffee coffee;

    public Cafe() {
        coffee = new Americano(); // 객체를 직접 생성 (강한 결합)
    }
}

 

이렇게 직접 객체를 생성한다면 여러 문제가 발생할 수 있다.

1. Cafe는 Americano가 구체적으로 어떤 클래스인지 알고 있어야 한다.

2. Americano의 생성 방식까지 알아야 한다.

3. 만약 Americano가 아닌 Latte, Espresso가 온다고 하면 Cafe 코드를 직접 수정해야 한다.

즉, 이렇게 객체 간의 강력한 결합이 만들어져 확장성, 유지보수면에서 불리해진다.

 

이 문제를 해결하려면, 객체의 의존성을 낮추기 위해 DI 방식을 적용하면 된다.

public class Cafe {
    private Coffee coffee;

    public Cafe(Coffee coffee) {
        this.coffee = coffee; // 외부에서 주입
    }
}


// -----------------------
Cafe cafe = new Cafe(new Americano());
Cafe cafe2 = new Cafe(new Latte());

 

이렇게 하면 Cafe 클래스는 Coffee라는 추상 타입에만 의존하고 특정한 인터페이스에만 의존하고
실제 어떤 커피를 마실지, 구체적인 구현은 외부에서 결정되므로 클래스 간의 결합도가 낮아지고 확장성, 유지보수면에서 유리하다.

 

 

DI를 구성하기 위한 방법에는 여러 가지 방법이 있다.

 

1. 생성자 기반 주입

public class Cafe {
    private Coffee coffee;

    public Cafe(Coffee coffee) {
        this.coffee = coffee; 
    }
}

 

Cafe 객체를 생성할 때 반드시 Coffee 객체를 제공해야 하기 때문에 Cafe 객체는 의존성이 필수가 된다.

이렇게 하면, 어떤 의존성을 필요로 하는지 명확해지고 테스트 / 유지 보수 시 더 안정적이다.

주입 방식 중에 가장 선호되는 방식이다.

 

2. Setter 기반 주입

public class Cafe {
    private Coffee coffee;

    public void setCoffee(Coffee coffee) {
        this.coffee = coffee;
    }
}

 

생성자 기반 주입과는 다르게 coffee는 선택적으로 주입될 수 있고, 의존성을 나중에 바꿀 수도 있다.

하지만 호출을 잊으면 coffee가 null이기 때문에 런타임 오류가 발생할 가능성이 있다.

 

3. 필드 기반 주입

public class Cafe {

    @Autowired
    private Coffee coffee; // <-필드

}

 

@Autowired 어노테이션을 사용하여 *필드에 직접 의존성을 주입하는 방식이다.

생성자나 세터 없이도 Spring이 리플렉션(Reflection)을 이용해서 필드에 값을 주입해 준다.

 

코드가 짧고 간결해지지만, 테스트가 어렵고 유지보수가 어려우며 의존성이 불명확해진다는 단점이 있다.

*필드: 클래스(객체)의 속성(상태)을 저장하는 변수

 

DI 방식 비교 요약 표

방식 장점 단점 사용 추천
생성자 주입 불변성 보장, 의존성 명확, 테스트 용이 순환 참조 발생 시 어려움 ✅ 가장 권장됨
Setter 주입 선택적 의존성 가능, 유연성 있음 의존성 주입 보장 안됨 (null 위험) ⚠️ 상황에 따라
필드 주입 코드 간결, 빠르게 구현 가능 테스트/확장 어려움, 의존성 불명확 ❌ 권장되지 않음

 


Spring에서의 DI 사용법

Spring에서는 어노테이션 기반 설정과 자바 기반 설정(@Configuration + @Bean) 등을 통해 DI를 자동화한다.

 

아래는 실무에서 가장 많이 쓰이는 어노테이션 기반 설정 방식이다.

@Component
public class Americano implements Coffee {
    public String getName() {
        return "아메리카노";
    }
}

 

Spring은 @component 어노테이션이 붙은 클래스를 자동으로 스캔하여 Bean으로 등록한다.

어노테이션 역할
@Component 일반적인 컴포넌트
@Service 서비스 계층 클래스 표시용
@Repository DAO/Repository 계층 클래스 표시
@Controller MVC 컨트롤러 표시

 

Spring에서의 생성자 주입

@Component
public class Cafe {
    private final Coffee coffee;

    @Autowired
    public Cafe(Coffee coffee) {
        this.coffee = coffee;
    }
}

 

Spring 자동 주입 동작 흐름

  1. 컴포넌트 스캔: @component나 @service 등 어노테이션이 붙은 클래스를 찾는다.
  2. 빈 등록: 찾은 클래스는 Spring IoC 컨테이너에 Bean으로 등록된다.
  3. 의존성 탐색: @Autowired, 생성자 매개변수 등을 분석하여 의존성 타입을 확인한다.
  4. 주입 실행: IoC 컨테이너가 해당 의존성을 찾고, 객체를 생성하거나 주입한다.

 

동작 흐름 예시

interface Coffee {}

@Component
public class Americano implements Coffee {}

@Component
public class Cafe {
    private Coffee coffee;

    @Autowired
    public Cafe(Coffee coffee) {
        this.coffee = coffee;
    }
}

 

Spring의 내부 수행 동작

 

  1. Cafe 생성자에서 Coffee 타입을 요구한다.
  2. Spring 컨테이너에 Americano라는 Coffee 타입의 Bean이 등록되어 있다.
  3. 타입이 일치하므로 Americano 객체를 찾아서 Cafe 생성자에 자동으로 주입한다.

출처

https://www.baeldung.com/spring-dependency-injection

https://devmango.tistory.com/174

https://oneul-losnue.tistory.com/364

'Spring' 카테고리의 다른 글

[Spring] AOP & Proxy 란?  (3) 2025.08.01
[Spring] 트랜잭션 전파(Propagation) 정리  (3) 2025.07.12
[Spring] JDBC Template 정리  (0) 2025.05.31
[Spring] MVC 정리  (0) 2025.04.12
[Spring] 빈 / 빈 스코프(Bean / Bean Scope) 정리  (0) 2025.03.19