프로그래밍-학습기록/객체 지향 프로그래밍

DI(Dependency Injection)와 서비스 로케이터

leesche 2021. 1. 14. 04:09

DI(Dependency Injection)와 서비스 로케이터

애플리케이션 영역과 메인 영역

(본 책에서는 예제를 통해 애플리케이션과 메인 영역을 설명한다. 예제가 구체적이고 설명도 자세해서 책을 찬찬히 읽으면 이해가 잘 된다.)

메인 영역은 다음 작업을 수행한다.

  • 어플리케이션 영역에서 사용될 객체를 생성한다.
  • 각 객체 간의 의존 관계를 설정한다.
  • 어플리케이션을 실행한다.

메인 영역은 어플리케이션영역의 객체를 생성하고, 설정하고, 실행하는 책임을 갖기 때문에, 어플리케이션 영역에서 사용할 하위 수준의 모듈을 변경하고 싶다면 메인 영역을 수정하게 된다.

모든 의존은 메인 영역에서 어플리케이션 영역으로 향한다. 반대의 경우인 어플리케이션 영역에서 메인 영역으로의 의존은 존재하지 않도록 한다. 이는 메인 영역을 변경한다고 해도 어플리케이션 영역은 변경되지 않는다는 것을 뜻한다. 따라서 어플리케이션에서 사용할 객체를 교체하기 위해 메인 영역의 코드를 수정하는 것은 어플리케이션 영역에는 어떠한 영향도 끼치지 않는다.

서비스 로케이터 방식은 로케이터를 통해서 필요로 하는 객체를 직접 착는 방식인데, 이 방식에는 몇 가지 단점이 존재한다. 그래서, 서비스 로케이터를 사용하기보다는 외부에서 사용할 객체를 주입해 주는 DI(Dependency Injection) 방식을 사용하는 것이 일반적이다. 본 장에서는 먼저 DI가 무엇인지 알아보고, 그 뒤에 서비스 로케이터와 비교해 보도록 하겠다.

DI(Dependency Injection)을 이용한 의존 객체 사용

사용할 객체를 직접 생성할 경우, 콘크리트 클래스에 대한 의존이 발생하게 된다.

콘크리트 클래스를 직접 사용해서 객체를 생성하게 되면 의존 역전 원칙을 위반하게 되며, 결과적으로 확장 폐쇄 원칙을 위반하게 된다. 이는 변화에 경직된 유연하지 못한 코드를 만들게 된다. 또한, 서비스 로케이터를 사용하면 서비스 로케이터를 통해서 의존 객체를 찾게 되는데, 이 경우 몇 가지 단점이 발생한다.

이런 단점을 보완하기 위한 방법이 DI(Dependency Injection: 의존 주입)이다. DI는 필요한 객체를 직접 생성하거나 찾지 않고 외부에서 넣어 주는 방식이다. DI 자체의 구현은 매우 간단한데, 사용할 객체를 전달받을 수 있는 방법을 제공하면 DI를 적용하기 위한 모든 준비가 끝난다.

DI를 통해서 의존 객체를 관리할 때에는 객체를 생성하고 각 객체들을 의존 관계에 따라 연결해주는 조립 기능이 필요하다. 예제(이 글에서는 없다)에서는 Main 클래스가 조립기의 역할을 함께 하고 있는데, 조립기를 별도로 분리하면 향후에 조립기 구현 변경의 유연함을 얻을 수 있다.

이렇게 객체 조립 기능이 분리되면, 이후에 XML 파일을 이용해서 객체 생성과 조립에 대한 정보를 설정하고, 이 XML 파일을 읽어 와 초기화 해주도록 구현을 변경할 수 있을 것이다. 자바를 주로 사용하고 있다면, 웹 개발에서 많이 사용되고 있는 스프링 프레임워크가 떠오를 것이다. 스프링 프레임워크가 바로 객체를 생성하고 조립해 주는 기능을 제공하는 DI 프레임워크이다.

생성자 방식과 설정 메서드 방식

DI를 적용하려면 의존하는 개체를 전달받을 수 있는 방법을 제공해야 하는데, 이 방법에는 크게 다음의 두 가지 방식이 존재한다.

  • 생성자 방식
  • 설정 메서드 방식

생성자 방식은 생성자를 통해서 의존 객체를 전달받는 방식이다. 앞에서 봤던 예가 바로 생성자 방식을 사용하였다.

