2025-08-24 13:28

의존성 주입 완벽 핸드북

  • 의존성 주입(DI)은 객체가 필요로 하는 다른 객체(의존성)를 외부에서 직접 생성하여 전달하는 디자인 패턴입니다.

  • DI를 사용하면 클래스 간의 결합도를 낮춰 코드의 유연성, 재사용성, 테스트 용이성이 크게 향상됩니다.

  • 생성자 주입, 세터 주입, 필드 주입 등 다양한 주입 방식이 있으며, 제어의 역전(IoC) 원칙을 구현하는 핵심 기술입니다.

개발 고수가 되기 위한 필수 관문 의존성 주입 완벽 핸드북

소프트웨어 개발의 세계는 거대한 레고 성을 짓는 것과 같습니다. 수많은 레고 블록(객체)들이 서로 맞물려 하나의 멋진 구조물을 만들어내죠. 그런데 만약 모든 블록이 강력 접착제로 단단히 붙어있다면 어떨까요? 성의 디자인을 바꾸고 싶거나, 특정 블록 하나만 교체하고 싶을 때, 성 전체를 부숴야 할지도 모릅니다.

소프트웨어에서도 이와 비슷한 일이 벌어집니다. 객체들이 서로 너무 단단하게 연결되어 있으면(이를 **‘강한 결합도’**라고 부릅니다), 작은 변경 하나가 연쇄적인 수정을 일으키고, 테스트는 어려워지며, 코드의 재사용성은 바닥을 치게 됩니다.

이러한 ‘강력 접착제’ 문제를 해결하기 위해 등장한 구원투수가 바로 **의존성 주입(Dependency Injection, DI)**입니다. DI는 객체들이 서로에게 느슨하게 연결되도록 도와주는 강력한 디자인 패턴으로, 현대적인 소프트웨어 개발에서는 거의 표준처럼 사용되고 있습니다. 이 글에서는 의존성 주입이 왜 만들어졌는지, 어떤 구조로 동작하는지, 그리고 어떻게 사용하는지에 대해 A부터 Z까지 상세하게 파헤쳐 보겠습니다.

1. 의존성 주입은 왜 만들어졌을까 (탄생 배경)

DI의 필요성을 이해하려면 먼저 ‘의존성’이 무엇인지 알아야 합니다.

**의존성(Dependency)**이란, 하나의 객체가 다른 객체의 기능을 사용해야 할 때 발생하는 관계를 의미합니다.

예를 들어, 자동차(Car) 객체가 움직이려면 엔진(Engine) 객체가 필요합니다. 이때 자동차엔진의존한다고 말합니다.

가장 직관적인 코드를 한번 볼까요?

// 의존성을 내부에서 직접 생성하는 방식
public class Car {
    private Engine engine;
 
    public Car() {
        // 자동차가 직접 엔진을 생성합니다. (강한 결합)
        this.engine = new GasolinEngine();
    }
 
    public void start() {
        engine.turnOn();
    }
}

위 코드에서 Car 클래스는 생성자 안에서 GasolinEngine이라는 특정 엔진을 직접 만들어서 사용합니다. 이것이 바로 ‘강력 접착제로 붙인 레고 블록’ 상태입니다.

이 코드에는 몇 가지 심각한 문제가 있습니다.

  1. 유연성 부족: 만약 ElectricEngine이나 DieselEngine으로 바꾸려면 어떻게 해야 할까요? Car 클래스의 코드를 직접 수정해야만 합니다. 새로운 엔진이 추가될 때마다 Car 클래스는 계속 변경되어야 합니다.

  2. 재사용성 저하: GasolinEngine이 아닌 다른 엔진을 사용하는 Car 객체는 이 클래스로 만들 수 없습니다.

  3. 테스트의 어려움: Car 클래스를 테스트하려면 항상 실제 GasolinEngine이 필요합니다. Engine이 아직 개발 중이거나, 테스트 환경에서 사용하기 복잡하다면 Car 클래스의 단위 테스트가 매우 어려워집니다. 가짜 엔진(Mock Object)을 넣어 테스트하고 싶어도 구조적으로 불가능합니다.

이러한 문제를 해결하기 위해 개발자들은 한 가지 아이디어를 떠올렸습니다.

