의존성 주입(Dependency Injection, DI) 또는 의존관계 주입으로 번역되는 이 개념을, <토비의 스프링3>(이일민 저, 에이콘)에서는 아래와 같이 소개했다. 제어의 역전(Inverion of Control, IoC)에서는 오브젝트가 자신이 사용할 오브젝트를 스스로 선택하지 않는다. 당연히 생성하지도 않는다. 또 자신도 어떻게 만들어지고 어디서 사용되는지를 알 수 없다. 모든 제어 권한을 자신이 아닌 다른 대상에게 위임하기 때문이다. (...) 스프링은 IoC를 모든 기능의 기초가 되는 기반기술로 삼고 있으며, IoC를 극한까지 적용하고 있는 프레임워크다. (...) 객체지향적인 설계나, 디자인 패턴, 컨테이너에서 동작하는 서버 기술을 사용한다면 자연스럽게 IoC를 적용하거나 그 원리로 동작하는 기술을 사용하게 될 것이다. (...) 스프링 IoC 기능의 대표적인 동작원리는 주로 의존관계 주입이라고 불린다. 물론 스프링이 컨테이너이고 프레임워크이니 기본적인 동작 원리가 모두 IoC 방식이라고 할 수 있지만, 스프링이 여타 프레임워크와 차별화돼서 제공해주는 기능은 의존관계 주입이라는 새로운 용어를 사용할 때 분명하게 드러난다. (...) 두 개의 클래스 또는 모듈이 의존관계에 있다고 말할 때는 항상 방향성을 부여해줘야 한다. 즉 누가 누구에게 의존하는 관계에 있다는 식이어야 한다. (...) 그렇다면 의존하고 있다는 건 무슨 의미일까? 의존한다는 건 의존대상, 여기서는 B가 변하면 그것이 A에 영향을 미친다는 뜻이다. B의 기능이 추가되거나 변경되거나, 형식이 바뀌거나 하면 그 영향이 A로 전달된다는 것이다. (...) 인터페이스에 대해서만 의존관계를 만들어두면 인터페이스 구현 클래스와의 관계는 느슨해지면서 변화에 영향을 덜 받는 상태가 된다. 결합도가 낮다고 설명할 수 있다. (...) 정리하면 의존관계 주입이란 다음과 같은 세 가지 조건을 충족하는 작업을 말한다.
- 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다. - 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다. - 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.
의존관계 주입의 핵심은 설계 시점에는 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제3의 존재가 있다는 것이다. (...) 가장 간단한 답변은 '유연한 확장이 가능하게 하기 위해서'라고 할 수 있다. DI는 개방 폐쇄 원칙(OCP)이라는 객체지향 설계 원칙으로 잘 설명될 수 있다. 유연한 확장이라는 장점은 OCP의 '확장에는 열려 있다(개방)'에 해당한다. DI는 역시 OCP의 '변경에는 닫혀 있다(폐쇄)'라는 말로도 설명이 가능하다. 폐쇄 관점에서 볼 때 장점은 '재사용이 가능하다'라고 볼 수 있다. (...) 예를 들어보면 서비스 오브젝트가 사용하는 DAO가 있다고 할 때, DAO의 구현을 JDBC로 했다가, 그것을 JPA, 하이버네이트, JDO, iBatis 등으로 변경하는 것을 생각할 수 있다. 구현 방식을 통째로 바꾸는 것이다.
즉, 의존성 주입이란 사용할 오브젝트에 대한 레퍼런스를 외부에서 주입해주는 작업이다. applicationContext.xml / @Autowired 등을 통해 의존관계를 미리 설계해 두면, 스프링은 자동으로 이를 참고해서 객체를 생성, 관리하고 의존관계를 주입해 준다. 개념만 들으면 피상적으로 느껴지므로 아래처럼 예시와 함께 이해하는 것이 빠르다.
실생활 예시
기재팀에서 어떤 노트북을 빌려 주건 상관 없다.
- 사원이 사용하는 노트북이 바뀌어도, 사원은 크게 신경쓸 필요가 없다. : 노트북을 직접 구입하거나 조립하지 않고, 기재팀에서 알아서 용도마다 제공해 줄 테니 가져다가 쓰기만 하면 되기 때문 - 사원이 알고 있는 건 기재팀 뿐이고, 기재팀 내 정확한 노트북 구입이나 조립과정은 알 필요가 없다. - 기재팀은 다양한 종류의 노트북을 보유하고 있다. - 기재팀이 사원에게 필요할 노트북을 골라서 주입(대여)해 준다.
➡️ 즉, 사원은 노트북을 직접 새로 구입(new 생성)하거나 만들지 않고, 필요한 것을 기재팀(외부)에서 전달(DI)받는다. 아래 코드 예시를 보자.
예시 코드
// 1. Laptop 인터페이스 (사원이 사용하는 노트북 종류)
public interface Laptop {
void turnOn();
}
// 2. Laptop의 구현체(한성컴퓨터 제품)
public class HansungLaptop implements Laptop {
@Override
public void turnOn() {
System.out.println("한성 노트북 전원 ON");
}
}
// 3. Laptop의 구현체(MSI 제품)
public class MsiLaptop implements Laptop {
@Override
public void turnOn() {
System.out.println("Msi 노트북 전원 ON");
}
}
// 4. 사원 클래스 - Laptop을 의존
public class Employee {
private final Laptop laptop;
// 사원은 생성 시점에 어떤 Laptop을 사용할지 “외부”(회사 기재팀)에서 주입받음
public Employee(Laptop laptop) {
this.laptop = laptop;
}
public void work() {
laptop.turnOn();
System.out.println("업무를 시작합니다!");
}
}
// 5. 사용하는 예시
public class Main {
public static void main(String[] args) {
// 외부(회사 기재팀)에서 알맞은 Laptop을 선택하여 Employee에게 주입
Laptop myLaptop = new HansungLaptop();
Employee employee = new Employee(myLaptop);
employee.work();
}
}
Employee가 Laptop Interface에 의존하도록 만들어서, 구현체인 HansungLaptop과 MsiLaptop을 알아서 바꿔 끼울 수 있도록 설계한 예시. 생성자 주입을 통해서 어떤 Laptop 객체를 사용할지 외부에서 결정할 수 있다.
장점
1. 느슨한 결합으로 인한 유연성 증가 2. 재사용성 증가 3. DI를 사용한 고립화로 테스트가 용이해짐 4. 코드 가독성 증가
이는 동시에 DI를 사용하지 않았을 때, 불편한 점이 된다.
DI를 사용하지 않은 경우, 1. 내부 코드 수정 없이는 테스트(정상 동작, 예외 상황 등) 작성이 불가능할 때가 많다. 2. 의존 객체(예: Laptop)가 또 다른 의존 객체(DB, 네트워크, 파일 등)을 직접 호출해버릴 수 있어 여러 의존성이 뒤엉키며 테스트 격리를 어렵게 만든다. 3. 테스트에서 가짜 객체(Fake Object)나 스텁(Stub)으로 대체하기가 어려워진다.
DI를 사용하는 경우, 위와 같은 불편을 피할 수 있다.
비고
1. 스프링 Bean은 스프링 컨텍스트가 실행되는 테스트 환경(ex. @SpringBootTest)에서 동작하며, 일반적으로 @Test 내에서 자동으로 주입되지 않는다. 2. 일반 JUnit 테스트 @Test 의 경우, 프레임워크가 객체를 생성해 주는 게 아니라 단순히 <"new"로 직접 테스트 클래스를 만들고 테스트 메서드를 실행>한다. 즉, 일반적인 @Test는 스프링 컨텍스트가 실행되지 않는다.