생성자를 통해 전달 받은 객체를 필드에 보관한 뒤, 메서드에서 사용하게 된다.

설정 메서드 방식은 메서드를 이용해서 의존 객체를 전달받는다.

설정 메서드를 어떻게 구현할지 여부는 사용할 DI 프레임워크에 따라 달라질 수 있다.

예를 들어, 스프링 프레임워크 초기 버전은 public void setSome(Some some) 형식의 설정 메서드를 지원했기에 한 번에 여러 의존 객체를 전달하려면 생성자 방식을 사용해야 했다.

생성자 방식이나 설정 메서드 방식을 이용해서 의존 객체를 주입할 수 있게 되었다면, 조립기는 생성자와 설정 메서드를 이용해서 의존 객체를 전달하게 된다.

각 방식의 장단점

DI 프레임워크가 의존 객체 주입을 어떤 방식까지 지원하느냐에 따라 달라지겠지만, 필자(작가)는 생성자 방식과 설정 메서드 방식 중 생성자 방식을 더 선호한다. 그 이유는 생성자 방식은 객체를 생성하는 시점에 필요한 모든 의존 객체를 준비할 수 있기 때문이다. 생성자 방식은 생성자를 통해 필요한 의존 객체를 전달받기 때문에, 객체를 생성하는 시점에서 의존 객체가 정상인지 확인할 수 있다.

생성 시점에 의존 객체를 모두 받기 때문에, 한 번 객체가 생성되면 객체가 정상적으로 동작함을 보장할 수 있게 된다.

생성자 방식을 사용하려면 의존 객체가 먼저 생성되어 있어야 하므로, 의존 객체를 먼저 생성할 수 없다면 생성자 방식을 사용할 수 없게 된다.

생성자 방식과 달리 설정 메서드 방식은 객체를 생성한 뒤에 의존 객체를 주입하게 된다. 이 경우 의존 객체를 설정하지 못한 상태에서 객체를 사용할 수 있게 되므로, 객체를 메서드를 실행하는 과정에서 NullPointerException이 발생할 수 있게 된다.

생성자 방식과 달리 설정 메서드 방식은 객체를 생성한 이후에 의존 객체를 설정할 수 있기 때문에, 어떤 이유로 인해 의존할 객체가 나중에 생성된다면 설정 메서드 방식을 사용해야 한다.

의존할 객체가 많을 경우, 설정 메서드 방식은 메서드 이름을 통해서 어떤 의존 객체가 설정되는지 (생성자 방식에 비해) 보다 쉽게 알 수 있으며, 이는 코드 가독성을 높여 주는 효과가 있다.

DI와 테스트

단위 테스트는 한 클래스의 기능을 테스트하는데 초점을 맞춘다.

DI는 의존 객체를 Mock 객체로 쉽게 대체할 수 있도록 함으로써 단위 테스트를 할 수 있도록 돕는다.

DI를 사용하지 않는 상황이라면, 한 클래스의 테스트 때문에 다른 클래스의 코드를 변경해 주는 상황이 발생할 수 있다. 게다가 Mock 객체를 이용한 테스트를 마치면, 다시 원래대로 코드를 되돌려야 한다.

스프링 프레임워크에서 예

대표적인 DI 프레임워크인 스프링 프레임워크는 생성자 방식과 설정 메서드 방식을 모두 지원하고 있다. 스프링은 XML 파일을 이용해 객체를 어떻게 생성하고 조립할지를 설정한다.

  • 태그는 생성자에 전달할 객체를 지정할 때 사용된다.

      // <constructor-arg ref="fileJobQueue" />
      // <constructor-arg ref="ffmpegTranscoder" />
      // 이 설정은 다음과 비슷한 코드를 실행한다고 생각하면 된다.
      Worker worker = new Worker(fileJobQueue, ffmpegTranscoder);
  • 태그는 설정 메서드 방식을 이용한다. XML 파일에서 name 속성의 값의 메서드를 이용해 의존 객체를 설정한다는 의미이다.

      JobCLI jobCli = new JobCLI();
      // <property name = "jobQueue" ref="fileJobQueue" />
      // 위 XML은 다음과 비슷한 코드를 실행한다고 생각하면 된다.
      jobCli.setJobQueue(fileJobQueue);