“객체가 필요한 부품(의존성)을 직접 만들지 말고, 외부에서 만들어서 넣어주면 어떨까?”

이것이 바로 의존성 주입의 핵심 아이디어입니다. CarEngine을 만드는 것이 아니라, Car를 만드는 누군가가 Engine을 만들어서 Car에게 전달해주는 것이죠.

// 의존성을 외부에서 주입받는 방식
public class Car {
    private Engine engine;
 
    // 생성자를 통해 외부에서 Engine을 전달받습니다.
    public Car(Engine engine) {
        this.engine = engine;
    }
 
    public void start() {
        engine.turnOn();
    }
}
 
// 사용하는 쪽
Engine myEngine = new ElectricEngine(); // 전기 엔진을 만들고
Car myCar = new Car(myEngine); // 자동차에 주입!
myCar.start();

이제 Car 클래스는 GasolinEngine이나 ElectricEngine 같은 구체적인 클래스를 전혀 알지 못합니다. 그저 Engine이라는 ‘규격’(인터페이스)에 맞는 부품이라면 무엇이든 받아서 사용할 수 있습니다. 결합이 느슨해진 것입니다. 덕분에 우리는 Car 코드 수정 없이 엔진을 마음대로 교체할 수 있고, 테스트 시에는 가짜 엔진을 쉽게 주입할 수 있게 되었습니다.

2. 의존성 주입의 구조 (핵심 구성 요소)

의존성 주입은 크게 세 가지 핵심 요소로 구성됩니다.

  1. 서비스 (Service / Dependency): 도움이 되는 객체. 즉, 의존성의 대상이 되는 객체입니다. 위의 예시에서는 Engine이 서비스에 해당합니다.

  2. 클라이언트 (Client): 서비스를 사용하는 객체. 즉, 의존성을 주입받는 객체입니다. 예시에서는 Car가 클라이언트입니다.

  3. 인젝터 (Injector / DI Container): 클라이언트가 필요로 하는 서비스를 생성하고, 클라이언트에게 서비스를 주입하는 역할을 담당합니다. 예시에서는 new Car(new ElectricEngine()) 코드를 실행하는 외부의 무언가가 인젝터의 역할을 수행한 것입니다.

이 구조의 핵심은 클라이언트가 자신이 사용할 서비스를 직접 선택하거나 생성하지 않는다는 점입니다. 모든 권한은 인젝터에게 위임됩니다. 클라이언트는 그저 인젝터가 건네주는 서비스를 수동적으로 받아서 사용할 뿐입니다.

이처럼 객체의 생성과 구성에 대한 제어권이 클라이언트 자신에게서 외부(인젝터)로 넘어갔다고 해서, 이를 **제어의 역전(Inversion of Control, IoC)**이라고 부릅니다. 의존성 주입은 바로 이 IoC 원칙을 구현하는 대표적인 방법 중 하나입니다.

비유로 이해하기: 레스토랑 주방

  • 클라이언트: 요리사 (Chef)

  • 서비스: 식재료 (Ingredient - 예: Meat, Vegetable)

  • 인젝터: 주방 관리 시스템 (KitchenSystem)

과거의 방식이라면, 요리사가 스테이크를 만들기 위해 직접 농장에 가서 소를 키우고, 밭에 가서 채소를 재배해야 했습니다. (강한 결합)

하지만 IoC/DI가 적용된 현대적인 주방에서는, 요리사는 그저 “고기 주세요”, “채소 주세요” 라고 요청만 합니다. 그러면 주방 관리 시스템이 미리 준비된 최상급의 고기와 신선한 채소를 요리사에게 가져다줍니다. 요리사는 식재료가 어디서 어떻게 왔는지 신경 쓸 필요 없이 요리에만 집중할 수 있습니다. 주방 관리 시스템 덕분에 오늘은 한우를, 내일은 와규를 사용하는 등 식재료를 유연하게 바꿀 수도 있습니다.

3. 의존성 주입의 방법들 (How-to)

의존성을 주입하는 방식에는 크게 세 가지가 있습니다. 각각의 장단점을 이해하고 상황에 맞게 사용하는 것이 중요합니다.

1) 생성자 주입 (Constructor Injection)

가장 널리 권장되는 방식으로, 클라이언트의 생성자를 통해 의존성을 주입합니다.

