작성일 :


의존성 주입(Dependency Injection)


소프트웨어 개발 환경에서 유연성, 확장성, 테스트 용이성은 중요하다.

이러한 요구사항들을 충족시키기 위한 좋은 방법 중 하나가 의존성 주입 (DI, Dependency Injection) 이다.



‘의존성’ 이란?


객체 지향 프로그래밍에서, 객체들은 서로 ‘의존성’ 을 가지고 상호작용 한다.

한 객체가 다른 객체의 메서드를 호출하거나 데이터에 접근할 때, 이러한 관계를 ‘의존성’ 이라고 한다.

예를 들어, OrderService 객체가 PaymentGateway 객체를 사용하여 결제를 처리하는 경우, OrderServicePaymentGateway 에 의존하고 있다고 할 수 있다.



‘의존성 주입’ 이란?


의존성 주입은 이러한 의존성을 객체 내부가 아닌, 외부 에서 주입하는 기법이다.

즉,

  • 의존성 : 하나의 객체가 다른 객체의 메서드나 데이터를 사용하는 관계

  • 주입 : 의존성이 필요한 객체에 의존성을 외부에서 넘겨주는 과정

이다.

이 방식은 객체를 더 유연하게 만들어, 다양한 환경에서 재사용할 수 있게 하고, 테스트를 용이하게 한다.




의존성 주입의 구현 방식


의존성 주입을 구현하는 방법에는 여러 가지가 있지만, 가장 일반적인 세 가지 방법은 다음과 같다.



생성자 주입(Constructor Injection)


생성자 주입은 의존성 주입 패턴에서 가장 기본적이면서도 널리 사용되는 방식이다.

이 방법에서는객체가 생성될 때 생성자를 통해 모든 필요한 의존성이 주입된다.

이러한 접근 방식으로, 객체가 생성된 후에는 변경 불가능한(Immutable) 상태를 유지하도록 보장한다.


장점

  • 불변성 보장

    객체의 불변성이 보장되어, 객체가 한 번 생성된 후에는 상태가 변경되지 않는다.

    이는 멀티스레드 환경에서의 안정성을 포함하여, 여러 부수적인 이점을 제공한다.

  • 명시성

    필요한 모든 의존성이 생성자를 통해 명시적으로 제공되므로, 객체가 올바르게 생성되기 위해 필요한 의존성을 쉽게 파악할 수 있다.

  • 순환 의존성 방지

    생성자 주입을 사용하면 순환 의존성 문제를 컴파일 시간에 발견할 수 있다.

    이는 객체가 서로를 필요로 할 때 발생하는 문제로, 런타임에 문제가 생기는 것을 방지한다.


단점

  • 복잡한 생성자

    의존성이 많은 경우, 생성자가 길어지고 복잡해질 수 있다.

    이를 해결하기 위해, 팩토리 패턴 같은 다른 패턴을 사용하거나 의존성을 그룹화할 수 있다.



[생성자 주입 예시]

1
2
3
4
5
6
7
8
9
public class OrderService {
  private PaymentGateway paymentGateway;

  public OrderService(PaymentGateway paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  // OrderService 의 메서드들...
}




수정자 주입(Setter Injection)


‘수정자 주입’ 은 객체의 생성 이후, 세터 메서드(Setter)를 통해 의존성을 주입하는 방식이다.

이 방법은 선택적 의존성이나, 런타임에 변경될 수 있는 의존성에 특히 더 유용하다.


장점

  • 유연성

    세터 메서드를 통해 의존성을 주입하기 때문에, 객체 생성 후에도 의존성을 변경할 수 있다.

    이는 런타임에 의존성을 교체할 필요가 없는 경우 유용하다.

  • 선택적 의존성

    모든 의존성이 필수적이지 않은 경우, 수정자 주입을 통해 선택적으로 의존성을 주입할 수 있다.


단점

  • 완전성

    객체가 완전히 초기화되지 않은 상태로 사용될 가능성이 있다.

    즉, 모든 수정자를 호출하지 않고 객체를 사용하게 되면, 런타임 오류가 발생할 수 있다.

