JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

Spring

[1편] 의존성 주입(DI)과 제어의 역전(IoC), 왜 필요할까?

lamarcK 2025. 6. 2. 20:32

의존성 주입이란 무엇인가?

의존성 주입(Dependency Injection, DI)이란 객체가 사용할 다른 객체(의존성)를 직접 만들지 않고, 외부(스프링 IoC 컨테이너)에서 주입받는 디자인 패턴이다. 디자인 패턴이라고 말했지만 사용자가 직접 구현해야하는 것이 아니라 스프링이라는 프레임워크가 기본적으로 채택하여 자동으로 적용되는 사항 중 하나다. 마치 자바라는 언어가 객체 지향의 패러다임을 사용하고 자바스크립트는 함수형 패러다임을 사용하듯 스프링이라는 프레임워크 자체에 적용되는 일종의 문법적 요소라고 볼 수 있다.

  • 목적 : 객체 간의 결합도(Coupling)를 낮추고, 각 객체가 자기 책임에만 집중하도록 응집도(Cohesion)를 높이기 위해 사용한다. 이를 통해 유연하고, 확장 가능하며, 테스트하기 쉬운 소프트웨어를 만들 수 있다.

왜 의존성 주입을 사용해야 하는가? : 강한 결합의 문제

자바의 모든 코드는 클래스 내부에서 작성된다. 때문에 새로운 인스턴스를 생성하는 것도 결국 클래스 내부에서 new를 통해서 이뤄진다. 하지만 이 경우 해당 클래스 내부에 영구적으로 new라는 코드가 존재하게 된다. 이는 결국 해당 인스턴스의 생성이 new가 선언된 클래스와 절대 떨어질 수 없는 강한 결합을 만든다.

 

이 경우 new로 인스턴스를 만드는 클래스를 변경하든 new가 선언된 클래스를 수정하든 서로 코드의 변화가 생기게 된다. 이는 다시 말하면 인스턴스의 기능을 변경하려면 new가 선언된 클래스를 바꾸거나 인스턴스의 내용을 통째로 바꾸거나 해야 한다는 것이다. 이는 결과적으로 하나의 기능임에도 불구하고 2개의 클래스에 영향을 주는 결과를 낳는다.

 

이러한 강한 결합 문제를 해결하고 코드의 유연성을 확보하기 위해 등장한 것이 바로 '제어의 역전(Inversion of Control, IoC)' 원칙이며, 이를 구현하는 대표적인 디자인 패턴이 의존성 주입(Dependency Injection, DI)이다. 의존성 주입은 객체가 사용할 의존성을 new를 통해 직접 생성하는 대신, 외부에서 생성된 객체를 전달받아 사용하는 방식을 말한다. 즉, 객체 생성 및 관리의 제어권이 자기 자신으로부터 외부(스프링의 IoC 컨테이너와 같은)로 넘어가게 되는 것이다.

강한 결합의 예시

// DI가 없는 코드 (강한 결합)

// 타이어 종류 1
class ATire {
    public String getBrand() {
        return "A 타이어";
    }
}

// 자동차
class Car {
    // Car가 직접 ATire 객체를 생성하고 의존함 (강한 결합)
    private ATire tire = new ATire();

    public void drive() {
        System.out.println(tire.getBrand() + "로 달립니다.");
    }
}

// 실행
public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.drive(); // "A 타이어로 달립니다." 출력
    }
}

만약 여기서 타이어를 BTire로 바꾸려면 어떻게 해야 할까? Car 클래스 내부의 private ATire tire = new ATire(); 코드를 직접 수정해야만 한다. 즉, 부품을 교체하기 위해 자동차 본체를 뜯어고쳐야 하는 것이다. 이것이 바로 '강한 결합'의 문제이다.

제어의 역전(IoC) : 프레임워크에 대한 위임과 트레이드오프

제어의 역전이란 마치 자동차의 생산을 하청 업체에게 대신 맡기는 것과 비슷하다고 볼 수 있다. 본래는 자회사를 만들어서 자동차의 부품을 생산했던 기업이 하청 업체에게 설계도를 전달하고 부품을 대신 생산해 달라고 말하는 것이다. 그러면 기존의 본사 + 자회사의 관리 방식이 본사 + 하청 업체(알아서 해줌)의 방식으로 변경된다.

즉, 부품의 생산과 관리등 업무의 세부적인 내용은 하청 업체에서 알아서 처리하게 되고 본사는 완성된 부품만 받으면 되는 것이다. 이 과정에서 원청 업체가 생산에 개입하거나 관리를 하는 일은 없어진다.

 

마찬가지로 의존성 주입을 사용하면 개별 클래스들은 스프링 IoC 컨테이너라는 외부 환경에 강하게 의존하게 된다. 이제 객체는 더 이상 혼자 독립적으로 생성되고 실행될 수 없으며, 자신의 생성과 생명주기(Lifecycle)를 온전히 스프링 컨테이너에 위임한다.

  • 전통적 방식의 의존성 : Car 클래스 → Tire 클래스 (구체적인 클래스에 의존)
  • DI 방식의 의존성 : Car 클래스 → 스프링 IoC 컨테이너 (프레임워크에 의존)

