주요글: 도커 시작하기
반응형
Spring, PicoContainer 등 경략 콘테이너의 주요 개념인 IoC에 대해서 살펴본다.

디펜던시 인젝션 패턴을 살펴보기에 앞서

본 글은 마틴 파울러(Martin Fowler)가 작성한 'Inversion of control Containers and the Dependency pattern'을 번역한 글로서, 생략해도 문제가 없는 부분은 줄이거나 생략하였다. 본 글에서는 IoC에 대해서 살펴보는데, 본 글이 Spring이나 PicoContainer와 같은 경량 콘테이너의 핵심 개념인 디펜던시 인젝션(dependency injection) 패턴을 이해함으로써 이들 콘테이너를 보다 더 효과적으로 사용하는데 도움이 되길 바란다.
요즘 J2EE를 대체할만한 것들이 오픈소스로 진행되고 있다. 이것들은 주류에 해당하는 J2EE의 복잡함을 없애고자 출현하기 시작했으며 참신한 아이디어를 채용하고 있다. 이들의 공통된 이슈는 서로 다른 객체들을 어떻게 연결하느냐에 대한 것이다. 웹 컨트롤러 아키텍처와 데이터베이스 인터페이스를 서로 다른 팀이 개발할 때, 두 팀이 상대의 코드에 대해 잘 모르는 경우 어떻게 두가지를 엮어낼 것인가 하는 문제는 우리가 흔이 겪는 문제이다. 이 문제를 해결하기 위해 다수의 프레임워크가 등장했고, 몇몇 프레임워크는 다른 레이어에 위치하는 컴포넌트를 조립하는 기능을 제공하는 방법을 만들어냈다. 이런 기능을 제공하는 프레임워크를 보통 경량 콘테이너(lightweight container)라고 부르며, Spring과 PicoContainer가 바로 이런 경량 콘테이너에 해당된다.

이들 콘테이너는 몇가지 흥미로운 디자인 이론에 기반하고 있다. 본 글에서 마틴 파울러(이후로는 '필자'라고 표현하겠다)는 이들 이론 중 몇가지를 살펴볼 것이다. 본 글에서 사용되는 예제는 자바 언어로 구현되어 있지만, 대부분의 객체지향 언어에도 동일하게 적용가능하다.

컴포넌트와 서비스

객체를 서로 연결하는 (즉, 객체를 조립하는) 내용에 대해서 살펴보기 전에 서비스(service)와 컴포넌트(component)라는 용어를 본 글에서 어떤 의미로 사용할 것인지에 대해서 명확하게 정의하고 넘어가도록 하겠다.

먼저, 어플리케이션에서 사용될 목적으로 만들어진 소프트웨어의 한 구성 요소를 표현할 때에는 컴포넌트라는 용어를 사용할 것이다. 컴포넌트의 기능을 확장하는 목적이 아닌 이상 어플리케이션은 컴포넌트의 소스 코드를 수정하지 않으며, 확장하더라도 컴포넌트 작성자가 허용한 방식으로만 확장한다.

서비스는 외부 어플리케이션에서 사용된다는 점에서 컴포넌트와 비슷하다. 컴포넌트와의 주요 차이점은 컴포넌트는 로컬에서 (jar 파일이나 dll 형태로) 사용되지만, 서비스는 지정한 원격 인터페이스(웹서비스, 메시징 시스템, RPC, 소켓 등)를 통해 원격으로 동기/비동기 방식으로 사용된다는 것이다.

본 글에서는 '서비스'를 주로 사용하지만 같은 로직을 로컬 '컴포넌트'에도 동일하게 적용할 수 있다. 사실, 원격 서비스에 쉽게 접근하기 위해 로컬 컴포넌트가 필요할 때도 있다. 하지만, 매번 '컴포넌트 또는 서비스'라고 쓰고 읽는 것은 귀찮은 작업이므로 본 글에서는 대부분의 경우에 '컴포넌트 또는 서비스' 대신에 '서비스'라는 용어를 사용하도록 하겠다.

간단한 예제

본 글에서 살펴볼 디펜던시 인젝션에 대해 좀더 구체적으로 이해를 돕기 위해 실행 가능한 예제를 사용할 것이다. 이 예제는 매우 간단한 예제로서 매우 작아서 실제 개발에 비해 다소 현실감이 떨어지지만 개념을 이해하는데에는 충분한 예제가 될 것이다.

이 예제에서 필자는 특정 감독이 제작한 영화 목록을 제공하는 컴포넌트를 작성할 것이다. 이 기능은 다음과 같이 한개의 메소드를 구현하였다.

    class MovieLister...
        public Movie[] moviesDirectedBy(String arg) {
            List allMovies = finder.findAll();
            for (Iterator it = allMovies.iterator(); it.hasNext();) {
                Movie movie = (Movie) it.next();
                if (!movie.getDirector().equals(arg)) it.remove();
            }
            return (Movie[]) allMovies.toArray(new Movie[allMovies.size()]);
        }

위 코드의 구현은 매우 간단하다. 먼저 finder 객체를 통해서 전체 영화 목록을 구한 뒤, 그 목록에서 특정 감독이 제작한 영화를 추려내는 작업을 한다. (finder 객체는 특정한 방법으로 구한다.) 이 코드는 앞으로 수정하지 않을 것이며, 이 코드를 기반으로 해서 본 글에서 살펴보고자 하는 실제 내용을 전개할 것이다.