  • 불변성 부재

    객체의 의존성이 변경될 수 있으므로, 객체의 불변성이 보장되지 않는다.



[수정자 주입 예시]

1
2
3
4
5
6
7
8
9
public class OrderService {
  private PaymentGateway paymentGateway;

  public void setPaymentGateway(PaymentGateway paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  // OrderService 의 메서드들...
}




인터페이스 주입(Interface Injection)


인터페이스 주입은 객체가 의존성 주입을 받기 위해 특정 인터페이스를 구현하는 방식이다.

이 인터페이스는 의존성을 주입하는 메서들르 정의한다.

의존성 주입 컨테이너 또는 주입자는 이 인터페이스를 통해 객체에 의존성을 주입한다.


장점

  • 명시성

    의존성 주입을 위한 인터페이스를 명시적으로 정의함으로써, 어떤 객체가 어떤 의존성을 필요로 하는지 쉽게 파악할 수 있다.

  • 유연성

    다양한 타입의 의존성을 주입할 수 있는 인터페이스를 통해, 객체의 사용 가능한 의존성을 늘릴 수 있다.


단점

  • 사용 빈도

    다른 방식에 비해 상대적으로 사용 빈도가 낮으며, 특정 상황에서만 유용하다.

  • 구현 복잡성

    각 객체가 의존성 주입을 위한 인터페이스를 구현해야 하므로, 구현이 복잡해질 수 있다.




[인터페이스 주입 예시]

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface IPaymentGatewayInjector {
  void injectPaymentGateway(PaymentGateway paymentGateway);
}

public class OrderService : IPaymentGatewayInjector {
  private PaymentGateway paymentGateway;