즉, '특정 구현 클래스에 대한 의존성'을 '프레임워크에 대한 의존성'으로 바꾼 것이다. 단 스프링이라는 프레임워크는 정해진대로 자동으로 작동하기 때문에 이것을 따로 관리할 필요가 없어진다. 이것은 일종의 트레이드오프라고 할 수 있다.

프레임워크에 종속되어 해당 프레임워크의 규칙을 따르고 객체 생성의 직접적인 제어권을 포기하는 대가로, 코드의 유연성, 확장성, 테스트 용이성이라는 엄청난 이점을 얻은 것이다. 물론 해당 프레임워크는 좀 더 편리하게 프로그래밍을 하기 위해 의도적으로 만들어진 것이기 때문에 딱히 손해를 본 것은 없다고 할 수 있다. 바느질을 재봉틀로 바꾼다고 해서 과연 손해를 보겠는가? 

관심사의 분리(SoC) : 핵심 책임에 집중하기

관심사의 분리(Separation of Concerns, SoC)는 의존성 주입(DI)의 핵심 철학으로, 객체가 마땅히 가져야 할 자신의 핵심 책임과 그 외의 부수적인 책임(객체 생성, 의존성 연결 등)을 명확히 나누는 것을 의미한다. 

 

예를 들어 개발자는 Car 클래스에 drive라는 메서드만을 정의하고 싶지만 drive라는 메서드는 Tire라는 인스턴스의 데이터를 매개변수로 받을 필요가 있다고 치자. 그러면 필연적으로 Car라는 클래스 안에 new Tire로 인스턴스를 생성하는 과정이 필요해진다. 또한 기능이 다른 2개의 Tire 클래스를 각각 ATire, BTire로 만들었을 경우 ATire에서 BTire로 바꾸려면 new Tire 부분을 수정해야만 했다.

 

그런데 스프링 프레임워크를 사용하면 개발자는 TireATire인지 BTire인지, 그리고 그 타이어 객체가 어떻게 생성되고 관리되는지에 대해 전혀 신경 쓸 필요가 없다. 그냥 "나는 Tire 인터페이스를 구현한 어떤 객체가 필요해"라고 선언(@Autowired 등)만 해두면, 스프링 컨테이너가 약속된 Tire 객체를 알아서 주입해 준다. 당장에 TireCar가 어떻게 결합될지를 전혀 신경 쓰지 않아도 되는 것이므로 결과적으로 Car 클래스의 코드는 오직 자신의 핵심 책임(drive 기능)에만 집중할 수 있게 된다.

좀 복잡하게 말하자면 Tire라는 클래스 자체가 인스턴스 생성 시 다양한 매개변수를 받아야 한다면 그것까지 개발자가 모두 정해둬야 했던 것이다.

    // 네 개의 매개변수를 받는 생성자
    public ATire(String brand, String model, int sizeInInches, String seasonType) {}

 

하지만 스프링 컨테이너에 이를 위임한 결과, 복잡한 의존성 생성과 관리라는 '관심사'는 스프링 컨테이너가 모두 대신 처리해 준다. 결과적으로, Car 클래스 입장에서는 마치 필요한 모든 것이 처음부터 갖춰진 환경에서 자신의 로직만 수행하는 것처럼 보이게 된다. 이것이 DI가 코드의 가독성과 유지보수성을 극적으로 향상시키는 이유다.

// 역할(인터페이스) 정의
interface Tire {
    String getBrand();
}

// 구현체 1 - 스프링이 관리할 컴포넌트임을 알림
@Component // 스프링 컨테이너에게 이 클래스를 빈(Bean)으로 등록하라고 지시
@Primary // @Primary를 사용해서 우선 순위를 높임
class ATire implements Tire {
    @Override
    public String getBrand() { return "A 타이어"; }
}

// 구현체 2 - 스프링이 관리할 컴포넌트임을 알림
@Component // 스프링 컨테이너에게 이 클래스를 빈(Bean)으로 등록하라고 지시
class BTire implements Tire {
    @Override
    public String getBrand() { return "B 타이어"; }
}

// 자동차 (이제 '역할'에만 의존)
@Component // 스프링 컨테이너에게 이 클래스를 빈(Bean)으로 등록하라고 지시
class Car {
    private final Tire tire;

    // 생성자 주입: 스프링이 Tire 타입의 빈을 찾아서 주입해 줌
    // @Autowired는 생략 가능 (스프링 4.3 이상, 단일 생성자인 경우)
    public Car(@Autowired Tire tire) {
        this.tire = tire;
    }

    public void drive() {
        System.out.println(tire.getBrand() + "로 달립니다.");
    }
}

이제 CarATire인지 BTire인지 전혀 신경쓰지 않는다. 오직 Tire라는 역할(인터페이스)에만 의존한다. 타이어를 교체하고 싶으면, Tire 인터페이스를 구현한 ATireBTire 중 하나에 @Primary를 붙이는 방식으로 주입되는 빈을 조절하면 된다. Car 클래스는 전혀 수정할 필요가 없다. 이것이 바로 관심사의 분리(SoC)이며, DI가 주는 강력한 이점이다.