본 글에서 살펴보고가 하는 핵심 내용은 이 finder 객체를 어떻게 MovieLister 객체와 연결시키느냐 하는 것이다. 이것이 흥미로운 이유는 앞서 작성한 moviesDirectedBy 메소드에서 저장된 Movie 객체를 찾는 코드가 finder 객체에 전적으로 의존하고 있기 때문이다. 따라서, finder를 참조하는 모든 메소드는 finder 객체의 findAll 메소드를 사용하는 방법을 알아야 한다. 필자는 finder 객체를 위한 인터페이스를 정의함으로써 finderAll 메소드의 사용방법을 정의하였다.

    public interface MovieFinder {
        List findAll();
    }

이렇게 함으로써 MovieLister와 MovieFinder의 결합도(coupling)가 낮아지지만, 실제 Movie 목록을 구하기 위해서는 MovieLister 클래스가 사용할 MovieFinder의 구현 클래스를 알아야 한다. MovieLister 클래스의 생성자에 다음과 같은 코드를 삽입해서 finder의 구현 클래스를 명시할 수 있을 것이다.

    class MovieLister...
      private MovieFinder finder;
      public MovieLister() {
        finder = new ColonDelimitedMovieFinder("movies1.txt");
      }

ColonDelimiterMovieFinder 클래스는 콤마로 구분된 영화 정보를 담고 있는 파일로부터 영화 목록을 읽어와주는 데, 이 클래스가 어떻게 구현되었는지는 본 글에서 중요하지 않으므로 위 코드에 대한 설명은 생략하도록 하겠다.

자, 이제 코드는 개인적으로 사용하기에는 충분히 훌륭하다. 그런데, 만약 친구가 이 클래스를 보고 마음에 들어해서 이 클래스를 사용하고 싶어한다면 어떤 일이 벌어질까? 만약 친구가 갖고 있는 영화 목록 파일이 콤마를 사용해서 영화를 구분하고 파일 이름이 'movies1.txt'라면 아무런 문제가 없을 것이다. 만약 파일 명이 다르다면 간단하게 프로퍼티 파일로부터 파일의 이름을 가져오도록 MovieLister 클래스를 수정하면 될 것이다. 하지만, 파일이 아닌 데이터베이스, XML 파일, 웹 서비스 또는 완전히 다른 형식의 텍스트 파일로부터 영화 목록을 가져와야 한다면 어떻게 될까? 이 경우, 데이터를 읽어오기 위해서는 ColonDelimitedMovieFinder가 아닌 다른 클래스가 필요할 것이다. 앞서 MovieFinder 인터페이스를 정의했기 때문에 moviesDirectedBy 메소드는 변경할 필요가 없다. 하지만, 원하는 finder 구현 객체를 얻기 위해서는 다른 방법을 필요로 한다.


[그림1]은 이런 상황에 해당하는 의존성을 보여주고 있다. MovieLister 클래스는 MovieFinder 인터페이스와 그 구현 클래스에 모두 의존하고 있다. 원하는 것은 인터페이스에만 의존하고 실제 구현 클래스에는 의존하지 않도록 하는 것인데, 그렇다면 어떻게 해야 구현 클래스에는 의존하지 않을 수 있을까?

필자가 지은 '엔터프라이즈 애플리케이션 아키텍처 패턴'을 보면 'Plugin' 패턴으로 이 상황을 설명하고 있다. 앞서 MovieLister 클래스를 사용하길 원했던 친구가 어떤 구현 클래스를 사용할지 모르기 때문에, finder를 위한 구현 클래스는 컴파일 타임에 프로그램에 연결되지 않는다. 대신 MovieLister 클래스 작성자와 관계없이, 런타임에 사용할 구현 클래스를 플러그인 할 수 있다. 문제는 MovieLister 클래스가 컴파일 타임에 MovieFinder 구현 클래스를 알 필요가 없으면서, 런타임에 MovieLister 클래스와 MovieFinder 구현 클래스를 어떻게 연결하느냐는 것이다.

이를 실제 시스템으로 확장해보면, 우리는 다양한 이런 종류의 서비스와 컴포넌트를 갖고 있다. 각각의 경우에 인터페이스를 사용함으로써 (그리고 인터페이스가 없는 경우에는 어댑터를 사용함으로써) 각 컴포넌트의 사용을 추상화시킬 수 있다. 하지만, 이 시스템을 다른 방식으로 배포하길 원한다면, 이들 서비스와 상호작용을 할 수 있도록 하기 위해 플러그인(plugin)을 사용해야만 하며, 그렇게 함으로써 다른 배포 환경에서 다른 구현체를 사용할 수 있게 된다.

이제, 이 플러그인을 어떻게 어플리케이션에 조립해서 넣느냐 하는 것이 핵심 문제가 된다. 이것은 최근에 유행하는 경량 콘테이너들이 당면한 주요 문제중의 하나이며, 보편적으로 IoC(Inversion of Control)를 사용함으로써 그 문제를 해결할 수 있다.

IoC(Inversion Of Control)

경량 콘테이너들이 "Inversion of Control"을 구현했기 때문에 매우 유용하다고 했을 때, 매우 당황스러웠다. 왜냐면, IoC는 프레임워크들의 공통된 특징이라서, 경량 콘테이너가 IoC를 갖고 있기 때문에 특별하다고 말하는 것은 자동차가 바퀴를 갖고 있기 때문에 특별하다고 말하는 것이나 다름없기 때문이었다.

질문 하나, 제어(Control)의 관점이 전도(Inversion)된다는 것은 무엇일까? 필자가 처음 IoC를 접할때, 그것은 유저 인터페이스 제어와 관련된 것이었다. 예전의 유저 인터페이스는 어플리케이션 프로그램에 의해 제어됐었다. 프로그램은 "이름을 입려하세요", "주소를 입력하세요"와 같은 명령어를 순차적으로 출력해서 사용자가 데이터를 입력하도록 유도한 뒤 사용자의 응답을 기다렸다. GUI를 사용함으로써, UI 프레임워크가 이런 루프를 포함하게 됐으며, 프로그램은 화면에 있는 다양한 필드를 위한 이벤트 핸들러를 제공하는 형태로 바뀌었다. 즉, 프로그램의 주요 제어권이 사용자에서 프레임워크로 전도된 것이다.

