주요글: 도커 시작하기
두 번째로 살펴볼 객체 지향 기초 이야기는 추상화와 다형성에 대한 것이다. 이는 모든 패턴의 기본이 되는 내용이므로 이에 대해 이해하는 것은 무엇보다 중요하다. (아래 내용이 완벽하진 않겠지만 기초가 필요한 분들이 입문하는데에 도움이 되었으면 한다.)

추상화(Abstraction)


추상화Abstraction란? 추상화의 사전적 의미는 특정한 개별 사물과 관련되지 않은 공통된 속성이나 관계 등을 뽑아내는 것이다. 이를 컴퓨터 관점에서 생각해보면, 추상화란 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의해 나가는 과정이면서 동시에 각 개별 개체의 구현에 대한 상세함은 감추는 것, 이것이 추상화라고 할 수 있다.

예를 들어, 프로그래밍 언어에서 for, while, foreach 등은 반복을 추상화한 것들이다. 실제로 이것들은 CPU의 이동 명령을 통해서 구현되겠지만, 이 구현으로부터 '반복'이라는 개념을 뽑아내서 for, while 등으로 추상화한 것이다. 비슷하게 OS는 그래픽 기능을 위한 API를 제공하는데, 이 API는 서로 다른 그래픽 카드들의 공통된 기능을 추상화한 결과물이다.

추상화를 하게 되면 상세한 구현이 아닌 공통된 개념과 관계에 집중할 수 있게 되는데, 이는 큰 수준에서 시스템을 이해할 수 있도록 도와준다. 예를 들어, 로그 분석 시스템에서 로그를 다음과 같은 세 가지 방법으로 읽어올 수 있다고 해 보자.
  • 원격 서버의 로그 파일을 FTP로 다운로드한 뒤 로그 조회
  • 원격 서버의 로그 파일을 SCP로 복사한 뒤 로그 조회
  • DB 서버의 로그 테이블로부터 로그 조회
이 세 가지는 개별으로 구현은 다르지만 개념적으로는 '로그 수집'을 처리하는 과정이다. 따라서, 위 세 가지를 '로그 수집'이라는 개념으로 추상화할 수 있으며, 세 개의 상세한 구현이 아닌 한 개의 개념으로 생각할 수 있도록 해 준다.

타입 추상화와 다형성(Polymorphism)

자바에서는 클래스를 이용해서 객체를 표현한다. 예를 들어, FTP 서버로부터 로그 파일을 다운로드해서 로그를 조회하는 객체는 다음과 같은 클래스로 표현할 수 있을 것이다.

public class FtpLogCollector {
    private String ftpServer;
    ... // 기타 다른 필드

    public FtpLogCollector(String ftpServer, String user, String password) {
        this.ftpServer = ftpServer;
        ...
    }

    public FileLogSet collect() {
        ... // FTP에서 파일 다운로드
        ... // 해당 파일의 로그 정보를 담고 있는 FileLogSet 생성 및 리턴
    }
}

FtpLogCollector를 이용해서 로그 정보를 조회하는 코드는 다음과 유사한 형태를 띌 것이다.

FtpLogCollector collector = new FtpLogCollector();
FileLogSet logSet = collector.collect();
FileLogIterator logIter = logSet.iterator();
while (logIter.hasNext()) {
    Log log = logIter.next();
    ...
}
logIter.close();

비슷하게 DB 서버로부터 로그 데이터를 조회하는 객체와 그 기능을 사용하는 객체는 다음과 같은 클래스로 표현될 수 있을 것이다.

public class DBLogCollector {
    private String jdbcUrl;
    ... // 기타 다른 필드

    public DbLogCollector(String jdbcUrl, String user, String password) {
        this.jdbcUrl = jdbcUrl;
        ...
    }

    public JdbcLogSet collect() {
        ... // DB에서 로그 읽어오는 JdbcLogSet 생성 및 리턴
    }
}

DBLogCollector collector = new DBLogCollector();
JdbcLogSet logSet = collector.collect();
JdbcLogIterator logIter = logSet.iterator();
while (logIter.hasNext()) {
    Log log = logIter.next();
    ...
}
logIter.close();

두 코드를 보고나면 너무나도 똑같다는 것을 알 수 있다. 차이점은 FtpLogCollector와 DBLogCollector, FileLogSet과 JdbcLogSet 그리고 FileLogIterator와 JdbcLogIterator의 구현이 다르다는 것일 뿐, 각 클래스가 제공하는 기능은 개념적으로 완전히 동일하다.
  • FtpLogCollector, DBLogCollector : 로그 집합을 특정 자원으로부터 수집하는 기능
  • FileLogSet, JdbcLogSet: 로그에 접근할 수 있는 Iterator를 제공하는 기능
  • FileLogIterator, JdbcLogIterator: 로그를 순차적으로 접근할 수 있는 기능
여기서 추상화의 정의를 다시 살펴보자. 추상화란 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의해 나가는 과정이면서 동시에 각 개별 개체의 구현에 대한 상세함은 감추는 것이라고 했다. 이를 위에 맞춰서 설명하면 FTP나 DB에서 로그를 수집하고 파일이나 테이블에 있는 로그에 접근하는 상세한 구현은 감추고 비슷한 개념인 로그 수집과 로그 접근을 도출해내는 것이 추상화인 것이다.

객체 지향 언어에서는 이렇게 추상화된 개념을 인터페이스로 표현한다. 언어마다 인터페이스를 제공하는 방식이 다른데, 자바의 경우 인터페이스를 interface라는 별도 타입으로 제공하고 있으며 또한 추상 클래스(abstract class)를 이용해서 객체의 인터페이스를 정의할 수도 있다. 앞서 로그 수집과 접근 기능을 추상화한 인터페이스는 다음과 같이 정의할 수 있을 것이다.

public interface LogCollector {
    LogSet collect();
}

public interface LogSet {
    LogIterator iterator();
}

public interface LogIterator {
    boolean hasNext();
    Log next();
    void close();
}