public class Car {
    private final Engine engine; // final 키워드 사용 가능
 
    // 생성자에서 의존성을 주입받음
    public Car(Engine engine) {
        this.engine = engine;
    }
}

장점:

  • 의존성 불변성 확보: final 키워드를 사용하여 의존성을 한번 주입받은 후에는 변경되지 않도록 강제할 수 있습니다. 이는 객체의 상태를 예측 가능하고 안정적으로 만듭니다.

  • 의존성 명시: 생성자의 시그니처만 봐도 이 클래스가 어떤 의존성을 필요로 하는지 명확하게 알 수 있습니다. 의존성 없이는 객체를 생성할 수 없으므로, 누락될 위험이 없습니다.

  • 순환 참조 방지: A가 B를, B가 다시 A를 필요로 하는 ‘순환 참조’가 발생했을 때, 애플리케이션 실행 시점에 오류를 발견할 수 있습니다.

단점:

  • 주입받을 의존성이 많아지면 생성자의 인자가 너무 길어질 수 있습니다. (하지만 이는 해당 클래스가 너무 많은 책임을 가지고 있다는 신호일 수 있습니다 - 단일 책임 원칙 위반)

2) 세터 주입 (Setter Injection)

세터(Setter) 메서드를 통해 의존성을 주입하는 방식입니다.

public class Car {
    private Engine engine;
 
    public Car() {
        // 기본 생성자
    }
 
    // 세터 메서드를 통해 의존성을 주입받음
    public void setEngine(Engine engine) {
        this.engine = engine;
    }
}

장점:

  • 선택적 의존성 주입: 해당 의존성이 필수적이지 않을 때 유용합니다. 객체를 생성한 후에 필요에 따라 의존성을 주입하거나 변경할 수 있습니다.

  • 유연성: 객체의 생명주기 동안 의존성을 교체해야 하는 경우에 사용할 수 있습니다.

단점:

  • 의존성 누락 가능성: 개발자가 setEngine() 메서드를 호출하는 것을 잊으면, enginenull인 상태로 Car 객체가 사용되어 NullPointerException이 발생할 수 있습니다.

  • 불변성 확보 불가: 객체의 상태가 외부에서 언제든지 변경될 수 있어 안정성이 떨어집니다.

3) 필드 주입 (Field Injection)

클래스의 필드(멤버 변수)에 직접 의존성을 주입하는 방식입니다. 주로 어노테이션(@)을 사용하는 프레임워크에서 많이 볼 수 있습니다. (예: Spring의 @Autowired)

public class Car {
    // 필드에 직접 의존성을 주입 (프레임워크의 도움이 필요)
    @Inject
    private Engine engine;
 
    public Car() {}
}

장점:

  • 코드의 간결함: 생성자나 세터 메서드를 작성할 필요가 없어 코드가 매우 짧아집니다.

단점:

  • 숨겨진 의존성: 클래스 내부 필드를 봐야만 어떤 의존성을 사용하는지 알 수 있어, 외부에서는 의존 관계가 명확히 드러나지 않습니다.

  • 단위 테스트의 어려움: DI 컨테이너 없이는 의존성을 주입할 방법이 마땅치 않습니다. 테스트 코드에서 객체를 생성하고 필드에 직접 Mock 객체를 주입하려면 리플렉션(Reflection)과 같은 복잡한 기술이 필요합니다.

  • 단일 책임 원칙 위반 가능성: 필드 주입이 너무 간편하다 보니, 개발자가 무분별하게 많은 의존성을 추가하여 클래스가 비대해질 위험이 있습니다.

어떤 방식을 사용해야 할까?

1순위: 생성자 주입 - 대부분의 경우 가장 좋은 선택입니다. 의존 관계가 명확하고, 객체의 안정성을 보장합니다.

2순위: 세터 주입 - 의존성이 선택적이거나, 변경 가능해야 할 특별한 경우에 사용합니다.

3순위: 필드 주입 - 편리하지만 단점이 명확하여 사용을 지양하는 것이 좋습니다. 특히 순수 Java 환경이나 테스트 코드 작성이 중요한 경우에는 피해야 합니다.

4. 심화 내용: DI 컨테이너와 장단점

DI 컨테이너 (DI Container) 란?