콘테이너에 부는 새로운 흐름에서 IoC가 적용되는 부분은 콘테이너가 어떻게 플러그인 구현체를 검색하느냐에 대한 것이다. 앞서 예제에서 MovieLister는 finder 구현체를 찾기 위해 직접 구현 클래스의 인스턴스를 생성했었다. 이것은 finder를 플러그인되도록 할 수 없게 만든다. 이들 콘테이너들은 별도의 조립 모듈에서 MovieLister에 finder 구현체를 연결할 수 있도록 함으로써 어떤 사용자든지 지정한 방식으로 플러그인 할 수 있도록 해야 한다.

필자는 이 패턴에 좀더 알맞은 이름을 필요로 했다. Inversion of Control은 너무 범용적인 용어이기 때문에 사람들이 혼동할 가능성이 있다. 그래서 여러 사람들과 다양한 논의를 한 끝에 '디펜던시 인젝션(Dependency Injection)' 이라는 이름을 만들어내었다.

필자는 먼저 디펜던시 인젝션의 다양한 형태에 대해 살펴볼 것이다. 하지만, 디펜던시 인젝션이 어플리케이션 클래스와 플러그인 구현체 사이의 의존성을 없애는 유일한 방법은 아니라는 점을 유념하기 바란다. (예를 들어, 서비스 로케이터 패턴을 사용할 수도 있는데, 이에 대해서는 디펜던시 인젝션 패턴을 설명한 다음에 논의할 것이다.)

디펜던시 인젝션 구현 방식

디펜던시 인젝션의 기본 아이디어는 객체들을 연결해주는 별도의 객체를 갖는 것이다. 이 조립기 객체는 MovieLister 클래스의 필드에 알맞은 MovieFinder 구현체를 할당해주는데, [그림2]는 조립기 객체를 사용할 때의 관계를 클래스 다이어그램으로 보여주고 있다.


디펜던시 인젝션에는 세 가지 종류가 있다. 이들 세가지는 생성자 방식, 세터(setter) 방식, 인터페이스 방식이다.

PicoContainer에서 사용되는 생성자 방식

경량 콘테이너인 PicoContainer가 어떻게 생성자 방식을 적용하는 지 살펴보도록 하자. (PicoContainer를 먼저 살펴보는 이유는 ThoughtWorks에 있는 필자의 동료들 다수가 PicoContainer 개발에 활발하게 참여하고 있기 때문이라고 한다.)

PicoContainer는 MovieLister 클래스에 MovieFinder 구현체를 전달하기 위해 생성자를 사용한다. 이를 위해 MovieLister 클래스의 생성자는 전달받을 구현체를 위한 파라미터를 제공해야 한다.

    class MovieLister...
        public MovieLister(MovieFinder finder) {
            this.finder = finder;       
        }

finder 자체도 PicoContainer에 의해 관리되며, PicoContainer가 ColonMovieFinder에 텍스트 파일의 이름을 전달하게 된다.

    class ColonMovieFinder...
        public ColonMovieFinder(String filename) {
            this.filename = filename;
        }

PicoContainer는 각각의 인터페이스가 어떤 구현 클래스와 연관되는지 그리고 ColonMovieFinder 생성자에 전달될 String 값이 무엇인지 알 필요가 있다.

        private MutablePicoContainer configureContainer() {
            MutablePicoContainer pico = new DefaultPicoContainer();
            Parameter[] finderParams =  {new ConstantParameter("movies1.txt")};
            pico.registerComponentImplementation(MovieFinder.class, ColonMovieFinder.class, finderParams);
            pico.registerComponentImplementation(MovieLister.class);
            return pico;
        }

이 설정 관련 코드는 보통 다른 클래스에서 구현된다. 본 글의 예제의 경우, MovieLister를 사용하는 사람들은 그들 자신의 클래스를 사용하기 위해 알맞은 설정 코드를 작성할 것이다. 물론, 이런 종류의 설정 정보를 별도의 설정 파일에 저장하는 것이 일반적이다. 설정 파일에서 정보를 읽어와 콘테이너를 알맞게 설정하는 클래스를 작성할 수도 있을 것이다. PicoContainer 자체가 이런 기능을 제공하고 있지는 않지만, XML 설정 파일을 사용해서 PicoContainer를 설정할 수 있도록 해주는 NanoContainer라는 관련 프로젝트가 존재한다. 이 NanoContainer의 핵심은 설정 파일의 포맷과 PicoContainer의 설정 메커니즘을 구분하는 것이다.

PicoContainer를 사용하기 위해서는 다음과 같이 코드를 작성하면 된다.

        public void testWithPico() {
            MutablePicoContainer pico = configureContainer();
            MovieLister lister = (MovieLister) pico.getComponentInstance(MovieLister.class);
            Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
            assertEquals("Once Upon a Time in the West", movies[0].getTitle());
        }

비록 이 예제에서 생성자 방식을 설명했지만, (그리고 PicoContainer의 개발자들이 생성자 방식을 더 선호하긴 하지만) PicoContainer는 세터 방식도 지원하고 있다.

Spring에서 사용되는 세터 방식

Spring 프레임워크는 자바 엔터프라이즈 분야에서 널리 사용되는 프레임워크이다. Spring은 트랜잭션, 퍼시스턴스 프레임워크, 웹 어플리케이션 개발 그리고 JDBC를 위한 추상 계층을 포함하고 있다. Spring은 PicoContainer와 마찬가지로 생성자 방식과 세터 방식을 모두 제공하고 있는데, Spring의 개발자들은 세터 방식을 더 선호하는 경향이 있다.