스프링 XML 설정에서는 순서나 이름이 잘 드러나지 않는 태그 보다는 어떤 프로퍼티에 의존을 설정하는지 잘 드러나는 태그가 설정의 가독성을 높여 준다. 이런 이유로, 필자(작가)의 경우 스프링을 처음 접할 때 생성자 방식보다 설정 메서드 방식을 주로 사용하게 되었다.

XML 설정 파일을 작성했다면, 스프링 프레임워크가 제공하는 클래스를 이용해서 XML 파일에 설정된 객체를 생성하고 조립할 수 있다.

다음은 스프링 프레임워크를 이용하는 코드의 일부를 보여주고 있다.

ApplicationContext context = new ClassPathXmlApplicationContext(
    new String[] {"config.xml"});
Worker worker = (Worker)context.getBean("worker");
JobCLI jobCli = (JobCLI)context.getBean("jobCli");
jobCli.interact();
worker.run();

ClassPathXmlApplicationContext 클래스는 XMl 파일을 분석한 뒤, XMl 파일에 정의되어 있는 방법으로 객체를 생성하고 생성자/설정 메서드를 이용해서 의존 객체를 주입해준다. 즉, 스프링은 객체를 생성하고 연결해 주는 조립기의 역할을 수행한다. 스프링이 객체를 생성하는 과정을 완료하면, 위 코드에서 보듯이 getBean() 메서드로 객체를 구해서 원하는 기능을 실행하면 된다.

외부 설정 파일을 사용할 경우의 장점은 의존할 객체가 변경될 때 자바 코드를 수정하고 컴파일할 필요 없이 XML 파일만 수정해 주면 된다는 점이다. 스프링은 XMl 기반의 다양한 설정 방법을 제공하고 있어서 보다 유연하게 객체 조립을 설정할 수 있다. (예를 들어, 외부의 프로퍼티 파일을 참조할 수 있으며, 프로퍼티 파일 교체 등의 방법으로 완전히 다른 XML 파일을 사용하도록 설정할 수도 있다.)

XML 설정 파일을 사용하는 방식은 개발자가 입력한 오타에 취약하다. XML 파일에 입력한 클래스 이름에 오타가 있을 경우, 이 사실을 알아내려면 프로그램을 실행해봐야 한다. 프로그램을 실행할 때 XML에 오류가 있을 경우 스프링 프레임워크는 예외를 발생시키는데, 이 예외가 발생되어야 비로소 XML의 오류를 알 수 있는 것이다.

XML을 사용할 때의 문제점을 해소하기 위한 방안으로 스프링 3 버전부터는 자바 코드 기반의 설정 방식이 추가되었다.

자바 기반의 설정의 장점은 오타로 인한 문제가 거의 발생하지 않는다는 점이다. 잘못이 있을 경우 컴파일 과정에서 다 드러나기 때문에 이클립스와 같은 IDE를 사용하면 설정 코드를 작성하는 시점에서 바로 확인할 수 있다. 반면에 의존 객체를 변경해야 할 경우, 앞서 XMl 파일을 이용할 때는 파일만 변경해 주면 됐지만 자바 기반 설정에서는 자바 코드를 수정해서 다시 컴파일하고 배포해 주어야 하는 단점이 있다.

서비스 로케이터를 이용한 의존 객체 사용

프로그램 개발 환경이나 사용하는 프레임워크의 제약으로 인해 DI 패턴을 적용할 수 없는 경우가 있다. 예를 들어, 모바일 앱을 개발할 때 사용되는 안드로이드 플랫폼의 경우는 화면을 생성할 때 Activity 클래스를 상속받도록 하고 있는데, 이 때 안드로이드 실행 환경은 정해진 메서드만을 호출할 뿐, 안드로이드 프레임워크가 DI 처리를 위한 방법을 제공하지는 않는다.

서비스 로케이터 구현

서비스 로케이터(service locator)는 어플리케이션에서 필요로 하는 객체를 제공하는 책임을 갖는다. 서비스 로케이터는 의존 대상이 되는 객체 별로 제공 메서드를 정의한다.

의존 객체가 필요한 코드에서는 ServiceLocator가 제공하는 메서드를 이용해 필요한 객체를 구한 뒤 알맞은 기능을 실행한다.