  public void InjectPaymentGateway(PaymentGateway paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  // OrderService 의 메서드들...
}





의존성 주입의 핵심 구성요소와 역할


의존성 주입 패턴을 이해하기 위해서는 의존성 주입 과정에 참여하는 네 가지 핵심 구성 요소와 각각의 역할을 파악하는 것이 중요하다.

이 글에서는 사용될 서비스 객체, 사용하는 서비스에 의존하는 클라이언트 객체, 클라이언트의 서비스 사용방법을 정의하는 인터페이스, 그리고 서비스를 생성하고 클라이언트로 주입하는 책임을 갖는 주입자에 대해 다뤄보고자 한다.



사용될 서비스 객체(Service Object)

정의와 역할

서비스 객체는 특정 기능을 구현한 객체로, 다른 객체가 사용할 수 있는 서비스를 제공한다.

예를 들어, 결제 처리, 데이터베이스 액세스, 외부 시스템과의 통신 등이 이에 해당한다.


중요성

서비스 객체는 애플리케이션의 핵심 기능을 담당하며, 재사용성과 모듈성을 가능하게 한다.

의존성 주입을 통해 이러한 서비스 객체를 다양한 클라이언트 객체에 재사용할 수 있다.



사용하는 서비스에 의존하는 클라이언트 객체(Client Object)

정의와 역할

클라이언트 객체는 서비스 객체를 사용하여 특정 작업을 수행하는 객체이다.

클라이언트는 서비스의 구현에 직접적으로 의존하지 않고, 대신 서비스를 사용하는 인터페이스에 의존한다.


중요성

클라이언트 객체의 유연성과 테스트 용이성은 서비스 객체의 주입 방식에 크게 의존한다.

의존성 주입을 통해 클라이언트 객체는 런타임에 필요한 서비스 구현을 받게 되며, 이를 통해 구현의 변경이나 테스트 시 목(Mock)객체의 사용이 용이해진다.



클라이언트의 서비스 사용 방법을 정의하는 인터페이스(Service Interface)

정의와 역할

서비스 인터페이스는 클라이언트가 사용할 서비스의 메서드를 정의한다.

이 인터페이스를 통해 클라이언트는 서비스의 구현 내용을 몰라도 서비스를 사용할 수 있게 된다.


중요성

서비스 인터페이스는 의존성 주입 패턴에서 핵심적인 역할을 한다.

인터페이스를 사용함으로써, 클라이언트 코드는 서비스의 구체적인 구현과 분리되어, 서비스 구현의 변경이 클라이언트 코드에 영향을 주지 않도록 한다.



서비스를 생성하고 클라이언트로 주입하는 책임을 갖는 주입자(Injector)

정의와 역할

주입자(때로는 DI 컨테이너 또는 IoC(Inversion of Control) 컨테이너 라고 함)

이 둘은 종종 혼용되어 사용되지만, 정확히는 다른 개념이다.
IoC, Inversion of Control 는 “제어의 역전” 이라는 디자인 원칙을 말하며, 다양한 방식으로 구현될 수 있다.
구현 방식 중 하나가 ‘의존성 주입’ 이다.

DI 컨테이너IoC 의 한 형태로 볼 수 있는데, 객체의 생성과 의존성 주입의 제어가 개발자로부터 DI 컨테이너 로 이전되기 때문에, “제어의 역전” 원칙이 적용된다.

즉, IoC 는 더 넓은 개념으로, 프로그램의 제어 흐름을 개발자로부터 프레임워크나 라이브러리로 넘기는 원칙을 말한다.
DI 컨테이너 는 이 IoC 원칙을 ‘의존성 주입’ 을 통해 구현하는 구체적인 도구나 프레임워크를 지칭한다.
따라서, 모든 DI 컨테이너IoC 의 한 형태이지만, IoC 가 반드시 DI 컨테이너 를 의미하는 것은 아니다.

는 의존성 주입 패턴에서 서비스 객체를 생성하고, 이를 클라이언트 객체에 주입하는 역할을 담당한다.

주입자는 클라이언트가 필요로 하는 서비스 인스턴스를 관리하고, 필요할 때 클라이언트에게 제공한다.


중요성

주입자는 의존성 주입 패턴의 운영을 담당하는 중추적인 구성 요소이다.

이를 통해 개발자는 객체 생성과 의존성 관리의 복잡성으로부터 비교적 자유로워지고, 코드의 재사용성과 유지보수성이 향상된다.




DI 컨테이너


위에서 살펴본 것 처럼, 소프트웨어 개발에서 의존성 관리는 프로그램의 유연성, 확장성 및 유지보수성을 결정짓는 핵심 요소 중 하나이다.

의존성 주입은 이러한 문제를 해결하는 효과적인 방법으로 자리 잡았지만, 수동으로 의존성을 관리하는 것은 오류가 발생하기 쉽고 많은 시간이 소요된다.

이러한 복잡성을 줄이기 위해 다양한 언어와 프레임워크에서는 DI 컨테이너 를 제공한다.



DI 컨테이너의 역할


  • 객체 생명주기 관리

    DI 컨테이너 는 객체의 생성, 생명주기 관리 및 소멸까지 전반적인 책임을 진다.

    객체가 필요로 하는 의존성을 자동으로 주입하고, 객체가 더 이상 필요하지 않을 때는 이를 적절히 정리한다.


  • 설정 중앙화

    프로그램 내의 의존성 구성을 한 곳에서 관리할 수 있도록 한다.

    이를 통해 개발자는 프로그램의 다양한 부분에서 일관된 방식으로 의존성을 다룰 수 있다.


  • 유연성 및 확장성 향상

    DI 컨테이너 를 사용함으로써, 프로그램의 구성 요소 간 결합도를 낮추고, 필요에 따라 구성 요소를 쉽게 교체할 수 있게 된다.

    이는 프로그램의 확장성과 유연성을 크게 향상시킨다.



DI 컨테이너의 중요성


  • 개발 효율성 증가

    DI 컨테이너 를 사용함으로써 개발자는 의존성 관리의 복잡성에서 벗어나, 비즈니스 로직(Business Logic) 개발에 더 집중할 수 있다.

    이는 개발 시간의 단축과 생산성 향상으로 이어진다.


  • 테스트 용이성

    의존성 주입을 통해, 개발자는 테스트 시에 실제 의존성 대신 목(Mock)객체나 스텁(Stub)을 쉽게 주입할 수 있다.