MovieLister 클래스를 세터 방식으로 작성해보자.

    class MovieLister...
      private MovieFinder finder;
      public void setFinder(MovieFinder finder) {
        this.finder = finder;
      }

비슷한 방식으로 ColonMovieFinder가 참조할 파일명을 입력받는 세터 메소드를 정의할 수 있다.

    class ColonMovieFinder...
        public void setFilename(String filename) {
            this.filename = filename;
        }

세번째 단계는 설정 파일을 작성하는 것이다. Spring은 XML 파일을 통해서 설정할 수 있는 기능을 제공하고 있으며 또한 코드에서 직접 설정할 수도 있다. 다음은 설정 정보를 담고 있는 XML 파일의 예이다.

    <beans>
        <bean id="MovieLister" class="spring.MovieLister">
            <property name="finder">
                <ref local="MovieFinder"/>
            </property>
        </bean>
        <bean id="MovieFinder" class="spring.ColonMovieFinder">
            <property name="filename">
                <value>movies1.txt</value>
            </property>
        </bean>
    </beans>

테스트 코드는 다음과 같다.

        public void testWithSpring() throws Exception {
            ApplicationContext ctx = new FileSystemXmlApplicationContext("spring.xml");
            MovieLister lister = (MovieLister) ctx.getBean("MovieLister");
            Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
            assertEquals("Once Upon a Time in the West", movies[0].getTitle());
        }

인터페이스 방식

세번째 방식은 인터페이스를 사용하는 방식이다. 인터페이스 방식을 사용하는 프레임워크에는 Avalon을 예로 들 수 있다. 인터페이스 방식을 사용하려면 먼저 의존성 전도를 수행할 때 사용할 인터페이스를 정의해야 한다. 다음은 MovieFinder를 객체에 전달하기 위한 인터페이스이다.

    public interface InjectFinder {
        void injectFinder(MovieFinder finder);
    }

이 인터페이스를 통해서 MovieFinder 구현체를 누구에게든 제공할 수 있다. 예를 들어, MovieLister와 같이 finder를 사용하고자 하는 클래스들은 이 인터페이스를 구현해주면 된다.

    class MovieLister implements InjectFinder...
        public void injectFinder(MovieFinder finder) {
            this.finder = finder;
        }

MovieFinder 구현 클래스에 파일 이름을 전달할 때에도 같은 방식을 사용하였다.

    public interface InjectFinderFilename {
        void injectFilename (String filename);
    }
    
    class ColonMovieFinder implements MovieFinder, InjectFinderFilename......
        public void injectFilename(String filename) {
            this.filename = filename;
        }

보통 구현체를 연결하기 위해 설정 코드를 필요로 한다. 본 글에서는 단순하게 코드에서 설정 정보를 작성하였다.

    class Tester...
        private Container container;
    
         private void configureContainer() {
           container = new Container();
           registerComponents();
           registerInjectors();
           container.start();
        }

위 코드는 2단계를 거치는데, 먼저 1단계(registerComponents 메소드)에서는 키값을 사용하여 컴포넌트를 등록한다. (다른 예제에서도 이와 비슷한 방식을 사용하고 있다.)

    class Tester...
      private void registerComponents() {
        container.registerComponent("MovieLister", MovieLister.class);
        container.registerComponent("MovieFinder", ColonMovieFinder.class);
      }

2단계에서는 의존하는 컴포넌트를 전달하기 위한 인젝터(Injector)를 등록하는 과정이다. 각각의 인젝션 인터페이스는 의존하는 객체를 전달해주는 코드를 필요로 한다. 여기서는 콘테이너에 인젝터 객체를 등록하는 방식을 사용했다. 각각의 인젝터 객체는 Injector 인터페이스를 구현하고 있다.

    class Tester...
      private void registerInjectors() {
        container.registerInjector(InjectFinder.class, container.lookup("MovieFinder"));
        container.registerInjector(InjectFinderFilename.class, new FinderFilenameInjector());
      }
    
    public interface Injector {
      public void inject(Object target);
    
    }

위 코드는 InjectFinder 를 구현한 객체를 전달받을 Injector가 "MovieFinder" 컴포넌트(container.lookup("MovieFinder")가 리턴한 객체인 ColonMovieFinder 구현체)라고 명시한다.

만약 의존하는 클래스가 이 콘테이너를 위해 작성된 클래스라면, 해당 컴포넌트가 Injector 인터페이스를 구현하도록 하면 된다. 본 글에서는 MovieFinder가 이런 경우에 해당한다. String과 같은 범용적인 클래스의 경우는 설정 코드 안에서 이너 클래스로 구현하면 된다.

    class ColonMovieFinder implements Injector......
      public void inject(Object target) {
        ((InjectFinder) target).injectFinder(this);        
      }
    
    class Tester...
      public static class FinderFilenameInjector implements Injector {
        public void inject(Object target) {
          ((InjectFinderFilename)target).injectFilename("movies1.txt");      
        }
      }

콘테이너를 사용하는 테스트 코드는 다음과 같다.

    class IfaceTester...
        public void testIface() {
          configureContainer();
          MovieLister lister = (MovieLister)container.lookup("MovieLister");
          Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
          assertEquals("Once Upon a Time in the West", movies[0].getTitle());
        }

콘테이너는 의존 관계를 나타내기 위해 Injection 인터페이스를 사용하고 올바른 의존 객체를 전달하기 위해 Injector를 사용한다.

서비스 로케이터 사용하기