객체를 추상화했으면, 그 다음으로 할 작업은 실제 기능을 제공하도록 추상화된 타입을 구현하는 것이다. 자바 언어에서는 상속을 통해서 추상화된 타입을 구현할 수 있도록 하고 있다. interface를 정의한 경우에는 인터페이스 상속인 implements를 이용해서 추상 타입을 구현하게 되고, 추상 클래스를 정의한 경우에는 클래스 상속인 extends를 통해서 추상 타입을 구현하게 된다. 다음은 FTP로부터 로그를 수집해서 로컬 파일에 저장하고 로컬 파일로부터 로그 데이터를 읽어오는 구현을 제공하는 구현의 모습을 보여주고 있다.
 
public class FtpLogCollector implements LogCollector {
    private String ftpServer;
    ...
    public FtpLogCollector(...) { ... }
    public LogSet collect() { ... // FileLogSet을 생성해서 리턴 }
}

public class FileLogSet implements LogSet {
    private List<File> files;

    public LogIterator iterator() { ... FileLogIterator를 생성해서 리턴 }
}

public class FileLogIterator implements LogIterator {
    public boolean hasNext() { ... }
    public Log next() { ... }
    public void close() { ... }
}

위 코드에서 FtpLogCollector, FileLogSet, FileLogIterator는 각각 LogCollector, LogSet, LogIterator가 정의한 기능(메서드)의 실제 구현을 제공하고 있다. JDBC를 사용하는 경우에도 비슷한 구성을 갖게 된다. 즉, FTP로 읽어오기 위해 필요한 구현과 DB에서 읽어오기 위해 필요한 구현이 모두 동일한 인터페이스에 맞춰서 개발되는 것이다.

인터페이스 상속이나 클래스 상속을 통해서 추상화 타입을 구현한다고 해서 모든 게 끝나는 것은 아니다. 이런 추상화가 힘을 발휘할 수 있는 것은 다형성 때문이다. 다형성Polymorphism은 이름 그래도 한 객체가 여러 형태를 갖는다는 의미로 다형성으로 인해 추상화가 빛을 발한다.

다형성을 자바 언어와 연결해 설명해 보자면, 한 클래스의 인스턴스(객체)는 그 클래스가 상속 받은 모든 상위 타입도 될 수 있다는 것이다. 앞의 예제로 다시 한 번 설명해 보자. FtpLogCollector는 LogCollector를 상속받고 있으므로, FtpLogCollector 타입의 객체는 LogCollector로서도 동작할 수 있다. 따라서, 다음과 같은 코드 작성이 가능하다.

LogCollector collector = new FtpLogCollector(....);
LogSet logSet = collector.collect();
LogIterator logIter = logSet.iterator();

FtpLogCollector 객체는 LogCollector 타입도 되기 때문에, 위 코드에서와 같이 FtpLogCollector의 객체를 LogCollector 타입 변수에 할당할 수 있다. 또한, collector.collect()는 실제 객체의 타입인 FtpLogCollector 객체의 collect() 메서드를 실행하게 된다.

위 코드에서 FtpLogCollector를 생성하는 코드를 한 번 더 추상화하면 어떻게 될까?

LogCollector collector = LogCollectorFactory.create();
....

LogCollectorFactory 클래스는 시스템 설정에 따라서 FtpLogCollector나 JdbcLogCollector를 생성해주는 기능을 제공하는 팩토리라고 해 보자. 즉, LogCollectorFactory 클래스는 LogCollector 타입의 객체 생성을 추상화한 클래스이다. 이제 위 코드는 더 이상 특정 LogCollector의 구현체(FtpLogCollector와 같은)에 의존하지 않으며, 오직 추상 타입인 LogCollector만 사용하고 있다. 따라서, FtpLogCollector를 사용하다가 DBLogCollector를 사용하더라도 위 코드는 전혀 영향을 받지 않는다. 오직 LogCollectorFactory만 영향을 받을 뿐이다. 이렇게 실제 사용되는 구현 클래스가 변경되었음에도 불구하고 그 타입을 사용하는 코드가 영향을 받지 않는 것이 바로 다형성으로 인해 가능하다.

추상화란 결국 개발자의 뇌를 위한 것

로그 분석 기능을 생각해보면 큰 덩어리로 '로그 파일을 읽어와 파일을 한줄 한줄 파싱한 뒤 계산해서 내용을 DB에 저장한다'일 것이다. 이런 상세함을 한 번에 다 머리속으로 생각할 수 있는 사람은 많지 않다. (물론, 정말 똑똑한 사람은 한 번에 다 생각할 수 있겠지만..) 그래서 필요한 게 생각이 가능한 수준의 덩어리로 나눠서 생각하는 것이다. 상세한 구현을 생각이 가능한 수준의 덩어리로  만들어나가는 과정이 추상화 과정이며, 이를 통해 개발자는 구현의 상세함 속에서 허우적거리지 않고 (머리속에서) 관리 가능한 수준으로 프로그램과 구현을 다룰 수 있게 된다.

관련글

참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.



  1. 지노 2012.10.23 08:41

    좋은 글 잘 읽었습니다. ^^

한 번 만들어진 소프트웨어는 얼마나 사용될까? 윈도우 XP는 10년 넘게 사용되고 있고, 포털 사이트의 카페 서비스 역시 10년 넘게 운영을 하고 있다. 여기서 알 수 있는 사실은, 한 번 쓰고 버릴 소프트웨어를 제외한 나머지 다수의 소프트웨어는 개발 기간보다 더 오랜 시간 동안 사용된다는 점이다. 소프트웨어는 살아 있는 생물과도 같아서 지속적으로 변화를 하게 되며, 이는 곧 소프트웨어를 유지하기 위해서는 계속해서 비용이 발생한다는 것을 뜻한다. 소프트웨어를 유지하기 위해 비용이 계속해서 발생하는데, 소프트웨어를 개발하는 시간보다 소프트웨어를 사용하는 시간이 더 길다는 것은, 다시 말해서 소프트웨어를 개발하는 비용보다 소프트웨어를 유지하는 비용이 더 많이 발생한다는 사실을 말해준다.


초기 개발 비용 <<<<<<<< 유지보수 개발 비용


비용을 줄이려면 어떻게 코드를 만들어야 할까? 여러 가지 답이 있겠지만 필자는 다음의 두 가지라고 생각한다.