    이는 단위 테스트의 격리와 용이성을 보장한다.


  • 유지 보수성 향상

    의존성이 명시적이고 중앙화되어 관리되므로, 애플리케이션의 변경 사항이 발생했을 때 이를 빠르고 안전하게 반영할 수 있다.




의존성 주입의 장단점


지금까지의 내용을 종합해보면, 의존성 주입은 소프트웨어 개발에서 널리 사용되는 디자인 패턴으로, 객체 간의 결합도를 줄이고 유연성 및 확장성을 향상시키는 데 중요한 역할을 한다.

하지만, 모든 디자인 패턴들과 마찬가지로, 의존성 주입도 장단점이 존재한다.



장점


  • 결합도 감소

    의존성 주입은 객체 간의 느슨한 결합(Loose Coupling)을 촉진한다.

    객체가 구체적인 클래스가 아닌 인터페이스에 의존할 때, 구현이 변경되어도 클라이언트 코드는 영향을 받지 않는다.

    이러한 느슨한 결합은 시스템의 유연성과 확장성을 크게 향상시킨다.


    예시 :

    고객 관리 시스템에서 이메일 서비스를 사용하는 경우, 이메일 서비스의 구현이 변경되더라도 (예: SMTP 에서 REST API 기반으로 변경) , 클라이언트 코드는 수정할 필요가 없다.

    단지, 새로운 서비스 구현을 DI 컨테이너 에 등록하기만 하면 된다.


  • 테스트 용이성

    의존성 주입은 단위 테스트를 단순화한다.

    테스트 중인 객체에 대한 목(Mock)객체나 스텁(Stub)을 쉽게 주입할 수 있어, 테스트를 격리된 환경에서 수행할 수 있다.

    이는 테스트의 신뢰성을 향상시키고, 테스트 작성 시간을 단축시킬 수 있다.

    예시 :

    결제 시스템을 테스트할 때, 실제 결제 게이트웨이 대신 목(Mock) 결제 처리기를 주입하여, 결제 처리 로직이 올바르게 작동하는지 테스트할 수 있다.

    이렇게 하면 외부 시스템의 영향 없이 테스트가 가능하다.


  • 재사용성 및 유지보수성 향상

    객체의 생성과 의존성 주입을 분리함으로써, 객체를 다양한 컨텍스트에서 재사용할 수 있다.

    또한 의존성이 중앙화되어 관리되므로, 변경 사항이 생겼을 때 관련된 의존성만 수정하면 되어 유지보수가 용이해진다.

    예시 :

    로깅 서비스를 애플리케이션 전반에 걸쳐 사용하는 경우, 로깅 구현을 변경하려면 DI 컨테이너 에서 로깅 서비스 구현을 교체하기만 하면 된다.

    이는 프로그램의 다른 부분에 영향을 주지 않는다.



단점


  • 복잡성 증가

    DI 를 도입하면 초기 학습 곡선이 존재하고, 애플리케이션의 설정과 부트스트래핑(Bootstrapping)이 복잡해질 수 있다.

    특히, 큰 프로젝트에서 DI 컨테이너 를 처음 도입할 때, 구성과 관리가 어려울 수 있다.


  • 런타임 오류

    의존성 주입은 대부분 런타임에 이루어지기 때문에, 컴파일 타임에는 타입 안정성이 보장되지 않는다.

    잘못된 의존성이 주입되거나 필요한 의존성이 주입되지 않는 경우, 프로그램 실행 중 오류가 발생할 수 있다.




결론


의존성 주입은 소프트웨어 개발에서 매우 유용한 디자인 패턴이지만, 적절한 상황에서 올바르게 사용해야 그 이점을 최대화할 수 있다.

의존성 주입의 장점을 활용하되, 단점을 인지하고 이를 완화할 수 있는 전략을 수립하는 것이 중요할 것이다.

예를 들어, 복잡성을 관리하기 위해 적절한 문서화, 단위 테스트, 그리고 의존성 관리 전략을 수립할 필요가 있다.