Injector의 주요 장점은 MovieLister 클래스와 MovieFinder 구현체 사이의 의존성을 없앤다는 것이다. Injector를 사용함으로써 MovieLister 클래스를 다른 개발자들에게 제공할 수 있었고, 그 개발자들은 자신들에 알맞은 MovieFinder 구현체를 플러그인 할 수 있게 되었다. 디펜던시 인젝션이 객체 사이의 의존 관계를 없애는 유일한 방법은 아닌데, 또 다른 방법으로는 서비스 로케이터를 사용하는 방식이 있다.

서비스 로케이터의 기본 아이디어는 어플리케이션이 필요로 하는 모든 서비스를 포함하고 있는 객체를 갖는 것이다. 본 글의 예제를 위한 서비스 로케이터는 필요한 MovieFinder를 리턴해주는 메소드를 갖게 될 것이다. 물론, MovieLister는 서비스 로케이터를 참조해야 하는데, [그림3]은 서비스 로케이터를 사용할 때의 의존관계를 보여주고 있다.


본 글에서는 싱글톤 레지스트리(registry)를 사용해서 ServiceLocator를 구현할 것이다. MovieLister는 ServiceLocator를 사용해서 MovieFinder를 얻을 수 있다.

    class MovieLister...
        MovieFinder finder = ServiceLocator.movieFinder();
    
    class ServiceLocator...
        public static MovieFinder movieFinder() {
            return soleInstance.movieFinder;
        }
        private static ServiceLocator soleInstance;
        private MovieFinder movieFinder;

디펜던시 인젝션의 경우와 비슷하게, ServiceLocator를 설정해주어야 한다. 본 글에서는 코드에서 직접 설정을 해주는 방식을 예로 들겠다.

    class Tester...
        private void configure() {
            ServiceLocator.load(new ServiceLocator(new ColonMovieFinder("movies1.txt")));
        }
    
    class ServiceLocator...
        public static void load(ServiceLocator arg) {
            soleInstance = arg;
        }
    
        public ServiceLocator(MovieFinder movieFinder) {
            this.movieFinder = movieFinder;
        }

테스트 코드는 다음과 같다.

    class Tester...
        public void testSimple() {
            configure();
            MovieLister lister = new MovieLister();
            Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
            assertEquals("Once Upon a Time in the West", movies[0].getTitle());
        }

서비스 로케이터를 위한 격리된(segregated) 인터페이스 사용하기

간단한 서비스 로케이터를 사용할 때의 이슈 중 하나는 MovieLister가 전체 서비스 로케이터 클래스에 의존한다는 점이다. 격리된 인터페이스(segregated interface)를 사용하면 이런 의존성을 줄일 수 있다. 즉, 전체 서비스 로케이터 인터페이스를 사용하는 대신에 MovieLister가 필요로 하는 인터페이스의 일부만 MovieLister에 선언하는 것이다.