  • 코드 분석이 용이
  • 코드 변경이 용이
당연한 얘기지만, 코드를 쉽게 읽을 수 있고 변경을 쉽게 할 수 있어야 유지보수 비용이 줄어든다. 근데 왜 객체 이야기를 하는데 비용 얘기를 하느냐? 그건 바로 객체 지향적으로 개발을 하는 것이 위 두 가지를 달성할 수 있는 방법이기 때문이다. 설계를 잘 하느니 객체를 잘 뽑아내느니 등의 말은 겉치레에 불과하다. 필자가 객체 지향을 좋아하는 이유는 뭔가 있어 보이는 것도 있지만(^^;) 그것보다는 전체적으로 유지보수 비용을 감소시켜주기 때문이다. 객체 지향이 비용을 감소시켜 주지 못한다면 객체 지향은 쓸모가 없는 것이나 다름없다.

그렇다면, 왜 객체 지향을 하면 유지보수 비용이 줄어들게 될까? 이는 절차 지향과 객체 지향을 비교해보면 좀 더 명확해질 것 같다.


절차 지향과 객체 지향


절차 지향의 핵심은 데이터를 중심으로 구현을 한다는 데 있다. 각각의 기능을 수행하는 프로시저(함수)들이 데이터를 조작하거나 사용하는 방식으로 구현을 한다.



다양한 프로시저들이 데이터를 공유해서 동작하기 때문에 데이터의 변화는 곧 데이터를 사용하는 코드(프로시저)의 변화로 이어진다. 심지어 한 데이터 타입의 변화가 다른 데이터의 변화를 일으키기는 경우도 있다. 예를 들어, 화폐를 표현하는 데이터의 타입이 int에서 double로 변경되면 화폐를 사용하는 프로시저 뿐만 아니라 잔고, 판매금액 등의 다른 데이터 타입까지도 모두 변하게 된다. 심할 경우, 한 변수의 타입 변화가 수 일이 소용되는 노가다를 만들어내기도 한다. 이는 데이터 중심인 절차 지향으로 구현할 경우 유지보수 비용이 높아진다는 것을 의미한다.


중심에 데이터를 놓고 프로시저를 구현하는 절차 지향과 달리 객체 지향은 데이터와 기능을 함께 담고 있는 객체들이 서로 대화하는 방법으로 구현된다. 객체 간에는 데이터를 (최대한) 주고 받지 않으며, 객체 상호간에 기능을 실행해 달라고 요청하는 방식으로 구현을 하게 된다.



각 객체들은 내부적으로 데이터를 담고 있지만 다른 객체에게 데이터를 노출하지는 않는다. 객체와 객체 간에 데이터가 흐르지 않기 때문에 특정 객체가 담고 있는 데이터에 변화가 생기더라도 해당 객체로만 변화가 한정되며 다른 객체에까지 변하가 전파되지 않게 된다. 이 얘기는 변화에 따른 유지보수 비용이 절차 지향에 비해 상대적으로 더 작다는 것을 의미한다.


프로시저와 객체 지향에 대한 보다 구체적인 예는 필자가 이전에 쓴 '캡슐화 http://javacan.tistory.com/entry/EncapsulationExcerprtFromJavaBook'란 글을 참고하기 바란다.


모든 것을 객체 지향으로 할 필요는 없다. 단순히 한 개의 DB 테이블의 데이터를 읽어서 보여주는 웹 방명록을 생각해보자. 이 기능은 데이터를 중심으로 한 절차 지향으로 구현하는 것이 훨씬 간단하다. 또한 일회성으로 사용될 이벤트 프로그램을 구현하기 위해 객체 지향을 추구할 필요는 없다. 왜냐면 유지보수 비용이 발생하지 않기 때문이다. 하지만, 도메인이 복잡하고 유지보수 기간이 길어질수록 절차 지향 방식은 높은 개발 비용을 발생시키기 때문에, 이런 경우에는 객체 지향적으로 하는 것이 비용측면에서 유리하다. 물론, 코드를 만져야 하는 개발자의 정신 건강 측면에서도 훨씬 유리하다.


객체의 핵심은 기능을 제공하는 것


객체를 가장 간단하게 정의하자면 '기능을 제공하는 것'이라고 할 수 있다. 조금 더 길게 설명하자면 다음과 같다.

  • 제공하는 기능으로 정의된다.
  • 내부적으로 어떤 데이터를 들고 있고 어떻게 기능을 구현하는 지는 숨긴다.

객체는 기능을 제공한다. 예를 들어, TV라는 객체를 생각해보자. TV는 다음의 기능들을 제공할 것이다.

  • 채널 증가
  • 채널 감소
  • 지정 번호로 채널 변경
  • 볼륨 증가
  • 볼륨 감소

TV가 내부적으로 볼륨의 크기를 어떤 데이터 타입으로 관리하는지는 중요하지 않고, 단지 TV 객체에 볼륨을 증가시키라고 요청을 할 뿐이다. 이를 코드로 표현하면 다음과 같다.


TV tv = new TV();

tv.increaseVolume(); // 기능을 제공


만약 데이터가 흘러다니도록 구현을 한다면 다음과 같은 코드가 만들어질 것이다.


TV tv = new TV();

int volume = tv.getVolume();

tv.setVolume(volume + 1);


두 코드의 차이는 명확하다. 앞서 첫 번째 코드의 increaseVolume()는 볼륨을 증가시키는 기능을 제공하며, 내부적으로 볼륨의 타입이 변경되더라도 영향을 받지 않는다. 반면에 두 번째 코드는 볼륨 값을 저장하는 타입이 int 임을 노출하고 있으며, 이 데이터에 기대서 볼륨 조절 기능을 구현하고 있다. 두 번째 코드에서 볼륨을 보관하는 데이터 타입이 short이나 byte와 같은 다른 타입으로 변경될 경우 그 데이터를 사용하는 코드가 함께 변경되게 된다.


객체의 역할(또는 책임)과 메시징


객체는 기능을 제공하는 존재이기 때문에, 객체 지향적으로 설계를 한다는 것은 각각의 객체가 어떤 기능을 제공하는지 정의하는 작업이 된다. 또한, 각 객체들이 서로 어떻게 상대방의 기능을 사용하는지를 설계하는 것이 객체 설계가 된다. 이런 의미에서 객체 지향 설계를 할 때 가정 먼저 해야 할 작업은 객체가 어떤 역할을 수행하는 지 결정하는 것이다.


하나의 객체가 모든 기능을 제공하는 것은 유지보수 측면에서 바람직하지 않기 때문에, 각 기능들을 관련된 것들끼리 분리해서 각각의 객체들에 역할을 분배해준다. 예를 들어, 로그 분석하는 시스템을 생각해보자. 이 경우 다음과 같이 객체 별로 역할을 구분해서 할당할 수 있다.