서비스 로케이터가 올바르게 동작하려면 서비스 로케이터 스스로 어떤 객체를 제공해야 할지를 알아야 한다. 앞서 DI를 사용할 때 메인 영역에서 객체를 생성했던 것과 비슷하게, 서비스 로케이터를 사용하는 경우에도 메인 영역에서 서비스 로케이터가 제공할 객체를 초기화 해준다.

서비스 로케이터는 어플리케이션 영역의 객체에서 직접 접근하기 때문에, 서비스 로케이터는 어플리케이션 영역에 위치하게 된다. 메인 영역에서는 서비스 로케이터가 제공할 객체를 생성하고, 이 객체를 이용해 서비스 로케이터를 초기화 해준다.

서비스 로케이터를 구현하는 방법은 다양하다.

  • 객체 등록 방식의 구현 방법
  • 상속을 통한 구현 방법
  • 지네릭/템플릿을 이용한 구현 방법

객체 등록 방식의 서비스 로케이터 구현

서비스 로케이터를 구현하는 가장 손쉬운 방법은 다음과 같다.

  • 서비스 로케이터를 생성할 때 사용할 객체를 전달한다.
  • 서비스 로케이터 인스턴스를 지정하고 참조하기 위한 static 메서드를 제공한다.

어플리케이션 영역 코드에서는 서비스 로케이터가 제공하는 메서드를 이용해 필요한 객체를 구한 뒤, 해당 객체의 기능을 실행하게 된다.

서비스 로케이터가 제공할 객체 종류가 많을 경우, 서비스 로케이터 객체를 생성할 때 한 번에 모든 객체를 전달하는 것은 코드 가독성을 떨어뜨린다. 이런 경우에는 각 객체마다 별도의 등록 메서드를 제공하는 방식을 취해 서비스 로케이터 초기화 부분의 가독성을 높여 줄 수 있다.

객체를 등록하는 방식의 장점은 서비스 로케이터 구현이 쉽다는 데 있다. 생성자나 set 메서드를 통해서 서비스 로케이터가 제공할 객체를 등록한 뒤, 사용 코드에서는 서비스 로케이터의 get 메서드를 이용해 사용할 객체를 구하기만 하면 된다. 하지만, 서비스 로케이터에 객체를 등록하는 인터페이스가 노출되어 있기 때문에 어플리케이션 영역에서 얼마든지 의존 객체를 바꿀 수 있다. 이는 의존 역전 원칙을 어기게 만드는 원인이 될 수 있다.

상속을 통한 서비스 로케이터 구현

  • 객체를 구하는 추상 메서드를 제공하는 상위 타입 구현
  • 상위 타입을 상속받은 하위 타입에서 사용할 객체 설정

지네릭/템플릿을 이용한 서비스 로케이터 구현

서비스 로케이터의 단점은 인터페이스 분리 원칙을 위반한다는 점이다. 이 문제를 해결하려면 의존 객체마다 서비스 로케이터를 작성해 주어야 한다. 이 방법은 의존 객체 별로 서비스 로케이터 인터페이스가 분리되는 효과는 얻을 수 있지만 다음 코드처럼 동일한 구조의 서비스 로케이터 클래스를 중복해서 만드는 문제를 야기할 수 있다.

이런 중복을 피하기 위해서, 자바의 지네릭이나 C++의 템플릿을 이용해서 서비스 로케이터를 구현하면 중복된 코드를 피하면서 인터페이스를 분리한 것과 같은 효과를 낼 수 있다.

서비스 로케이터의 단점

서비스 로케이터의 가장 큰 단점은 동일 타입의 객체가 다수 필요할 경우, 각 개체 별로 제공 메서드를 만들어 주어야 한다는 점이다.

DI를 사용하면 이런 상황이 발생하지 않는다.

서비스 로케이터의 또 다른 단점은 인터페이스 분리 원칙을 위배한다는 점이다. 서비스 로케이터를 사용하는 코드 입장에서, 자신이 필요한 타입뿐만 아니라 서비스 로케이터가 제공하는 다른 타입에 대한 의존이 함께 발생하기 때문에, 다른 의존 객체에 의해서 발생하는 서비스 로케이터의 수정 때문에 영향을 받을 수 있게 된다.

이렇듯 서비스 로케이터는 DI에 변경의 유연함을 떨어뜨리는 문제를 갖고 있기 때문에, 부득이한 상황이 아니라면 서비스 로케이터보다는 DI를 사용하자.


출처

  • 책 <개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴> 6장 DI와 서비스 로케이터