이 경우, MovieLister의 제공자는 MovieFinder를 저장하기 위해 필요한 서비스 로케이터 인터페이스를 제공할 수도 있을 것이다.

    public interface MovieFinderLocator {
        public MovieFinder movieFinder();

서비스 로케이터는 MovieFinder에 접근할 수 있도록 하기 위해서 MovieFinderLocator 인터페이스를 구현할 필요가 있다.

        MovieFinderLocator locator = ServiceLocator.locator();
        MovieFinder finder = locator.movieFinder();
    
        public static ServiceLocator locator() {
            return soleInstance;
        }
        public MovieFinder movieFinder() {
            return movieFinder;
        }
        private static ServiceLocator soleInstance;
        private MovieFinder movieFinder;

인터페이스를 사용하게 되면 더이상 static 메소드를 사용해서 서비스에 접근할 수 없게 된다. 따라서, 서비스 로케이터의 인스턴스를 구해주는 클래스를 만들고, 원하는 서비스에 접근할 때에 그 클래스를 사용해야 한다.

동적 서비스 로케이터

앞서 서비스 로케이터 예제는 정적이었다. 서비스 로케이터는 각각의 서비스마다 접근하기 위한 메소드를 갖고 있었다. (예를 들어, MovieFinder 서비스를 구할 때는 movieFinder() 메소드를 사용하였다.) 이런 정적인 방식 뿐만 아니라 어떤 서비스든지 필요할 때에 접근할 수 있도록 해주는 동적인 서비스 로케이터를 만들 수도 있다.

이 경우 서비스 로케이터는 각각의 서비스를 저장하기 위해 맵(map)을 사용하고 서비스를 로딩하고 구하기 위한 범용적인 메소드를 제공하게 된다.

    class ServiceLocator...
        private static ServiceLocator soleInstance;
        public static void load(ServiceLocator arg) {
            soleInstance = arg;
        }
        private Map services = new HashMap();
        public static Object getService(String key){
            return soleInstance.services.get(key);
        }
        public void loadService (String key, Object service) {
            services.put(key, service);
        }

서비스를 로딩할 때에는 알맞은 키값을 서비스와 연관시키게 된다.

    class Tester...
        private void configure() {
            ServiceLocator locator = new ServiceLocator();
            locator.loadService("MovieFinder", new ColonMovieFinder("movies1.txt"));
            ServiceLocator.load(locator);
        }

서비스르 로딩할 때 사용된 키값을 사용하여 서비스를 구하게 된다.

    class MovieLister...
        MovieFinder finder = (MovieFinder) ServiceLocator.getService("MovieFinder");

필자는 이 방식을 선호하지는 않는다. 비록 이 방식이 유연하기는 하나, 명시적이지 않기 때문이다. 이 방식에서 서비스에 접근할 수 있는 유일한 방법은 텍스트로 된 키값을 통한 것 뿐이다. 필자는 서비스를 찾기 위한 명시적인 메소드가 존재하는 것을 선호하는데, 그 이유는 인터페이스에 메소드가 정의되어 있어서 쉽게 서비스를 찾을 수 있기 때문이다.

알맞은 방식을 선택하는 방법

지금까지 디펜던시 인젝션 패턴과 서비스 로케이터에 대해 설명했다. 이제, 각 방식의 장점과 단점을 살펴보고 언제 어떤 방식을 사용할지에 대해서 논의할 차례이다.

서비스 로케이터 vs 디펜던시 인젝션

첫번째로 선택해야 할 것은 서비스 로케이터와 디펜던시 인젝션 중 어떤 패턴을 사용하느냐는 것이다. 이 두 패턴은 맨 처음 살펴봤던 예제에서 문제가 됐던 결합도(coupling) 문제를 없애준다. 이 두 패턴의 중요한 차이점은 어플리케이션 클래스에 제공되는 방식이 다르다는 점이다. 서비스 로케이터를 사용할 경우, 애플리케이션 클래스가 서비스를 사용하기 위해서는 서비스 로케이터에 직접적으로 요청하게 된다. 반면에 디펜던시 인젝션을 사용하게 되면 서비스를 사용하기 위한 어떠한 요청도 발생하지 않으며, 서비스는 어플리케이션 내에 위치하게 된다. (어플리케이션은 세터 방식 또는 생성자 방식을 통해서 사용할 서비스를 전달받게 된다.)

IoC는 프레임워크의 일반적인 특징인데, 그것은 비용과 관련된 문제가 있다. 먼저, IoC는 이해하기 어렵고 디버깅을 할때 문제가 되는 경향이 있다. 그래서, 필자는 IoC가 필요하지 않는 이상 가급적이면 IoC를 사용하지 않는 편이다. 그렇다고 해서 IoC가 나쁘다는 것은 아니다.

두 방식간 차이점의 핵심은 서비스 로케이터를 사용할 경우 서비스의 모든 사용자가 서비스 로케이터에 의존한다는 것이다. 서비스 로케이터가 다른 구현체와의 의존성을 숨길수는 있지만, 어쨋든 어플리케이션은 서비스 로케이터에 접근할 수 있어야 한다. 따라서, 의존성이 문제가 되느냐의 여부에 따라서 서비스 로케이터와 디펜던시 인젝션을 선택할 수 있다.

디펜던시 인젝션을 사용하면 보다 쉽게 컴포넌트 사이의 의존관계를 확인할 수 있다. 디펜던시 인젝터를 사용할 경우, 생성자나 세터 메소드를 이용한 인젝션 기법을 조사해서 의존관계를 이해할 수 있다. 서비스 로케이터를 사용할 경우, 로케이터를 호출하는 소스 코드를 검색해야 의존관계를 판별할 수 있다. 최근에 나온 IDE는 참조한 코드를 검색해주는 기능을 제공하므로 쉽게 소스 코드를 검색할 수 있긴 하지만, 생성자나 세터 메소드를 검사하는 것 만큼 쉽지는 않을 것이다.

두가지 중 어떤 것을 선택하느냐의 문제는 서비스를 어떻게 사용하느냐에 달려있다. 만약 서비스를 사용하는 다양한 클래스로 구성된 어플리케이션을 구축한다면 어플리케이션 클래스에서 서비스 로케이터로 의존성을 넘기는 것은 문제가 되지 않는다. MovieLister를 다른 개발자에게 제공했던 경우에, 서비스 로케이터를 사용해도 문제가 잘 해결되었다. 이 경우, 개발자들은 MovieFinder의 올바른 서비스 구현체를 사용하기 위해 (설정 파일을 사용하든 설정 코드를 사용하든) 서비스 로케이터를 알맞게 설정해주기만 하면 됐다.

두 방식 사이의 차이점은 앞서 개발한 클래스가 다른 사람이 작성한 어플리케이션에 제공되는 컴포넌트인지에 따라 결정된다. 예를 들어, MovieLister의 경우를 생각해보자. 이 경우 MovieLister 클래스의 개발자는 다른 개발자가 사용할 서비스 로케이터 API에 대해서 잘 알지 못한다. 사용자들은 각각 그들 자신에 알맞은 호환되지 않는 서비스 로케이터를 사용할 수도 있다. 이 경우 격리된 인터페이스를 사용함으로써 이 문제를 해결할 수 있다. 격리된 인터페이스를 사용함으로써 각 사용자는 제공된 인터페이스와 그들이 구현한 인터페이스 사이의 어댑터를 작성할 수 있게 되지만, 어떤 경우든지 간에 특정 인터페이스를 찾기 위해서는 여전히 먼저 서비스 로케이터를 참조할 필요가 있다. 그리고, 일단 어댑터가 만들어지면, 서비스 로케이터를 직접적으로 연결하는 것은 단순해진다.

인젝터를 사용하면 컴포넌트로부터 인젝터로의 의존 관계가 없기 때문에, 일단 설정되고 나면 컴포넌트는 인젝터로부터 서비스를 구할 수 없게 된다.

사용자들이 디펜던시 인젝션을 선호하는 공통된 이유는 테스트하기가 더 쉽게 때문이다. 여기서 핵심은 테스트를 수행한다는 것이다. 여러분은 실제 서비스 구현체 대신에 스텁이나 목(mock)을 사용한 테스트를 쉽게 수행할 수 있다. 하지만, 테스트와 관련해서 실제로는 디펜던시 인젝션과 서비스 로케이터 사이에 아무런 차이점이 없다. 둘다 스텁을 쉽게 사용할 수 있다. 필자는 서비스 로케이터를 사용했던 프로젝트에서 많은 노력을 기울이지 않고도 테스트를 목적으로 서비스 로케이터를 교체한 것을 관찰할 수 있었는데, 이를 통해 두 방식 사이에 테스트의 용이함에 대해 별다른 차이가 없다는 걸 깨닫게 되었다.

물론, 테스트 문제는 EJB 프레임워크와 같이 매우 직접적으로 코드에 개입하는 컴포넌트 환경에서는 더욱 심해진다. 필자의 시각에서 이런 종류의 프레임워크는 어플리케이션 코드에 미치는 영향을 최소화시켜야 하며, 특히 수정-실행 주기를 빠르게 할 수 있는 방법을 만들어야 한다. 중량 컴포넌트를 교체할만한 플러그인을 사용하는 것이 이 과정에 많은 도움이 될 것이다.

생성자 방식 vs 세터 방식

각 객체를 서로 연결하기 위해 몇가지 방법을 사용하게 된다. 인젝션의 장점은 적어도 생성자 방식과 세터 방식을 위한 매우 간단한 기법이 존재한다는 것이다. 이를 위해 컴포넌트에 별도의 작업을 하지 않아도 되고, 인젝션과 관련된 설정 역시 매우 직관적이다.

인터페이스 인젝션의 경우 다수의 인터페이스를 작성해야 하기 때문에 컴포넌트 코드에 직접적인 영향을 미친다. Avalon 방식의 경우 콘테이너가 요구하는 인터페이스의 개수가 적기 때문에, 그렇게 나쁜 방식은 아니다. 하지만, 컴포넌트와 의존관계를 조립하기 위해 필요한 작업이 많기 때문에, 최근의 경량 콘테이너들은 인터페이스 방식 보다는 생성자와 세터 방식을 사용하고 있다.

세터 방식과 생성자 방식 중에서 어떤 것을 선택하느냐의 문제는 생성자 또는 세터 메소드에서 필드의 값을 채우느냐의 문제와 관련되기 때문에 객체 지향 관점에서 생각해봐야 할 문제이다.

필자의 경우는 가능한 객체 생성 시점에 유효한 객체를 생성하고 있다. 켄트 백이 지은 'Smalltalk Best Practice Patterns: Constructor Method and Constructor Parameter Method' 에서 이에 대한 의견을 참고할 수 있다. 생성자에 파라미터를 지정함으로써 유효한 객체를 만들기 위해서 무엇이 필요한지 명확하게 알 수 있게 된다. 만약 객체를 생성하는 방법이 여러개 존재한다면 그에 해당하는 여러개의 생성자를 작성하면 된다.

생성자 기법의 또 다른 장점은 세터 메소드를 제공하지 않음으로써 간단하게 필드를 불변 값으로 지정할 수 있다는 점이다. 이것은 매우 중요한 점이다. 만약 세터 메소드를 사용해서 초기화를 한다면, 이후에 세터 메소드가 임의로 호출되는 것 때문에 발생하는 문제를 겪을 수도 있다. (필자의 경우는 초기화를 위한 값을 할당할 때에는 보통의 세터 메소드 대신 initFoo 와 같은 초기화를 의미하는 이름의 메소드를 사용하는 것을 선호한다.)

하지만, 생성자 기법을 사용하는 것이 항상 좋은 것은 아니다. 만약 생성자의 파라미터가 많을 경우 복잡해 보일 수가 있다. 생성자가 복잡하고 길다는 것은 종종 그 클래스가 과도하게 사용된다는 것을 의미하며, 이런 경우 클래스를 여러개로 분리하는 것을 고려해봐야 한다.

유효한 객체를 생성하는 방법이 여러개 존재한다면, 생성자를 통한 방법으로 유요한 객체를 만드는 것을 보여주는 것이 어려울 수도 있다. 왜냐면, 생성자는 오직 파라미터의 개수와 타입으로만 구분할 수 있기 때문이다.

생성자가 String과 같은 단순한 파라미터를 갖는 경우에도 문제가 될 수 있다. 세터 인젝션을 사용하는 경우 세터 메소드의 이름을 통해서 String 파라미터가 무엇을 의미하는 지 알려줄 수 있다. 반면에 생성자를 사용하는 경우 파라미터의 위치에 따라 의미가 파악된다. (예를 들어, 파라미터 개수가 많은 생성자를 사용할 때, 각 파라미터에 어떤 값을 넘겨야 하는 지 알기 위해서 API 문서를 매번 참고하기도 한다.)

다수의 생성자와 상속을 갖고 있다면, 문제는 더 복잡해진다. 모든 것을 초기화하기 위해서는 생성자에서 상위 클래스의 생성자로 알맞은 값을 전달해주어야 하며, 자식 클래스에서 필요로 하는 값 또한 생성자에 파라미터로 추가될 것이다. 이는 생성자를 더욱 복잡하게 만들어버린다.

비록 생성자 방식의 단점을 설명하긴 했지만, 그래도 생성자 방식으로 시작하는 것이 좋다. 이후에 앞서 말했던 문제가 발생하면 그때 세터 방식으로 전환하는 것이 좋다.

이 이슈는 프레임워크에서 디펜던시 인젝션을 제공한 다양한 팀들의 논의 끝에 나온 내용이다. 하지만, 이러한 프레임워크를 개발하는 대부분의 사람들은 두 가지 방식을 모두 제공하는 것이 중요하다는 것을 깨달은 것으로 보인다. (비록 그들이 둘 중 한가지를 선호하는 경우가 있다 하더라도 말이다.)

설정 코드 vs 설정 파일

별도로 논의되기도 하고 종종 함께 논의되기도 하는 이슈가 서비스의 연결을 설정 파일로 할 것인지 또는 코드에서 할 것인지에 대한 것이다. 다양한 곳에 배포되어 사용되는 어플리케이션의 경우는 설정 파일을 사용하는 것이 좋다. 최근엔 XML을 사용해서 설정 파일을 작성하는 경우가 많다. 하지만, 코드를 사용해서 객체를 연결하는 것이 더 쉬울 때도 있다. 다양한 환경에 배포되지 않는 간단한 어플리케이션이 이에 적합한 예다. 이 경우, 별도의 XML 파일보다 약간의 설정 코드가 더 명확할 수 있다.

대조적인 예로 객체 사이의 관계가 매우 복잡한 경우가 있다. 일단, 여러분이 프로그래밍 언어에 가까워지기 시작하면 XML을 사용하는 것이 점점 약해지기 마련이다. 명확한 프로그램을 하기 위해서는 모든 구문을 갖춘 실제 언어를 사용하는 것이 더 좋기 때문이다. 만약 몇가지 구분되는 빌더 시나리오가 존재한다면, 객체를 조립하는 다양한 빌더 클래스를 만들고, 간단한 설정 파일을 사용해서 빌더 클래스를 선택하도록 하면 된다.

필자는 가끔 사람들이 지나칠 정도로 설정 파일을 사용하고 있다는 생각이 든다. 프로그래밍 언어는 설정 메커니즘을 직관적이고 강력하게 만들어준다. 최신의 언어는 큰 시스템을 위한 플러그인들을 조립하는데 사용되는 작은 어셈블러를 쉽게 컴파일 할 수 있도록 해 준다. 만약 컴파일이 싫다면, 스크립트 언어를 사용해도 잘 동작할 것이다.

종종 설정 파일은 프로그래머가 아닌 사람들이 수정할 필요가 있기 때문에 설정 파일에서 프로그래밍 언어를 사용하면 안 된다고 말하는 사람들이 있다. 하지만, 그런 경우가 실제로 얼마나 많은 지 묻고 싶다. 실제로 프로그래머가 아닌 일반 사용자가 트랜잭션 설정을 변경하는 걸 원하는 경우가 있을까? 비 프로그래밍 언어로 구성된 설정 파일은 그것이 단순한 경우에만 잘 동작한다. 만약 설정 파일의 내용이 복잡해진다면, 알맞은 프로그래밍 언어를 사용해보는 것을 고려해봐야 한다.

필자는 프로그램 방식의 인터페이스를 사용해서 손쉽게 설정할 수 있는 방법을 항상 제공하고 그런 뒤 옵션을 별도의 설정 파일로 처리할 것을 권한다. 프로그램 방식의 인터페이스를 사용하기 위해 설정 파일을 조작하는 방식은 쉽게 구현할 수 있다. 만약 컴포넌트를 프로그래밍 인터페이스를 통해서 설정할 수 있다면, 컴포넌트 사용자는 프로그래밍 인터페이스를 사용하거나, 컴포넌트 개발자가 제공한 설정 파일을 사용하거나, 또는 사용자가 직접 개발한 설정 파일 양식을 사용해서 프로그래밍 인터페이스로 쉽게 연결할 수 있을 것이다.

서비스 사용과 서비스 설정 분리하기

서비스의 설정을 서비스의 사용으로부터 구분하는 것은 중요한 문제이다. 사실, 이것은 구현으로부터 인터페이스를 구분하는 것에 적합한 기초적인 디자인 이론이다. 그것은 조건 로직이 어떤 클래스의 인스턴스를 생성할지를 결정한 뒤 그 조건을 평가할 때 중복된 조건 코드가 아닌 다형성을 통해서 수행하는 객체지향 프로그램에서 볼 수 있는 것들이다.

만약 서비스와 사용의 구분이 한 코드 기반하에서 유용하다면, 그것은 컴포넌트나 서비스와 같은 외부 요소를 사용할 때 특히 중요하다. 첫번째 질문은 여러분이 특정 환경과 관련된 구현 클래스의 선택을 뒤로 미루고 싶은 지의 여부이다. 만약 그렇다면, 플러그인을 구현해야 한다. 일단, 플러그인을 사용하면 플러그인을 조립하는 것은 어플리케이션의 나머지 부분과 독립적으로 실행되며, 따라서 다른 환경을 위해서 설정 파일을 손쉽게 교체할 수 있다. 어떻게 이것을 구현할지의 여부는 두번째 문제다. 이 설정 방식은 서비스 로케이터를 설정하거나 직접적으로 객체를 설정하기 위해 인젝션을 사용할 수 있다.

결론

요즘 인기를 끌고 있는 경량 콘테이너들은 서비스를 조립하기 위해 공통적으로 디펜던시 인젝션 패턴을 사용하고 있다. 디펜던시 인젝션은 서비스 로케이터를 대체하는 유용한 패턴이다. 어플리케이션 클래스를 개발할 때 두 패턴은 대충 동일하지만, 필자는 서비스 로케이터가 좀더 간단한 기능이기 때문에 약간 더 우세하다고 생각한다. 하지만, 다양한 어플리케이션 사용될 클래스를 개발해야 한다면, 디펜던시 이젝션이 더 나은 선택이다.

만약 디펜던시 인젝션을 사용한다면 몇가지 선택할 것들이 있다. 필자는 생성자 방식을 사용할 때 특정한 문제가 없다면 생성자 기법을 따를 것을 권한다. 만약 콘테이너가 필요하다면 생성자 방식과 세터 방식을 모두 지원하는 콘테이너를 찾아보는 것이 좋다.

서비스 로케이터와 디펜던시 인젝션 중 어떤 것을 선택하는 것 보다 더 중요한 건 서비스의 사용과 서비스의 설정을 어떻게 구분할 것인가에 대한 것이다.

관련링크:

+ Recent posts