  • 로그 수집 객체 객체: 로그를 수집해서 한 곳에 모아준다.
  • 로그 분석 객체: 로그 파일로부터 데이터를 추출해서 중간 결과를 생성한다.
  • 리포트 생성 객체: 중간 결과로부터 실제 원하는 리포트를 생성한다.
  • DB 연동 객체: DB에 데이터를 삽입해주는 기능을 제공한다.
  • 흐름 제어 객체: 위 객체들간의 실행 순서를 제어한다.

객체의 역할과 함께 도출되는 것은 객체들 간의 관계이다. 전체 기능이 각각의 객체에 분산되어 있기 때문에, 완성된 기능을 제공하려면 각 객체들이 제공하는 기능을 엮어주어야 한다. 이는 객체 간의 메시징을 통해서 이루어진다. 한 객체는 다른 객체에 기능 실행을 요구하기 위한 요청을 보내는데 이 요청을 보통 메시지라고 표현한다. 다른 객체로부터 메시지를 받은 객체는 메시지에 알맞은 기능을 실행하고 그 결과를 메시지로 보낸다. 자바의 경우 기능 실행을 요청하는 메시지가 메서드 호출로 구현되며, 응답 메시지는 리턴이나 익셉션 등으로 구현된다.


객체의 역할과 메시지로부터 뭔가 떠오르는 것이 있을 것이다. 그것은 바로 커뮤니케이션 다이어그램이다. 커뮤니케이션 다이어그램은 객체 간의 메시지를 주고 받는 과정을 표현하기 위한 다이어그램으로, 이는 객체 지향 설계의 기본이 된다. 실제로 객체를 설계한다는 것은 다음을 반복하는 과정이다.

  • 기능들을 제공할 객체 후보를 선별한다.
  • 각 객체들이 어떻게 메시지를 주고 받는 지 연결한다.
  • 안 좋은 냄새가 나면 위 단계를 되풀이한다.

좋은 객체를 만드는 기본 규칙: 데이터 말고 기능 제공해 줘!


좋은 객체가 한 번에 나오면 좋겠지만, 설계란 그리 호락호락하지 않음을 누구나 알고 있다. 그래서 지속적으로 설계가 바뀌게 되고, 그렇기에 리팩토링이 구현에서 중요한 과정이 되는 것이다. 하지만, 출발점을 좋게 잡을 수는 있다. 그 중 하나가 다음에 따라 객체의 기능을 제공하는 것이다.

  • 데이터를 달라고 하지 않고, 해 달라고 하기!
  • 데이터 조회 기능은 필요한 경우에 한정해서 제공한다.

위 규칙에 따라 객체를 설계하다보면 자연스럽게 객체들 사이로 데이터가 흘러다니는 것을 줄이고, 객체가 내부 내장을 드러내지 않고 (즉, 캡슐화하고) 알맞은 기능을 제공하도록 만들 확률을 높일 수 있게 된다.


내용 정리


간단하게 내용을 정리해 보자.

  • 객체는 기능을 제공하는 역할을 수행하며, 객체의 내부가 어떻게 구현되었는지는 숨긴다.
  • 각 객체는 메시지를 주고 받으며 협업하며, 이를 통해 하나의 완성된 기능을 제공한다.
  • 객체 지향은 변화의 영향이 주변으로 퍼져나가는 것을 감소시켜 유지보수 비용을 줄여준다.
관련글

참고자료

제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.

 

1월 28일 예정되어 있는 DDD 쌩기초 세미나 발표 자료입니다.
세미나 관련 정보는 http://cafe.daum.net/javacan/9Voo/88 에서 확인할 수 있습니다.
 
많은 입문서에서 캡슐화(encapsulation)를 단순히 private을 사용해서 필드를 외부에 감추는 것 정도로 소개하고 넘어가는 경우가 많아, 많은 입문자들에게 오해를 심어주는 것 같다. 그래서 집필 중인 자바 프로그래밍 입문서에서 캡슐화와 관련된 부분을 블로그를 통해 공개하고자 한다. 아래 내용부터는 (아직 집필중인) 책의 캡슐화 부분을 발체한 내용이다.

* 본 문서는 책의 일부 내용을 발췌한 것으로서 온오프라인 상의 무단 배포를 금합니다.

캡슐화(encapsulation)

지금까지 자바에서 클래스를 만드는 방법을 살펴봤다. 필드를 이용해서 객체의 상태를 보관하고, 메서드를 이용해서 기능을 구현하고, 생성자를 이용해서 객체를 생성하는 방법을 공부 했다. 또한, public, protected, private을 이용한 접근 제어 방법도 살펴봤다.

그럼, 왜 클래스를 이용하는 걸까? C 언어의 구조체와 함수를 이용해도 자바의 클래스와 비슷한 구현이 가능할 것 같은데 말이다. 클래스를 사용하는 이유는 객체 지향 방식으로 구현하기 위해서이다. 자바는 클래스를 이용해서 객체를 표현하고 있으며, 자바 언어로 객체 지향을 잘 하기 위해서는 객체 지향 원칙에 따라 클래스를 작성해 주어야 한다.

그렇다면, 객체 지향의 가장 기본이 되는 원칙은 무엇일까? 바로 이 절의 제목인 '캡슐화(encapsulation)'가 객체 지향의 가장 기본이 되는 대원칙이다. 본 절에서는 완벽하게는 아니어도 조금이나마 독자가 캡슐화가 무엇이고 왜 캡슐화가 중요한 지 느낄 수 있도록 할 것이다.

흔히 객체 지향과 반대되는 개념으로 절차 지향을 언급하는데, 본 절에서는 아주 간단한 스톱워치 기능을 구현하는 과정을 통해서 절차 지향의 단점을 파악하고 캡슐화를 통해 어떻게 이 문제를 해결할 수 있는 지 살펴 볼 것이다.

절차 지향 방식의 구현

성능 측정 어플리케이션을 개발해서 고객에게 납품하기로 했다고 하자. 성능 측정을 위해 필요한 공통 기능은 실행 시간을 측정하는 것이다. 그래서 시간 데이터를 표현하기 위해 다음과 같이 밀리초(1/1000초) 단위로 시간을 보관할 수 있는 클래스를 작성하였다.

package ch03.util;

public class ProceduralStopWatch {