지금까지의 예제에서는 new Car(new ElectricEngine())처럼 우리가 직접 객체를 생성하고 주입하는 코드를 작성했습니다. 하지만 프로젝트의 규모가 커지고 관리해야 할 객체가 수백, 수천 개가 되면 이 작업은 매우 번거롭고 복잡해집니다.

DI 컨테이너는 이 과정을 자동화해주는 강력한 도구(프레임워크)입니다. 개발자는 어떤 객체를 어떻게 생성하고, 어떤 의존성을 주입해야 하는지에 대한 ‘설정’만 해주면, DI 컨테이너가 애플리케이션 실행 시점에 알아서 객체를 생성하고, 의존성을 분석하여, 필요한 곳에 주입해줍니다.

대표적인 DI 컨테이너는 다음과 같습니다.

  • Java: Spring Framework, Google Guice

  • Android (Java/Kotlin): Dagger, Hilt

  • TypeScript (Node.js): NestJS, InversifyJS

  • C#: .NET Core 내장 DI 컨테이너

DI 컨테이너를 사용하면 개발자는 객체 생성과 관리라는 지루한 작업에서 해방되어 비즈니스 로직 구현에만 집중할 수 있습니다.

의존성 주입의 장점과 단점

모든 기술에는 명과 암이 있듯이, 의존성 주입도 장점과 단점을 모두 가지고 있습니다.

장점 (Pros)단점 (Cons)
결합도 감소 (Loose Coupling): 클래스들이 서로 느슨하게 연결되어, 특정 구현에 종속되지 않고 유연하게 상호작용할 수 있습니다.학습 곡선 (Learning Curve): DI 패턴과 DI 컨테이너의 동작 방식을 이해하기 위한 초기 학습 비용이 발생합니다.
코드 재사용성 증가: 특정 환경에 종속되지 않는 독립적인 컴포넌트를 만들 수 있어 다른 곳에서 재사용하기 용이합니다.복잡성 증가: 간단한 애플리케이션에서는 오히려 코드가 더 복잡해지고 장황해 보일 수 있습니다. 클래스와 인터페이스가 늘어납니다.
테스트 용이성 향상: 실제 객체 대신 Mock(가짜) 객체를 쉽게 주입하여, 다른 부분과 격리된 상태에서 단위 테스트를 수행하기 매우 편리합니다.런타임 오류 가능성: 의존성 설정이 잘못된 경우, 컴파일 시점에는 오류를 잡지 못하고 애플리케이션 실행 시점에 오류가 발생할 수 있습니다.
유지보수성 및 확장성 향상: 기능의 변경이나 확장이 필요할 때, 관련된 컴포넌트만 수정하거나 새로운 컴포넌트를 만들어 교체하면 되므로 코드 수정 범위가 최소화됩니다.디버깅의 어려움: 코드의 흐름이 명시적으로 드러나지 않고 프레임워크 내부에서 결정되므로, 문제가 발생했을 때 호출 스택을 추적하기가 다소 어려울 수 있습니다.

결론: 좋은 설계를 위한 첫걸음

의존성 주입(DI)은 단순히 코드를 작성하는 기술을 넘어, **‘어떻게 하면 더 유연하고, 테스트하기 쉽고, 확장 가능한 소프트웨어를 만들 수 있을까?’**라는 고민에서 탄생한 설계 철학입니다. 처음에는 제어의 역전(IoC)이라는 개념이 낯설고, 코드가 오히려 복잡해지는 것처럼 느껴질 수 있습니다.

하지만 DI를 통해 얻을 수 있는 ‘느슨한 결합’이라는 가치는 프로젝트의 규모가 커질수록, 그리고 시간이 지날수록 눈부시게 빛을 발합니다. 마치 잘 정리된 공구함에서 필요한 도구를 바로 꺼내 쓰는 것처럼, DI는 우리가 만든 소프트웨어 컴포넌트들을 필요에 따라 손쉽게 교체하고 조립할 수 있게 해주는 최고의 도구입니다.

오늘 핸드북을 통해 의존성 주입의 개념이 명확히 정리되셨기를 바랍니다. 이제 여러분의 코드에 붙어있는 ‘강력 접착제’를 떼어내고, 유연하고 견고한 레고 성을 만들어나갈 준비가 되셨습니다.

레퍼런스(References)

의존성 주입