이미지 출처: dev.to/elabftw
의존성(Dependency)은 소프트웨어 개발에서 중요한 개념으로, 한 요소(예: 모듈, 클래스, 함수)가 다른 요소에 의존하여 동작하는 상황을 의미한다. 쉽게 말해, 어떤 코드가 제대로 동작하기 위해 다른 코드나 라이브러리에 기대는 상황을 의존성이라고 한다. 예를 들어, 사용자 입력을 받아 처리하는 기능이 있다고 가정할 때, 이 기능이 데이터를 서버에 전송하거나 API를 호출해야 한다면, 이 코드는 API에 의존성이 있다고 할 수 있다.
npm
과 같은 패키지 매니저를 사용하여 외부 패키지를 설치하고 관리한다. 이 과정에서 발생하는 의존성 문제를 패키지 의존성이라고 한다. 예를 들어, 프로젝트가 A 패키지와 B 패키지를 사용하고, 이들 패키지가 서로 다른 버전의 C 패키지에 의존할 때, 패키지 간 충돌이 발생할 수 있다. 이를 의존성 지옥(Dependency Hell)이라고 하며, 이런 경우 충돌을 해결하기 위해 다양한 노력이 필요하다.의존성 지옥은 여러 패키지나 라이브러리가 서로 다른 버전의 동일한 라이브러리에 의존할 때 발생하는 문제다. 예를 들어, A 패키지가 C 라이브러리의 1.0 버전에 의존하고, B 패키지가 C 라이브러리의 2.0 버전에 의존한다면, 이 두 버전 간의 충돌로 인해 프로젝트에서 예기치 않은 문제가 발생할 수 있다. 이러한 상황을 해결하기 위해 개발자는 종종 특정 버전의 라이브러리를 선택하거나, 별도의 폴리필(polyfill)을 사용하는 등의 방법을 고려해야 한다.
모듈 간 의존성이 많아지면 코드의 결합도가 높아진다. 결합도는 한 모듈이 다른 모듈과 얼마나 밀접하게 연결되어 있는지를 나타내는 지표다. 결합도가 높으면 한 모듈에서 발생한 변화가 다른 모듈에 광범위하게 영향을 미칠 수 있다. 예를 들어, 데이터 처리 모듈이 로깅 모듈에 강하게 결합되어 있다면, 로깅 모듈의 변경이 데이터 처리 모듈에 예기치 않은 영향을 줄 수 있다. 이런 상황은 유지보수를 어렵게 만든다.
의존성이 많은 코드에서는 단위 테스트(Unit Test)를 수행하기가 어려워진다. 단위 테스트는 특정 모듈이나 컴포넌트의 동작을 독립적으로 검증하는 것이 목표인데, 의존성이 많은 경우에는 다른 모듈이나 컴포넌트가 함께 동작해야 하므로 테스트가 복잡해진다. 이를 해결하기 위해 모의 객체(Mock Object)를 사용하여 테스트 대상 외의 의존성을 가상으로 대체하는 방법이 있다. 예를 들어, API 호출을 테스트할 때 실제 네트워크 요청을 보내는 대신, 네트워크 요청을 모의 객체로 대체하여 테스트할 수 있다.
의존성 주입(Dependency Injection, DI)은 IoC(Inversion of Control)를 구현하기 위한 디자인 패턴이다. 즉, 소프트웨어 디자인 패턴 중 하나이다. 이 패턴은 프로그램의 한 객체가 다른 객체와 협력하기 위해 직접 그 객체를 생성하거나 찾아서 사용하는 대신, 외부에서 필요한 객체(의존성)를 주입해 주는 방식을 말한다. 이 방법은 프로그램을 더 유연하고 테스트하기 쉽게 만든다.
의존성 주입의 기본 아이디어는 객체가 직접 필요한 의존성을 생성하지 않고, 외부에서 전달받는다는 것이다. 이를 통해 객체는 의존하는 구체적인 구현체에 대해 알 필요가 없으며, 이로 인해 객체 간 결합도가 낮아진다.
예를 들어, 다음은 의존성 주입을 사용하지 않은 코드다.
class Car {
constructor() {
this.engine = new Engine(); // 직접 객체를 생성
}
start() {
this.engine.start();
}
}
const myCar = new Car();
myCar.start();
위 코드에서 Car
클래스는 Engine
클래스에 강하게 결합되어 있다. Car
클래스 내부에서 Engine
객체를 직접 생성하기 때문에, Engine
의 구현이 변경되면 Car
클래스도 변경되어야 한다.
의존성 주입을 사용하면 다음과 같이 코드를 수정할 수 있다.
class Car {
constructor(engine) {
this.engine = engine; // 외부에서 주입
}
start() {
this.engine.start();
}
}
const myEngine = new Engine();
const myCar = new Car(myEngine); // 의존성 주입
myCar.start();
이제 Car
클래스는 Engine
의 구체적인 구현에 의존하지 않는다. Engine
객체는 외부에서 생성되어 Car
에 주입되며, 이로 인해 Engine
의 구현이 변경되어도 Car
클래스를 수정할 필요가 없다.
기본적으로 객체가 필요한 의존성을 직접 생성하거나 관리하게 되면, 그 객체는 특정 클래스에 강하게 결합되게 된다. 이는 프로그램의 유연성을 떨어뜨리고, 유지보수와 테스트를 어렵게 만든다. 예를 들어, 클래스 A가 클래스 B를 직접 생성한다면, 클래스 B를 다른 클래스로 대체하거나 수정하는 것이 매우 어려워진다. 의존성 주입은 이러한 문제를 해결하기 위해 도입된 개념이다.
의존성 주입은 여러 가지 중요한 이점을 제공한다.
의존성 주입은 주입 방법에 따라 여러 가지 방식으로 나눌 수 있다.
Car
클래스는 생성자를 통해 Engine
객체를 주입받는다.class Car {
private Engine engine;
// 세터 주입
public void setEngine(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
}
}
interface EngineInjector {
void injectEngine(Car car);
}
class Car implements EngineInjector {
private Engine engine;
@Override
public void injectEngine(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
}
}
의존성 주입(Dependency Injection) 방식 중에서 가장 선호되는 방식은 생성자 주입(Constructor Injection)이다. 이 방식이 선호되는 이유는 다음과 같다.
제어의 역전(Inversion of Control, IoC)은 객체 지향 프로그래밍에서 프로그램의 제어 흐름을 전통적인 방식에서 벗어나게 하는 설계 원칙이다. 일반적으로 객체는 자신이 필요로 하는 의존성을 직접 생성하거나 관리하지만, IoC를 적용하면 이러한 제어권을 외부로 넘겨서 프레임워크나 외부 코드를 통해 관리하도록 한다. 이로 인해, 객체가 스스로의 동작을 완전히 통제하는 대신, 외부에 의존하여 동작을 제어하게 된다.
의존성 주입(Dependency Injection)은 제어의 역전을 구현하는 구체적인 방법 중 하나이다. 의존성 주입은 객체가 자신의 의존성을 스스로 생성하지 않고, 외부에서 주입받는 방식으로 IoC를 구현한다. 예를 들어, 클래스 A가 클래스 B의 기능을 필요로 할 때, A가 B를 직접 생성하는 것이 아니라, 외부에서 B를 주입받는다.
이러한 외부 주입은 코드의 결합도를 낮추고, 테스트와 유지보수성을 높인다. IoC는 객체의 생성을 제어하는 흐름을 외부로 넘기는 큰 틀을 제공하고, DI는 이를 실현하는 방식이다.
// 예시: 의존성 주입 없이 직접 객체 생성
class Service {
private Repository repository = new Repository(); // 직접 객체 생성
}
이 방식은 Service
클래스가 Repository
에 강하게 결합되어 있어 테스트나 유지보수에 어려움이 있다. 반면 DI를 사용하면 객체의 생성이 외부에서 관리되므로 유연성이 높아진다.
// 예시: 의존성 주입을 사용한 객체 생성
class Service {
private Repository repository;
// 외부에서 Repository 객체를 주입받음
public Service(Repository repository) {
this.repository = repository;
}
}
의존성 주입은 크게 세 가지 방식으로 구현할 수 있다.
생성자 주입: 객체의 생성자에서 필요한 의존성을 주입받는다.
class Service {
private Repository repository;
public Service(Repository repository) { // 생성자에서 주입
this.repository = repository;
}
}
세터(setter) 주입: 세터 메서드를 통해 의존성을 주입받는다.
class Service {
private Repository repository;
public void setRepository(Repository repository) { // 세터에서 주입
this.repository = repository;
}
}
필드 주입: 프레임워크에 의해 필드에 직접 주입되는 방식이다. Spring에서는 @Autowired
를 사용한다.
class Service {
@Autowired
private Repository repository; // 필드에 주입
}
IoC 컨테이너는 의존성 주입을 관리하는 프레임워크 또는 라이브러리를 말한다. IoC 컨테이너는 객체의 생명주기와 의존성을 관리하며, 필요한 의존성을 객체에 주입해준다. Spring Framework의 IoC 컨테이너가 대표적인 예이다.
IoC 컨테이너는 다음과 같은 역할을 한다.
Spring Framework에서 IoC 컨테이너는 @Component
, @Autowired
, @Bean
등의 어노테이션을 통해 객체를 자동으로 생성하고 의존성을 주입하는 역할을 한다.
🤔 어노테이션(annotation)은 자바와 같은 프로그래밍 언어에서 메타데이터(metadata)를 코드에 추가하는 방법이다. 메타데이터란, 코드 자체에 대한 정보를 설명하는 데이터로, 컴파일러에게 특정한 지시사항을 전달하거나 런타임에 특정 동작을 수행하게 할 수 있다.