    public long startTime; // 밀리초(1/1000초) 단위
    public long stopTime; // 1/1000초 단위

    public long getElapsedTime() {
        return stopTime - startTime;
    }
}

ProceduralStopWatch 클래스는 시작 시간과 종료 시간을 저장할 수 있는 두 개의 public 필드를 제공하고 있다. 성능 측정 모듈에서 종료 시간과 시작 시간 사이의 차이를 구하는 코드가 많기 때문에, 흘러간 시간을 쉽게 구할 수 있도록 getElapsedTime() 메서드를 추가로 구현하였다.

이제 ProceduralStopWatch 클래스를 사용하는 코드는 다음과 같은 방식으로 작성될 것이다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startTime = System.currentTimeMillis(); // 시작 시간 설정

// 측정 대상 기능 실행

stopWatch.stopTime = System.currentTimeMillis(); // 종료 시간 설정
long elapsedTime = stopWatch.getElapsedTime(); // 시간 차이 구함

수 개월을 문제 없이 개발해 나갔다. 그런데, 늘 그렇듯이 고객으로부터 요구 사항을 변경하자는 연락이 왔다. 필요에 따라 밀리초(1/1000초) 보다 더 세밀한 단위로 측정할 필요가 있기 때문에 밀리초뿐만 아니라 더 세밀한 단위로도 측정할 수 있게 해 달라는 것이었다.

아주 간단한 방법은 다음과 같이 나노초 단위로 측정이 필요한 코드에서만 startTime 필드와 stopTime 필드에 나노초 단위로 시간을 저장하는 것이다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startTime = System.nanoTime(); // 시작 시간 나노초 단위 설정

// 측정 대상 기능 실행

stopWatch.stopTime = System.nanoTime(); // 종료 시간 나노초 단위 설정
long elapsedTime = stopWatch.getElapsedTime(); // 시간 차이 나노초 단위로 구함

하지만, startTime 필드에 어떤 경우에는 밀리초 단위의 값을 보관하고 어떤 경우에는 나노초 단위의 값을 보관하게 되면, 개발 과정뿐만 아니라 유지보수 과정에서 문제가 발생할 것 같다. 이미 개발자 중 몇 명은 기준 없이 밀리초와 나노초를 섞어서 사용하기 시작했다.

그래서, 나노초 단위를 저장할 수 있도록 ProceduralStopWatch 클래스에 startNanoTime, stopNanoTime의 두 필드와 getElapsedNanoTime() 메서드를 추가하기로 결심했다.

package ch03.util;

public class ProceduralStopWatch {

    public long startTime;
    public long stopTime;
   
    public long startNanoTime;
    public long stopNanoTime;

    public long getElapsedTime() {
        return stopTime - startTime;
    }
   
    public long getElapsedNanoTime() {
        return stopNanoTime - startNanoTime;
    }
}

이제 나노초 단위를 이용해서 시간을 측정해야 하는 코드를 다음과 같이 변경함으로써, 시간 단위가 나노초 임을 분명히 할 수 있게 되었다.

ProceduralStopWatch stopWatch = new ProceduralStopWatch();
stopWatch.startNanoTime = System.nanoTime(); // 시작 시간 나노초 단위 설정

// 측정 대상 기능 실행

stopWatch.stopNanoTime = System.nanoTime(); // 종료 시간 나노초 단위 설정
long elapsedTime = stopWatch.getElapsedNanoTime(); // 시간 차이 나노초 단위로 구함

일단, 급한 불은 껐다. 그런데, 여전히 불안함이 떠나질 않는다. 만약 고객이 초 단위로 값을 구해 달라는 요구가 추가되면 어떻게 해야 하나? 또는 같은 시간을 초 단위, 밀리초 단위, 나노초 단위로 표현해 달라고 하면 어떻게 해야 하나? 아마 코드는 점점 복잡해지고 요구사항이 추가되거나 변경될 때마다 함께 수정되는 코드도 많아질 것이다.

자, 그러면 왜 이런 일이 벌어졌을까? 가장 큰 이유는 데이터를 중심으로 개발했기 때문이다. (데이터를 중심으로 개발하는 것이 전형적인 절차 지향 방식 개발이다.)

[그림3.18] 데이터를 중심으로 개발되는 절차 지향 방식


ProceduralStopWatch 객체를 사용하는 코드는 객체의 필드에 직접 값을 할당하고 값을 가져올 수 있다. 편의상 getElapsedTime() 메서드를 만들었지만, 마음대로 필드의 값을 조작할 수 있다. ProceduralStopWatch 객체의 내부인 startTime 필드와 stopTime 필드를 마음대로 접근할 수 있기 때문에 많은 코드들이 직접 ProceduralStopWatch 객체의 필드 데이터를 조작할 수 있으며, 이로 인해 ProceduralStopWatch의 내부 코드를 변경하게 되면 많은 코드의 변화를 유발하게 된다.

[그림3.19] 절차 지향에서는 데이터의 변화가 많은 코드의 변화를 유발시킴

요구 사항의 변화가 없다면 또는 추가되지 않는다면, 데이터를 중심으로 개발하는 게 문제가 되지 않는다. 하지만, 거의 모든 프로젝트에서 요구사항은 추가되거나 변경되기 마련이며, 데이터를 중심으로 개발할 경우 데이터의 변화는 많은 코드에 수정을 발생시키는 요인이 된다.

그렇다면 어떻게 해야 ProceduralStopWatch 클래스를 사용하는 코드에 영향을 주지 않으면서 (또는 영향을 최소화하면서) ProceduralStopWatch 클래스의 내부 데이터 구조를 변경할 수 있을까? 정답은 바로 ProceduralStopWatch 클래스의 기능을 캡슐화하는 것이다.

객체 지향 방식의 구현

스톱워치 예를 객체 지향 방식으로 재구성해 보겠다. 객체 지향의 핵심 중의 핵심은 캡슐화에 있다. 캡슐화는 자세한 내부 구현을 외부에 드러내지 않고 숨기는 것이다. 캡슐화를 하게 되면 내부에 데이터를 어떻게 저장하는 지, 그 데이터를 어떻게 처리하는 지, 또는 특정 기능을 어떻게 제공하는 지에 대한 내용은 드러내지 않는다. 단지, 객체가 어떤 기능을 제공하는 지만 공유하게 된다.

예를 들어, 스톱워치 예의 경우 시간 데이터를 어떻게 구하는 지 또는 어떤 타입으로 저장하는 지 등의 구현은 외부로 드러내지 않고 다음의 기능만 외부에 제공하게 된다.
  • 스톱워치를 시작한다
  • 스톱워치를 중지한다.
  •  중지와 시작 사이의 시간 차이를 구한다.
스톱워치 기능을 제공하는 클래스를 캡슐화해서 다시 구현해 보자. 아래 코드는 객체 지향 방식으로 새롭게 구현한 스톱워치 클래스이다.

package ch03.util;

public class StopWatch {

    private long startTime;
    private long stopTime;

    public void start() {
        startTime = System.currentTimeMillis();
    }

    public void stop() {
        stopTime = System.currentTimeMillis();
    }

    public Time getElapsedTime() {
        return new Time(stopTime - startTime);
    }
}

StopWatch 클래스는 시작 시간과 종료 시간을 보관하기 위해 startTime 필드와 stopTime 필드를 사용하는데, 이 두 필드는 private 이다. 따라서, StopWatch 클래스를 제외한 다른 코드에서는 이 두 필드에 직접 접근할 수 없다.

start() 메서드는 startTime 필드에 현재 시간 값을 밀리초 단위로 저장한다. stop() 메서드도 유사하게 stopTime 필드에 밀리초 단위로 현재 시간 값을 저장한다. 즉, 스톱워치의 시작과 중지를 처리하려면 이 두 메서드를 호출해야 한다.

getElapsedTime()은 long이 아닌 Time이라는 클래스 타입을 리턴한다. Time은 시간을 표현하기 위해 만든 클래스로서 아래와 같다.

package ch03.util;

public class Time {

    private long t;
   
    public Time(long t) {
        this.t = t;
    }

    public long getMilliTime() {
        return t;
    }
}

StopWatch 클래스를 사용해서 시간 차를 구하는 코드는 다음과 같을 것이다.

StopWatch stopWatch = new StopWatch();
stopWatch.start(); // startTime 필드에 값을 할당하는 게 아닌, 기능 실행
// 코드
stopWatch.stop(); // stopTime 필드에 값을 할당하는 게 아닌, 기능 실행
Time time = stopWatch.getElapsedTime(); // long 타입이 아닌 시간을 표현하는 타입
// time.getMilliTime() 사용

앞서 절차 지향 방식에서는 필드에 직접 접근해서 필드 값을 변경했던 것에 반해 객체 지향 방식에서는 상세한 구현이 start() 메서드와 stop() 메서드로 캡슐화 되어 있다. StopWatch 클래스를 사용하는 코드는 start() 메서드가 내부적으로 어떻게 시작 시간을 저장하는 지에 대해서는 알 필요 없이, start() 메서드가 스톱워치의 시작 기능을 제공한다는 것만 알면 된다.

스톱워치를 중지한 뒤 흘러간 시간을 구할 때 사용된 리턴 타입은 long이 아닌 Time 클래스이다. long이 아닌 Time 클래스를 사용한 이유는 long은 시간을 표현하는 데 적합하지 않기 때문이다. 그래서 시간을 표현하기 위해 Time 클래스를 추가로 만들었고, Time 클래스로부터 필요한 값을 구하도록 했다.

이제 고객으로부터 새로운 요구사항을 받을 차례가 됐다. 절차 지향 방식을 설명할 때와 동일하게 나노초 단위로도 시간 차이 값을 구할 수 있어야 된다고 한다. 절차 지향 방식에서 ProceduralStopWatch 클래스를 사용하는 수 많은 코드를 변경해야 했던 기억이 새록 새록 떠오를 것이다. 하지만, 걱정하지 마라. 우리는 이미 스톱워치와 시간을 캡슐화하는 데 성공했고, StopWatch 클래스와 Time 클래스를 사용하는 코드에 어떤 영향도 주지 않고 이 두 클래스를 변경할 수 있다.

먼저, StopWatch 클래스가 내부적으로 나노초를 저장하도록 변경해보자. 다음과 같이 밀리초를 가져오는 코드를 나노초를 가져오는 코드로 변경했다.

package ch03.util;

public class StopWatch {

    private long startTime;
    private long stopTime;

    public void start() {
        startTime = System.nanoTime();
    }

    public void stop() {
        stopTime = System.nanoTime();
    }

    public Time getElapsedTime() {
        return new Time(stopTime - startTime);
    }
}

Time 클래스가 생성자에서 입력받는 값이 밀리초에서 나노초로 변경되었으므로 Time 클래스를 다음과 같이 변경하였다. t 필드가 저장하는 값의 단위가 나노초이므로 getMilliTime() 메서드의 구현이 일부 변경되었고, getNanoTime()메서드가 새로 추가되었다.

package ch03.util;

public class Time {

    private long t;
   
    public Time(long t) {
        this.t = t;
    }

    public long getMilliTime() {
        return t / 1000000L;
    }

    public long getNanoTime() {
        return t;
    }
}

나노초 단위를 수용할 수 있도록 StopWatch 클래스와 Time 클래스를 변경하였다. 이제 이두 클래스를 사용하던 코드가 나노초를 사용하도록 변경할 차례이다. 바꿔보자.

StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 코드
stopWatch.stop();
Time time = stopWatch.getElapsedTime();
// time.getNanoTime() 사용

어떤가? 나노초 단위로 시간 차이를 구하기 위해서 변경한 코드는 Time 클래스의 getMilliTime() 메서드 대신 getNanoTime() 메서드를 사용하도록 변경한 것뿐이다. 나머지 코드는 하나도 변경되지 않았다.

고객으로부터 요구사항이 또 들어왔다. 초 단위로도 시간 차이를 표현하고 싶단다. 이건 아주 쉽다. 왜냐면 우리는 Time 클래스를 이용해서 시간을 표현하고 있기 때문이다. 다음과 같이 Time 클래스에 메서드 하나만 더 추가해주면 끝이다.

package ch03.util;

public class Time {
    ...
    public double getSecondTime() {
        return (double)t / (double)1000000000;
    }
}

자세한 내부 구현을 숨기고 외부에는 기능만을 제공하도록 StopWatch 클래스를 캡슐화 하였다. 그리고, 스톱워치의 요구 사항이 변경되었지만 StopWatch 클래스를 잘 캡슐화 한 덕분에 StopWatch 클래스를 사용하는 코드에 거의 영향을 주지 않고 StopWatch 클래스를 변경할 수 있었다.

이게 바로 캡슐화의 힘이다. 내부 구현(특히 데이터)을 외부에 노출하지 않고 기능을 잘 캡슐화하게 되면, 해당 기능을 변경해야 하는 상황이 발생할 경우 특정 클래스로만 변화가 수렴되는 특징을 갖게 된다. 변화가 여러 클래스로 확산되지 않기 때문에 그 만큼 캡슐화 된 클래스의 수정은 용이하게 된다.

[그림3.20] 캡슐화는 요구사항 변화에 따른 코드 수정 범위를 최소화 해 준다.

실제로 다수의 소프트웨어는 개발 과정뿐만 아니라 개발이 완료된 이후에도 요구사항의 추가, 버그 수정 등으로 코드를 수정하게 된다. 만약 절차 지향으로 개발했다면 이런 수정 과정에서 변경되는 코드의 범위는 점점 커지게 되고 시간이 흐를수록 변화의 폭은 더욱 증폭된다. 따라서 절차 지향 방식에서 무엇인가를 변경하는 것은 개발자 입장에서 많은 위험과 어려움이 따르는 작업이 된다.

반면에 객체 지향 방식으로 개발했다면 변화의 범위는 소수의 클래스로 한정되는 경향이 있으며, 따라서 새로운 요구 사항을 (절차 지향에 비해) 쉽고 빠르게 수용할 수 있게 된다. 우리는 이미 StopWatch 예제에서 이런 특징을 확인할 수 있었다.

  1. 권남 2011.07.15 11:45

    멋진 설명 잘 읽었습니다. 책이 기대되네요.

    • 최범균 madvirus 2011.07.16 08:34 신고

      자바 기초와 실무 내용이 알맞게 나오도록 노력했는데, 좋은 책이 되었으면 하고 바라고 있습니다.

  2. 2011.07.15 22:54

    비밀댓글입니다

  3. richpapa 2011.07.18 11:01

    기대됩니다. 언제 나오나요?

  4. 백명석 2011.08.04 16:41

    근데 Time 클래스에 요구사항이 늘어감에 따라 메소드가 추가되는 것이 보기 않 좋네.
    방법이 있지 않나 ?
    하나 또 오르는 방법이 있는데... 어찌 생각하나 ?

    • 최범균 madvirus 2011.08.05 08:50 신고

      메서드가 증가가 많지 않을 것 같긴 하지만, 메서드가 계속 증가한다면 다음과 같은 메서드를 추가하면 어떨까 합니다.

      long elapsedTime(TimeUnit unit)

      이 경우에는 StopWatch 클래스에서 바로 위 클래스를 넣어도 될 듯 합니다. Time 클래스에 넣어야 한다면

      long value(TimeUnit unit) 정도의 이름이 좋을 것 같구요.

  5. 백명석 2011.08.05 09:14

    아니면 Visitor Pattern으로 원하는 timeUnit 별로 elapse Time을 계산하는 객체를 전달하여 계산하도록 하면 어떨까 ?
    long getElapsedTime(ElapsedTimeCalculateVisitor visitor) 이런 식으로.
    구조체는 새로운 타입 추가시 많은 변경을 유발하지만, 새로운 기능 추가시에는 해당 기능만 추가하면 되고,
    클래스는 polymorphism을 활용하여 변경 없이 새로운 타입 추가할 수 있지만, 기존 클래스에 새로운 기능 추가시에는 해당 클래스의 모든 클라이언트와 서브클래스까지 변경되어야 하는 재앙이 발생한다.
    그런데 이 같은 클래스의 문제는 Visitor로 해소할 수 있지 않을가 싶다 ^^

    • 최범균 madvirus 2011.08.05 09:40 신고

      너무 많이 가시는 거 같아요. 저도 Visitor와 같은 double dispatch 방식을 생각해보긴 했는데, Visitor가 결국 StopWatch의 내장(시간 저장하기 위한 타입)을 알게 되는 상황이 발생해서 고민이 좀 되더라구요.

      그래서 Time을 좀 더 추상화해서 Time 객체가 크기 비교를 할 수 있는 기능을 추가하고, Time이 스스로 자기를 표현할 수 있도록 구현해주는 것도 생각해 볼 만 할 것 같아요.

      그런데요,,, 이 논의 거기 팀원들하고 마저 하심 안 되요? 제가 어제 술독에 빠졌다가 나와서 정신이 없어요.

  6. 백명석 2011.08.05 10:09

    ㅎㅎㅎ 이런 논의가 관심있는 많은 분들과 공유될 수 있었으면 좋겠다. 몸 추스려 ^^

  7. Kunny 2012.04.04 15:44 신고

    디자인 패턴 책을 읽던중 패턴의 원칙중 하나는 변화하는것을 캡슐화한다고 적혀있었는데 도대체 뭐가 캡슐화인지 감이 안오던 중에 ..이 글을 보고 어렴풋이 이해하고 갑니다.

    사실 4일째 한번씩 보는중.

  8. 넘고 2012.08.21 15:52 신고

    맨 처음 말씀하신대로 캡슐화에 대해 데이터를 숨기는 정도로만 알고 있었는데...
    정말 좋은글 감사합니다!

  9. 황윤성 2016.03.11 23:02

    읽고 나니까 제가 캡슐화에 대해서 잘 모르고 있었다고 깨달았어요

    공부 중인 학생인데 감사합니다!

  10. 맹구 2018.05.30 15:43

    과거에 캡슐화를 쓰지 않은 저를 용서할수 없게되었습니다...

    좋은 글 올려주셔서 감사합니다.

예전에 UML 스터디 할 때 발표용으로 작성했던 자료.
 
패턴 스퍼티 - Visitor 발표 자료


패턴 스터디 발표 자료 - State 패턴
View more presentations from Beom kyun Choi. (tags: 패턴 스터디)


패턴 스터디에서 발표 때 사용할 Mediator 패턴 자료 공유
GoF Mediator 패턴
View more presentations from Beom kyun Choi. (tags: gof mediator)


패턴 스터디에서 1회에 Delegation과 Strategy를 발표할 예정인데,
발표때 사용할 PT 자료 공유합니다.

패턴스터디-1 Delgation&Strategy
View SlideShare presentation or Upload your own. (tags: 패턴delegation위임st...)
- 최범균
  1. kinjsp 2009.01.30 08:57

    잘 보았습니다^^
    중간에 양군사장님과 빅뱅이 나와서
    조금 놀랐어요,.ㅋㅋ

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을 사용하는 것이 점점 약해지기 마련이다. 명확한 프로그램을 하기 위해서는 모든 구문을 갖춘 실제 언어를 사용하는 것이 더 좋기 때문이다. 만약 몇가지 구분되는 빌더 시나리오가 존재한다면, 객체를 조립하는 다양한 빌더 클래스를 만들고, 간단한 설정 파일을 사용해서 빌더 클래스를 선택하도록 하면 된다.

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

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

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

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

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

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

결론

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

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

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

관련링크:
  1. rka 2016.09.30 15:40

    감사합니다 @@

  2. Gunju 2017.03.21 09:40 신고

    감사합니다!

자바에서 인터페이스는 다음과 같은 형태의 코드로 구성됩니다.

public interface Component {
    public void init();
    public void start();
    public Status getStatus();
    ...
}

인터페이스의 가장 큰 특징은 구현이 없이 약속(contract)만 정의했다는 겁니다. 예를 들어, 자바 1.4까지는 쓰레드 프로그래밍을 하려면 반드시 Runnable 인터페이스를 만들어야 했는데, 이 Runnable 인터페이스에 선언된 run() 메소드는 쓰레드가 시작될 때 실행된다는 걸 명시하고 있습니다.

인터페이스로는 어떻게 동작할 것이다라는 기능만 명세(specification)하고 그 명세에 따라 클래스를 구현(implementation)하는 것은 이젠 많은 개발자들에게 익숙한 방식입니다. 자바에서는 이미 Runnable 인터페이스를 사용해서 쓰레드를 구현하고 있고, 서블릿은 Servlet 인터페이스를 사용해서 서블릿을 구현하고 있습니다. EJB에서는 EJB의 기능을 명세한 인터페이스를 만들고, 그 인터페이스를 implements한 구현 클래스를 만들도록 하고 있구요.

이렇듯 몇년 동안 나온 기술들은 하나같이 인터페이스를 사용해서 기능을 명세하고 있고, 이 명세에 따라 클래스를 구현하도록 유도하고 있습니다. (심지어, Hibernate, Strut니 하는 오픈소스 기술들 역시 내부적으로 기능을 정의한 인터페이스와 그 인터페이스를 구현한 클래스로 구분되어 있습니다.)

인터페이스가 이렇게 많은 곳에서 기능을 명세하는 데 사용되는 가장 큰 이유는 인터페이스를 사용함으로써 구현에 의존적이지 않은 코드를 만들 수 있기 때문입니다. 예를 들어, EJB 콘테이너를 생각해보죠. EJB API는 크게 SessionBean과 EntityBean 인터페이스를 정의하고 있으며, 이들 인터페이스에는 빈 컴포넌트가 지켜야 할 규칙(즉, 메소드)이 명시되어 있습니다. 개발자들은 그 규칙에 따라 EJB 컴포넌트를 만들게 됩니다. 개발자가 어떤 클래스를 만들었던지간에 SessionBean 내지 EntityBean 인터페이스에서 정의한 대로 클래스를 구현했다면, EJB 콘테이너는 그 클래스를 사용할 수 있게 됩니다. 즉, 클래스의 구현에 상관없이 동일한 방식으로 모든 EJB 컴포넌트를 다룰 수 있게 되는 것이죠. 스트러츠 역시 구현에 상관없이 동작합니다. 개발자가 Action에서 명세한 대로 클래스를 구현했다면, 스트러츠는 올바르게 동작합니다.

이렇듯 인터페이스의 가장 큰 힘은 기능의 정의와 기능의 구현을 분리할 수 있다는 데 있으며, 이는 구현의 확장 및 교체가 쉽다는 걸 의미합니다. 마치, 똑같은 커넥터를 사용하는 컴퓨터 키보드를 쉽게 교체할 수 있듯이, 프로그램 모듈도 동일한 인터페이스를 채택하도록 함으로써 쉽게 교체할 수 있게 되는 것이죠.

여러분도 교체 가능한 모듈을 설계해야 한다거나, 확장이 쉬운 구조를 만들고 싶다면 인터페이스를 사용하세요. 그럼 답이 보일겁니다.

+ Recent posts