이제 책임을 분리함으로써 소프트웨어의 구조를 잘 만들 수 있게 되었으니 책임을 중심으로 패키지를 만드는 방식을 이야기 할 차례다.


<단일 패키지 내 클래스들 : 지금까지 만든 클래스들을 각각의 소스 파일로 분리하여 하나의 패키지에 넣으면 이와 같은 모양이 될 것이다.>


패키지(package)는 소프트웨어를 배포하는 물리적 단위다. 만약 잘 만들어진 소프트웨어를 재사용해야 하는 상황이라면 패키지 단위로 재사용하는 것이 가장 현명한 선택이다. 그리고 패키지 단위로 배포를 하기 위해서는 패키지 구조를 잘 만들어 두어야 한다. 다행히 지금까지 만들어 왔던 책임 중심의 구조는 패키지로 구조화 시키기에도 매우 적합하다. 잘 패키징 된 소프트웨어는 소스를 분석하기에도 편리하다. Java의 패키지 구조는 디렉토리 구조와 매치된다. 따라서 패키징을 잘 하면 디렉토리 별로 잘 분류된 문서들처럼 손쉽게 필요한 소스들을 찾아 볼 수가 있다. 여기서는 지금까지 만든 클래스들을 어떤 형태로 패키징 하는 것이 좋으며, 특히 구현 책임을 외부로 노출시키지 않기 위해서 어떤 형태로 소스 파일들을 분리하고 가시성을 설정하는 것이 좋은지 이야기 해보도록 하겠다.


가시성 조정

가장 간단한 가시성 부분을 먼저 해결해보도록 하자. 우리는 지금까지 여러 책임을 분리하면서 그 기저에 단 한가지 목표를 두었다. 그것은 Client와 구현 객체의 분리이다. 이를 위해서 계약 책임을 도입하고, 생성 책임을 분리하고, 조립 책임을 분리했다. 결국 Client 클래스 눈에 구현 객체들이 보이면 안되는 것이다. 이것을 아예 설계 단계에서 방지할 수 있는 방법은 구현 객체들의 가시성을 낮추는 것이다. 만약 구현 객체들을 패키지 가시성 수준으로만 낮춘다고 해도 다른 패키지에서는 구현 객체들이 존재하는지조차 알 수 없게 된다. Client 클래스와 구현 클래스들을 서로 다른 패키지에 넣고, 구현 클래스들의 가시성을 패키지 수준(즉 class 선언부에 아무런 가시성 키워드가 없는 형태)으로 떨어뜨려 놓기만 해도 실수로라도 구현 객체에 접근해서 의존성이 발생하는 것을 미리 막을 수 있다.

그 다음엔 Client와 Client와 구현 객체를 별도의 패키지로 분리해 둔다. 이 시기에는 다른 기준이 없기 때문에 구현객체와 Client만 떨어뜨려 놓으면 된다. 일단 Client는 사용자의 입장이고, 다른 모든 클래스들은 사용자가 사용할 대상에 해당된다. 따라서 Client 클래스만 별도의 패키지로 설정하도록 한다. 이제 남은 부분은 모두 MasterContract 클래스와 그 조립을 위한 클래스들이다. Java에서는 기본적으로 서브 패키지 개념이 없다. 사실 모든 패키지는 독립적이다. 하지만 아무래도 이것은 불편하다. 왜냐면 상위 패키지와 그 하위 패키지로 구분해 두면 어떤 패키지가 다른 패키지에 종속적인지, 반대로 이야기 하자면 어떤 패키지가 다른 어떤 패키지에 의존적인지를 알기가 수월할 수 있기 때문이다. 우리의 예제에서는 일단 오직 Client만 MasterContract 클래스를 사용하도록 되어 있기 때문에 패키지의 구조를 계층적으로 구성하도록 한다. 만일 패키지 간에 1:1의 의존 관계가 존재하지 않는다면 하위 패키지 형태로 분류하지 않는 편이 좋다. 아래는 client 패키지와 그 하위 패키지, 즉 master 패키지를 구분한 예이다.

<client 패키지와 master 패키지의 분리. master 패키지는 client 패키지에 의해서만 사용되고 있으므로 하위 패키지 형태로 구성했다.>


패키지 계층 구성

master 패키지 내부에 있는 클래스들은 아직도 정리가 안 된 느낌이다. Assembler나 MasterContract 클래스와 같이 좀 더 종합적인 구조를 가지고 있는 것과 Contract1, 2와 같이 하위 의존성을 위한 클래스나 interface들이 뒤섞여 있다. 이런 형태로 패키지가 구성되어 있으면 일단 소프트웨어의 구조를 분석하는데 불편하다. 어떤 클래스가 다른 어떤 클래스와 어떻게 의존하고 있는지를 알기가 어렵기 때문이다. 또 이런 형태는 배포의 기준점을 잡기가 힘들다. 예를 들어 Contract1에 해당하는 클래스들만 외부에 배포하고 싶다고 할 때 일일이 그와 연관된 클래스 파일들을 골라서 배포해야만 한다. 따라서 master 패키지 내의 소스 파일들도 하위 패키지들을 만들어 분리해 주는 편이 좋다.

master 패키지를 구분한다고 할 때 그 단위가 될 수 있는 기준을 먼저 살펴보도록 하자.

1. MasterContract를 구성하는데 필요한 클래스들.

2. 단일 계약 책임(Contract1, 2)과 그 구현 클래스들.


1번의 MasterContract를 구성하는데 필요한 클래스들은 MasterContract와 그 조립 책임을 담당하는 Assembler 클래스이다. 이들은 여전히 master 패키지에 존재할 필요가 있다. 2번에 해당하는 각각의 계약 책임 클래스들은 서로 분리할 필요가 있다. 첫째 이유는 master 패키지에 있을 클래스들과는 다른 패키지에 놓여져야 하기 때문이고, 두번째 이유는 Contract1과 Contract2는 서로간에 의존성이 없고, 따라서 서로를 모르는 편이 좋기 때문이다. 그리고 세번째로는 소스를 분석하는 입장에서 Contract1과 Contract2가 다른 패키지로 구분되어 있을 때 자연스럽게 체계를 파악할 수 있기 때문이다. 그렇다면 결국 패키지의 구분은 master 패키지의 하위 패키지로 contract1과 contract2를 위한 패키지를 도입하는 형태가 될 것이다. 아래는 이와 같은 형태로 분류된 패키지 구성이다.

<master 하위에 contract1과 contract2를 분류한 패키지 구성>


우선 contract1과 contract2에 대한 패키지를 master 패키지의 하위 패키지 형태로 구성한 이유는 master를 client 패키지의 하위로 분류한 이유와 같다. 만약 contract1이나 contract2가 외부의 다른 패키지와의 의존관계가 있다면 이렇게 종속적인 형태로 구성해서는 안될 것이다. contract1과 2를 구분하는 것은 이슈가 없을 정도로 합리적인 선택이다. 약간의 이슈가 될 수 있는 부분은 Contract1Factory나 Contract2Factory를 함께 패키지에 넣은 것이다. 이들을 master 패키지에 두는 것도 고려해 볼 수 있다. 하지만 패키지 구성을 조정해야 하는 상황이라면 생성 책임은 역시 계약 책임과 함께 존재하는 것이 나은 선택일 수 있다.

위의 패키지에서 주목할 부분은 Task 클래스와 Job 클래스이다. 이들은 각각 Contract1과 Contract2에 대한 구현 클래스이다. 앞서서 이야기 한 것처럼 Client 클래스가 이들 구현 객체는 몰라야 하는 상황이다. 따라서 Task 클래스와 Job 클래스의 가시성을 패키지 가시성 수준으로 떨어뜨려 두었다.


배포과정에서의 이슈

위의 구성에서 보면 client 패키지가 가진 의존성은 모두 하위 패키지 형태로 구성되어 있기 때문에 client 패키지를 배포하는데 전혀 문제가 없다. 또한 master 패키지를 배포한다고 해도 조립책임 및 하위 계약 책임, 그리고 그 구현 책임들을 포함한 패키지 계층이 배포될 수 있는 구조이므로 문제가 될 것이 없다. 한가지 생각해 볼 부분은 각 계약 책임의 구체 구현 객체들에 대한 처리이다.

첫번째 이슈는 배포 시 Task와 같은 구현 객체가 필요하지 않고, 따라서 Contract1Factory와 같은 생성 책임도 필요가 없는 경우이다. 즉 단지 계약 책임만을 가지고 가고 구현 객체는 필요 없을 경우에는 어떻게 하는 것이 좋을까? 잘 생각해 보면 Contract1Factory는 Task에 의존적이다. Contract1은 계약 책임만 가지고 있으므로 Task 클래스와 같은 구현 책임이나 Contract1Factory와 같은 생성 책임에 의존하지 않는다. 따라서 Contract1은 contract1 패키지 내에서 독립적이다. 만약 구현 객체가 필요 없는 경우를 대비한다면 생성 책임을 가진 Contract1Factory와 구현 책임을 가진 Task 클래스를 묶어 contract1의 하위 패키지로 만들어 구성할 수 있다.

<구현 책임과 생성책임을 별도의 하위 패키지로 구성한 경우>


위의 구성에서 만일 Contract1의 계약 책임만을 배포하겠다고 하면 하위 패키지를 제외하고 배포시키면 된다.

배포 과정에서의 또 한가지 이슈는 위의 구성에서 impl 패키지 하위의 구현 책임들을 모두 함께 배포하되, 배포를 받은 입장에서 새로운 구현 객체를 등록, 사용하려고 할 수 있다는 점이다. 이 경우는 Contract1Factory의 생성 책임이 추가되어야 한다는 의미이다. 이 때 만일 Contract1Factory가 컴파일 된 형태로 배포된다면 간단하게 Contract1Factory를 상속 받아서 추가된 구현 객체에 대한 생성 메소드를 추가하는 형태로 구현하면 된다. 대신 새롭게 구현된 구현 클래스나 생성 책임을 가진 클래스는 위의 패키지를 배포 받은 쪽 소스 상에 추가되어야 할 것이다.


단위 테스트(Unit Test) 이슈

위의 패키지 구성에서 또 한가지 고려해야 하는 부분은 단위 테스트이다. 우선 책임을 종류별로 분리하고 이를 각각 클래스화 시켰을 때 단위 테스트는 그렇지 않은 형태보다 훨씬 수월하다. 우선 조립 책임에 의해 의존 관계를 파악하기가 매우 쉽고, 필요하다면 계약 책임을 구현한 mock 객체나 spy 객체 등을 생성하여 재조립(reassemble)하기도 매우 편리하다. 또 각각의 책임이 명확하기 때문에 테스트 작성 계획을 세우거나 테스트 클래스들을 합리적으로 계층화 시키기에도 좋다.

단 한가지 이슈가 될 만한 부분은 구체적인 구현 객체의 가시성으로 인한 테스트 가능성이다. 구현 클래스들의 가시성을 패키지 가시성 수준으로 떨어뜨려 두었기 때문에, 테스트 클래스가 구현 클래스들과 같은 패키지에 있지 않으면 클래스가 존재하는지조자 알 수 없다. 이에 대한 해법은 이미 Spring 프레임워크 등에서 사용하고 있는 폴더 분리 방식을 통해 해결 할 수 있다. 이에 대해서는 아래 글을 참고하기 바란다.

package private class에 대한 unit test


Posted by 이세영2
,

한번 만들어진 소프트웨어는 거기에 그냥 머물지 않고 점차 발전한다. 더 많은 요구사항을 수용하면서 점점 더 복잡해진다. 처음에는 구현 - 계약 - 생성 책임을 가진 객체들을 분리시키는 것 만으로도 이미 충분하다고 생각할지도 모른다. Client가 겨우 몇 개의 계약 책임만을 다루는 형태로도 충분할 수 있다. 하지만 애초에 소프트웨어는 한계를 정해두고 성장하지 않는다. 단지 몇 개의 계약 책임만 가지고 있다가 어느 순간 그 복잡성이 폭발적으로 증가할 수도 있다. 지금 설명하려고 하는 조립 책임은 현대의 소프트웨어로 발전되는 과정에서도 가장 최근에 성립된 개념이라고 할 수 있다. 조립 책임은 소프트웨어의 규모가 충분히 커지지 않으면 그 필요성을 인지하기 힘들기 때문이다.

객체들은 서로 의존한다. 일단 계약 책임과 생성 책임을 분리함으로써 그런 의존 관계를 어느 정도 줄일 수 있다. 또한 Client가 간단히 나열할 수 있는 수의 계약 책임들만을 가지고 있다면 특별히 조립 책임에 관심을 가지지 않을 수도 있다. 하지만 계약의 종류가 점점 더 많아지게 되면 양상은 점차 복잡해진다. 대부분의 소프트웨어는 Client와 Contract interface만의 관계를 가지지 않는다. 계약들은 계약들간에도 관계를 가지게 된다. 예를 들어 A라는 계약을 Client가 사용하지만 A라는 계약은 B라는 계약에 의존할 수 있다. 그러면 A라는 계약을 사용하려면 B라는 계약을 구현한 객체에 대한 참조를 누군가가 만들어 주어야 한다. 이러한 참조의 연결은 계약의 개수가 증가하고, 계약 간의 의존 관계가 복잡해지면 함께 복잡해질 수 밖에 없다. 소프트웨어의 동작 시작 시점이나 동작 중에 Client 혹은 계약들 간에 필요한 의존성을 연결시켜주는 책임을 조립 책임이라고 한다.

만약 소프트웨어의 개발을 시작하는 시점에서 이와 같은 조립을 고려하지 않는다면 어떻게 될까? Client 클래스가 보통은 이 작업을 수행하게 될 것이다. Client 클래스 입장에서는 필요한 시기에 필요한 객체가 있기만 하면 되므로 생성의 위치나 다른 계약들의 관계에 대해서는 별로 신경쓰지 않을 수 있다. 이렇게 되면 시간이 갈수록 점점 더 사용해야 할 객체들이 늘어나면서 각 객체들간의 의존 관계에 의해서 조립 책임을 모아 정리하기가 어려워지게 된다. 그리고 어떤 계약은 다양한 구현 객체들을 가지고 있고, 이들 구현 객체는 사용자의 선택사항이나 요구에 따라서 동적으로 교체되어야 하는 경우도 있다. 이런 상황에서 객체를 잘못 교체하게 될 경우 소프트웨어의 동작은 예측하기 어려워진다. 따라서 여러 객체가 협력해야 하는 소프트웨어라면 초기부터 이런 조립 책임은 일찍 고려되어야만 한다.

그러면 조립 책임을 어떤 식으로 분리해야 하는지 알아보자. 이 조립 책임을 Client 클래스가 가지고 있었다고 가정해보자. 앞서 얘기했듯이 조립 책임에 대한 명확한 구분이 없을 경우 Client는 필요에 의해 언제든 자신이 사용할 객체를 생성하여 사용할 수 있었다. 따라서 조립 책임은 명확하게 나타나지 않고 코드 상에 분산되어 있을 가능성이 높다. 결국 Client가 직접 조립 책임을 지는 경우 Client는 본질적인 기능 뿐 아니라 조립 책임이라는 부수적인 책임도 지게 된다. 이는 단일 책임 원칙에도 어긋나고 코드는 쓸데없이 복잡해질 수 있다. 따라서 이 경우에도 별도의 클래스를 만들어서 조립 책임을 가지도록 하는 편이 좋다. 또한 Client가 개별 계약 구현 객체를 직접 사용할 수도 있고, 여러 계약 구현 객체들의 조합된 단일한 객체를 요구할 수도 있다. 전자의 경우 생성 책임과 조립 책임을 구분하기가 힘들기 때문에 예제에서는 조립 책임을 가진 클래스가 완성된(즉 잘 조립된) 객체를 제공해주는 것으로 가정한다.

interface Contract1{}              // 계약 책임1

class Task1 implements Contract1{} // 구현 책임1

interface Contract2{}              // 계약 책임2

class Job1 implements Contract2{}  // 구현 책임2

class Contract1Factory{            // 생성 책임1

    public static Contract1 createTask1(){ return new Task1(); }

}

class Contract2Factory{            // 생성 책임2

    public static Contract2 createJob1(){ return new Job1(); }

}

class MasterContract{              // 완성 객체

    Contract1 task;

    Contract2 job;

    public void setTask(Contract1 task) { // 계약1의 의존성 주입

        this.task = task;

    }

    public void setJob(Contract2 job) {   // 계약2의 의존성 주입

        this.job = job;

    }

}

class Assembler{ // 조립 책임

    Contract1 task;

    Contract2 job;

    MasterContract master;

    public MasterContract assemble(){ // 조립 메소드

        master = new MasterContract(); // 1

        reassemble(Contract1Factory.createTask1()); // 2

        reassemble(Contract2Factory.createJob1());  // 3

        return master;

    }

    public void reassemble(Contract1 task){ // 계약1의 재조립 메소드

        this.task = task;

        master.setTask(task);

    }

    public void reassemble(Contract2 job){ // 계약2의 재조립 메소드

        this.job = job;

        master.setJob(job);

    }

}

class Client{

    MasterContract master;

    public Client(){

        Assembler assembler = new Assembler();

        master = assembler.assemble();

    }

}

조립 책임을 보여주기 위해서는 적어도 두 종류의 계약이 필요하기 때문에 코드가 좀 길어졌다. 하지만 매우 단순한 코드이므로 천천히 살펴보도록 하자. 우선 두 종류의 계약 책임과 구현 책임을 가진 요소들을 선언했다. Contract1과 Contract2가 두가지 계약에 해당한다. Task는 Contract1의 계약을 구현하고 있고, Job은 Contract2의 계약을 구현하고 있다. 원래는 Task의 종류나 Job의 종류가 다양한 것을 가정하고 있지만 코드가 너무 길어질 것 같아서 제외하였다. 계약이 두 종류로 늘었으니 생성 책임도 둘로 늘어난다. 그리고 전체가 모두 조립된 객체를 내주도록 가정하였기 때문에 완성된 객체를 나타내는 MasterContract 클래스를 선언하였다. 이 MaterContract 클래스는 현재 일반 클래스이지만 만약 MasterContract 클래스 역시 다양한 변경이 가능하도록 하려면 역시 계약 방식(interface를 선언하고 이를 구현하는 방식)을 취해야 한다. 하지만 그런 관계에 대해서는 이미 다루었고 예제의 단순성을 위해 일반 클래스로 선언하였다. 

Assembler 클래스는 이 글에서 설명하고자 하는 조립 책임을 가진 클래스이다. 우선 가장 먼저 Assembler가 조립할 대상 객체들에 대한 참조 변수들을 모두 선언하고 있다. 이들 참조 변수들은 이후 유용하게 사용될 예정이다. assemble() 메소드는 완성 객체를 생성해내는 메소드이다. 이 메소드가 실행되면 MasterContract 객체가 완벽하게 조립되어 나오는 구조이다. 내부를 들여다보면 우선 MasterContract 객체를 생성한다(1). 이 시점에서는 객체는 생성되겠지만 MasterContract가 의존하는 다른 객체들은 아직 연결이 안된 상태이다. 이제 첫번째 계약 구현 객체를 연결한다(2). 이 과정에서 Assembler는 reassemble()이라는 메소드를 이용한다. reassemble()메소드는 메소드 오버로딩을 통해 총 두 개의 메소드로 구성되어 있는데, 각각 두 종류의 계약 구현 객체를 조립하는 메소드이다. 이들 메소드는 2와 3에 의해 각각 계약 구현 객체를 생성 받아 이를 MasterContract 객체에 설정 메소드를 통해 집어 넣는다. MasterContract 클래스는 setTask() 메소드 또는 setJob() 메소드와 같이 의존하는 객체를 외부에서 집어 넣을 수 있는 메소드를 제공한다. 이렇게 의존 객체를 설정하는 방식을 의존성 주입이라고 부른다. 계약을 구현한 구체 객체를 사용하는 객체 내부에서 직접 생성하지 않고 외부로부터 주입 받는 것을 디자인 패턴에서는 전략 패턴(Strategy Pattern)이라고 부른다. 이들 reassemble() 메소드들이 호출되면 전체 조립이 끝난다. 그런데 굳이 reassemble() 메소드를 추출하여 실행하게 된 이유는 무엇일까? 이는 Assembler 클래스 위쪽에서 MasterContract와 각 계약 구현 객체에 대한 참조 변수를 선언한 이유가 같다. Client 객체는 실행 도중 어떤 이유로든 MasterContract 객체가 의존하는 의존 객체들을 바꾸고 싶어 할 수 있다. 이런 경우 적절한 의존 객체를 생성하여 Assembler의 reassemble() 메소드를 호출하기만 하면 기존의 MasterContract 객체의 의존 객체가 변경될 것이다. 이런 방식으로 실시간으로 사용자의 요구가 바뀌거나 옵션에 따라 기능이 달리지는 등의 동작을 구현해 낼 수 있다.

이제 Client 클래스 내부에서는 Assembler를 통해 MasterContract 객체를 생성 받아 사용하기만 하면 된다.

그러면 이처럼 조립 책임을 가진 클래스를 별도로 유지하면 어떤 장점이 있는지를 살펴보자. 무엇보다도 우선 좋아지는 것은 Client 클래스의 코드이다. 조립 책임은 소프트웨어가 복잡해지면 복잡해질수록 더욱 복잡성이 증가한다. 최초에는 Client 코드에 별 문제가 없겠지만 소프트웨어의 확장성을 극대화하기 위해서는 Client 클래스의 코드가 단순해져야만 한다. 또 한가지 장점은 소프트웨어의 구조를 파악하기가 매우 수월해진다는 것이다. UML의 클래스 다이어그램과 같은 것이 없더라도 Assembler 클래스만 분석해보면 어떤 객체가 다른 어떤 객체와 의존관계에 있는지를 손쉽게 파악할 수 있다. 특히 Assembler 클래스는 오직 조립에 대한 책임만을 지고 있기 때문에 의존 관계를 파악하기에 가장 적절한 형태로 구현된다. 만약 조립 책임을 Client 클래스가 지고 있거나 여러 객체들이 분산해서 의존 객체를 생성, 조립하는 경우라면 서로의 의존 관계를 파악하기가 매우 어려울 것이다. 의존 관계를 파악하기 어려운 코드는 의존성 관리가 어렵기 때문에 조금만 수정이 가해지더라도 금새 관계가 복잡해지고 순환 참조와 같은 문제가 발생하게 된다. 조립 책임을 분리하는 것의 또 다른 장점은 새로운 객체에 대한 조립을 구현할 때 조립 과정을 바로 확인할 수 있기 때문에 의존 관계의 안정성 여부를 바로 파악할 수 있다는 점이다. 앞서 말했듯이 순환 참조는 의존 관계 분석이 어려울 때 발생한다. 만약 필요에 의해서 새로운 의존 관계를 만들고 싶을 때 바로 이를 조립 책임 객체에 구현해 보면 정상적인 의존 관계를 만들 수 있는지 여부를 바로 알 수가 있다.

그러면 이제 우리의 소프트웨어가 어떤 구조로 이루어져 있는지를 클래스 다이어그램으로 파악해 보자.

우선 Client 클래스는 MasterContract 객체와 Assembler 객체에 대한 의존 관계가 있다. 매우 단순하게도 단 두 개의 객체에 대한 의존관계만으로도 충분한 구조이다. 혹시 필요에 의해서 계약 객체를 변경해야 할 경우에는 Contract1Factory나 Contract2Factory에 접근하여 객체를 생성할 필요가 있을 수는 있다. 하지만 그렇다고 각 계약 객체나 계약 자체를 Client가 다루어야 할 일은 없다. 어떤 상황에서도 Client 코드는 개별 계약과는 상관 없다. 이를 통해 전체 소프트웨어의 유연성은 유지된다. MasterContract 클래스의 경우 역시 그 의존 관계를 명확히 알 수 있을 정도로 단순한 관계만을 유지하고 있다. 이를 통해 기능을 수행하는 객체들의 관계를 매우 명료하게 알 수 있다. Assembler 클래스는 이 클래스 다이어그램에서 가장 복잡한 요소이다. 다이어그램에 나타나진 않지만 Assembler는 Contract1과 Contract2, 그리고 MasterContract 객체에 대한 참조 변수를 가지고 있기 때문이다. 하지만 조립 책임만을 가진다는 점을 명확히 이해하고 각 객체들의 기능을 수행하는 작업을 함께 구현하지만 않는다면 코드는 전체적으로 매우 단순한 형태를 유지하게 될 것이다. 나머지 클래스들의 관계는 모두 이전의 예와 같기 때문에 별도로 설명하지는 않겠다.

조금만 욕심을 부리자면 Assembler가 모든 기능 객체들을 나열하여 가지고 있다는 점을 이용할 수도 있을 것이다. 즉, Visitor 패턴의 accept() 메소드를 Assembler 클래스가 구현하여 Visitor 객체를 받아 모든 구성 요소들을 돌면서 횡단 관심사들을 구현해 볼 수도 있겠다. 횡단 관심사를 Visitor 객체가 구현할 것이고, Assembler 클래스는 단지 Visitor 객체를 받아 호출만 해주는 것이므로 크게 부담이 될 것은 없다. 이에 대한 판단은 여러분들에게 맡기겠다.



Posted by 이세영2
,

이전 글에서 우리는 구현 책임을 가진 객체와 이를 사용하는 객체와의 의존 관계를 어떻게 더 좋은 관계로 바꾸는지를 알아 보았다. 구현 객체를 직접 사용하는 것은 위험하다. 구현 객체가 어떤 방식으로 수정될지 모르고, 만일 수정되면 사용하는 쪽의 코드 역시 수정되어야 하기 때문이다. 또한 다른 구현 객체를 사용하고자 할 경우에는 다른 구현 객체를 사용하는 코드를 중복으로 만들어 내야만 한다. 이렇게 중복이 많고 수정 가능성이 높은 코드가 좋은 품질의 소프트웨어를 만들어 낼 수는 없다. 그래서 중간에 계약 책임을 가진 Contract interface를 도입하였다. 이를 통해서 Client 객체는 직접 Task 객체에 의존하지 않도록 만들었다. 하지만 아직도 객체의 생성은 직접 생성자를 호출해야 하기 때문에 Task 객체에 대한 의존성이 완전하게 해결되지 못한 상태이다.

GoF의 디자인 패턴에는 이 문제를 해결할 수 있는 다수의 패턴을 포함하고 있다. GoF는 이들을 생성 패턴이라고 분류하였다. 생성 패턴은 GoF의 23가지 전체 패턴 중에서 5가지를 차지하고 있다. 단지 객체를 생성하는 것에 지나지 않는데도 이 같이 큰 비중을 차지한다는 것은 그만큼 객체 생성을 관리하는 것이 중요하다는 것을 의미한다. 이 글은 디자인 패턴을 소개하는 목적으로 쓰여진 것이 아니기 때문에 패턴에 대해서 자세하게 설명하지는 않겠지만, 이들 패턴이 추구하는 목표를 명확하게 하는데 주력해 보도록 하겠다. 여기에 가장 적합한 것이 Factory Method가 아닐까 한다.

디자인 패턴에는 동명의 디자인 패턴이 있다. 여기서 소개할 것은 디자인 패턴이 아니라 Factory Method라고 불리는 메소드, 즉 함수이다. "자바 API 디자인"이라는 책에서는 이 Factory Method를 활용하는 몇가지 예가 나와 있다. 우선 코드를 보면서 이야기 해보자.


class Base{}

class Subject extends Base{   

    // 기본형   

    private Subject(){}// 외부 생성 금지

    public static final Subject create(){ // Factory Method

        return new Subject();

    }

   

    // 구체적인 타입을 숨길 수 있다.

    public static final Base create2(){

        return new Subject();

    }

   

    // 인스턴스 캐싱(Flyweight)과 함께 적용이 가능하다.

    private static Subject instance = null;

    public static final Subject create3(){

        if(instance == null){

            synchronized(Subject.class){

                if(instance == null){

                    instance = new Subject();

                }

            }

        }

        return instance;

    }

   

    // 객체 생성 시점 이후 해야 할 일을 수행하기 용이하다.

    public static final Subject create4(){

        Subject s = new Subject();

        // 객체 생성 이후 초기화 작업

        // this 키워드를 활용하거나 다형성을 이용하는 위험한 작업을 수행

        return s;

    }

   

    // 객체 생성과 그 직후의 일을 동기화 할 수 있다.

    public static final synchronized Subject create5(){

        Subject s = new Subject();

        // 객체을 비롯하여 생성 이후 동작시까지 동기화가 필요한 작업을 수행할 수 있다.

        return s;

    }

}

위의 코드는 Factory Method를 활용하는 다양한 예들이다. 가장 기본적인 형태는 생성자를 private으로 바꾸고(외부에서 생성하는 것을 금지하기 위해서), create라는 자기 자신 타입을 리턴하는 정적 메소드(static)를 선언한 후, 그 안에서 자기 자신의 객체 하나를 생성하여 제공하는 것이다. 이 방법은 외부에서 자신의 생성자 메소드를 호출하는 것을 방지하고, 객체의 생성이 완료된 후부터 create() 메소드의 호출이 종료될 때까지, 즉 객체가 리턴될 때까지 외부에서 생성된 객체를 접근하지 못하도록 한다. 여기까지는 일반적인 생성자 메소드와 용도가 다르지 않다. 그 활용은 다음에 나오는 여러 create 메소드들이 보여주고 있다.

첫번째 용도는 구체적인 객체의 타입을 감추는 것이다. 위의 Subject라는 클래스는 Base 클래스를 상속 받는다. 즉, Subject라는 클래스는 다형성에 의해서 Base라는 타입도 가지게 된다. 유연성을 발휘하기 위해서는 구체적인 타입을 숨기는 것이 좋다는 점을 이미 알고 있다. create2() 메소드는 Subject라는 구체적인 객체를 생성하지만, 생성된 객체가 리턴될 때는 그 상위 타입인 Base 타입으로 리턴된다. 따라서 이 메소드를 통해 생성된 객체를 받는 쪽에서는 그 구체적인 타입을 알 수 없다.

두번째 용도는 인스턴스 캐싱이다. 어떤 객체는 소프트웨어 런타임 전체에서 단 한번만 생성되는 편이 좋을 수 있다. 그리고 그 객체가 이용될지 아닐지는 잘 모르는 경우가 있다. 이런 상황에서는 객체가 사용되는 시기까지 생성을 늦추는 편이 좋다. 즉, create3()과 같이 객체를 외부에서 요구하는 순간까지 객체의 생성을 미루다가, 일단 한번 객체가 생성된 이후에는 그 객체를 instance 참조 변수에 할당하여 두고, 이후 또다른 create3() 메소드 호출이 있으면 이미 생성된 객체를 내주도록 하는 것이다. 이러한 지연 로딩 방식을 인스턴스 캐싱이라고 부른다. 그리고 이렇게 늦게 객체를 생성하는 것을 디자인 패턴에서는 Flyweight 패턴이라고 부른다. 또한 위와 같이 객체 생성에 Flyweight 패턴을 적용한 것을 특별히 Singleton 패턴이라고도 부른다.

세번째 용도는 객체의 초기화 작업이다. 많은 객체들이 생성과 함께, 또는 생성 직후에 초기화 작업을 필요로 한다. 그래서 보통 생성자 내부에 여러 코드들을 집어 넣곤 하는데, 이것이 항상 안전하지는 않다. 어떤 상황에서는 메모리가 부족할 수도 있고, this 참조가 필요해서 사용해야 할 경우도 있다. 이런 작업은 객체가 생성된 이후에는 안전하지만 생성자 내부에서 사용하는데는 위험이 따른다. create4()와 같이 Factory Method를 활용하면 이 같은 문제를 해결할 수 있다. Factory Method는 객체의 생성을 안전하게 완료하고, 메소드가 종료되기 전까지의 시간을 벌어 준다. 만약 생성자 내부에서 해야 할 위험한 일들을 Factory Method의 객체 생성 이후에 수행한다면 객체에 대한 참조를 이용한다거나, 객체가 이용해야 할 다른 객체를 생성하는 등의 작업을 할 때 안전성을 확보할 수 있다.

네번째 용도는 생성의 동기화이다. create5() 메소드는 synchronized 키워드를 통해 Subject 클래스 전체에 대해 동기화 되어 있다. 만일 Subject가 멀티쓰레드 환경에서 동작하도록 설계 되어 있어야 한다면 객체의 생성 역시도 멀티쓰레드 환경에서 보호될 필요가 있을 것이다. 특히 생성자에서 여러 작업을 수행해야 하는 경우에는 더더욱 그렇다. 생성자는 synchronized 키워드를 붙일 수 없기 때문에 보다 안전하게 Factory Method를 이용할 수 있다.


자 우리는 이 중에서도 특히 객체들 간의 의존 관계를 끊는데 더욱 관심이 있다. 이와 관련해서는 구체적인 객체의 타입을 감추는 Factory Method의 특성을 이용할 수 있다. 하지만 이제와서 밝히는 것이지만 Subject 클래스 내부에서 Factory Method를 제공하면서 리턴 타입만 Base 타입으로 하는 형태는 사실 의미가 없다. 상위 타입을 리턴하는 Subject 클래스의 create2() 메소드를 사용하는 코드는 다음과 같다.

Base base = Subject.create2();

위 코드를 보면 알 수 있듯이 Subject 클래스라는 명칭(symbol)이 나온다. 명칭이 나오면 의존 관계가 성립된다. 이런 상황에서 위의 코드가 Subject라는 구체적인 객체에 대해 모른다고 말할 수 없다. 따라서 중요한 점은 Subject 클래스가 아니라 다른 클래스가 Subject에 대한 Factory Method를 제공해 주어야 한다는 점이다.

다시 Client - Contract - Task의 예로 넘어가보자. 우리는 Client와 Task간의 의존성을 끊고 싶다. 그럴려면 Factory Method를 통해 Task 객체를 생성하되, Contract 타입으로 리턴해 주도록 하면 된다. 그러면 Task에 대한 Factory Method를 어디에 구현해야 할까? 일단 Client 클래스는 탈락이다. Client와 Task간의 의존성을 끊는 것이 목적인데 Factory Method 내부에서는 Task의 생성자를 호출해야 하기 때문이다. Task 클래스도 탈락이다. Subject에서의 예와 같이 Task 클래스가 직접 Factory Method를 제공하게 되면 의존 관계가 없어지지 않기 때문이다. Contract interface도 역시 탈락이다. Contract는 interface이기 때문이다.(만약 계약 책임을 interface가 아니라 추상 클래스나 일반 클래스가 가진다면 Factory Method를 그 클래스에 구현하는 것이 가능하다. 이 방법은 "테스트 주도 개발(TDD)"이라는 책에 잘 나와 있으니 참고하기 바란다. 이 예에서는 책임을 분리하는 방법을 설명하는 것이 목적이므로 계약 책임과 생성 책임을 동시에 갖게 되는 방식을 사용하지는 않겠다.) 그러면 남는 방법은 별도의 클래스에게 생성 책임을 맡기는 것 뿐이다. 아래의 코드를 보자.

interface Contract{ // 계약책임

    public void method();

}

class Task1 implements Contract{ // 구현책임

    public void method(){}

}

class Task2 implements Contract{ // 구현책임

    public void method(){}

}

class ContractFactory{ // 생성 책임

    public static Contract createTask1(){ return new Task1(); }

    public static Contract createTask2(){ return new Task2(); }

}

class Client{

    Contract task = ContractFactory.createTask1();  // 1

    public void function(){

        task.method();

    }

}

위 코드에는 새롭게 CotractFactory 클래스가 추가되었다. 이 클래스에는 각종 Task 객체들을 생성할 수 있는 Factory Method들이 선언되어 있다. 잘 보면 Task1이나 Task2와 같은 객체를 생성하긴 하지만 Contract 타입으로 리턴하는 것을 알 수 있다. 따라서 ContractFactory를 통해 Task 객체들을 생성하면, 객체들을 리턴받은 쪽에서는 구체적인 타입을 알 수 없다. Client 클래스에서는 이전에 직접 Task1 또는 Task2의 생성자를 호출하는 대신 1의 코드와 같이 ContractFactory를 이용하여 Task 객체들을 생성하고 있다. 자세히 보면 이제 Client 클래스의 코드에는 어느 곳에서도 Task1이나 Task2를 찾아볼 수 없다. 즉 이제는 구체적인 구현 책임을 가진 객체들과의 의존이 완전히 끊어졌음을 알 수 있다.

현재의 상태를 클래스 다이어그램으로 나타내면 아래와 같다.

이전의 다이어그램과 비교해 보면 다음과 같은 부분이 달라졌다. 일단 ContractFactory라는 클래스가 추가되었다. Client 클래스는 ContractFactory를 통해 Contract 타입의 객체를 얻어가도록 변경되었다. 그리고 Client가 직접 Task1이나 Task2와 의존하던 의존성 연결이 사라졌다. 대신 ContractFactory가 의존성을 대신 가지게 되었다. 이제 전체적으로 보자면 Client 클래스는 Task1과 Task2에 대한 직접적인 의존 관계가 하나도 없다. 즉, Client 클래스는 구체적인 구현 책임을 가진 클래스와의 의존성이 없어진 것이다. 대신 Contract interface와 ContractFactory 클래스와의 의존성이 생겨났다. 일단 구체적인 구현 객체들과의 의존성을 없애겠다는 목표는 달성하긴 했지만, 구체적인 작업을 수행하는 것과는 별로 상관이 없어 보이는 Contract interface나 ContractFactory와 같은 부수적인 요소들과의 의존 관계가 생겨났다. 그러면 지금의 이 구조가 기존의 구조보다 더 낫다고 말할 수 있는 이유는 무엇일까?

가장 먼저 이야기 할 수 있는 것은 새로 발생한 의존은 구체적인 작업, 즉 기능과는 무관하다는 점이다. 기능은 요구사항의 변화에 민감하다. 요구사항이 변하면 기능도 변한다. 요구사항이 추가되면 기능도 추가 되어야 한다. 하지만 기능과 무관한 것은 그 요구사항도 잘 변하지 않는다. 간단하게 말해서 Client는 변화에 민감한 객체들과의 의존관계를 끊고 잘 변하지 않는 것들과의 의존관계를 맺게 된 것이다. 따라서 Client는 구체적인 구현 객체들의 변화에 의해 부수적인 변화가 발생할 가능성이 없어졌다. 또 다른 이점은 구체적인 구현 객체들의 수정도 자유로워졌다는 점이다. 구체적인 객체를 사용하는 곳이 많다는 것은 구체적인 객체를 수정할 경우, 그에 영향을 받아 수정되어야 할 객체들이 많다는 것이다. 이는 새로운 요구사항을 수용하는데 있어서 보수적이 되는 이유가 된다. 이런 형태로 구성된 소프트웨어는 사용자의 요구를 받아들이는데 보수적이 될 수 밖에 없다. 그리고 이런 소프트웨어는 사용자에게 인기가 없다. 또 한가지 이득은 계약에 의해 발생되는 것이다. 구체적인 구현 객체들은 계약을 무조건 따라야 한다. 따라서 계약을 수정할 경우 구체적인 객체들은 어쩔 수 없이 함께 수정되어야 한다. 이것은 interface를 수정하기가 어려워지는 이유이다. 하지만 반대로 이것이 이득일 경우가 있다. 즉, interface를 수정해야 할 중요한 요구사항이 들어왔다고 가정해보자. 그리고 잘 고려해 보니 유사한 요구사항들도 새로운 요구사항의 일부를 수용해야 할 것으로 판단이 되었다고 가정해 보자. 이 경우 기존에는 어떤 요구사항들이 어떤 구현 객체에 구현되어 있는지를 찾아보고 수정해야 한다. 그리고 이 수정에 따라서 구현 객체를 사용하는 Client 쪽도 함께 수정해 주어야 한다. 구체적인 구현 객체의 수만큼 말이다. 하지만 계약 책임을 지는 interface를 이용할 경우, interface를 수정하게 되면 자연스레 구체적인 객체에 대한 수정 사항이 발생한다. 구체적인 객체들은 interface를 제대로 구현하지 않으면 컴파일 자체가 안되기 때문이다. 따라서 수정하려는 입장에서는 문제가 매우 명확하게 보인다. 또한 Client 객체의 경우에는 계약 책임을 가진 interface를 이용하면 단지 interface에 의존하는 코드만 고치면 된다. interface를 이용하지 않았다면 구현 객체와 의존하는 코드들을 모두 고쳐야 했을 것이다. 마지막으로 ContractFactory를 이용하는 경우에는 생성이라는 책임이 명확하므로 생성에 대한 변경이 이루어졌을 때 그 책임이 어디 있는지 명확하고, 그에 대한 변경이 어디서 이루어져야 하는지를 정확하게 알 수 있다. 객체 생성은 어느 곳에서나 이루어질 수 있다. 참조 변수를 선언할 때도 생성할 수 있고, 생성자에서도 생성할 수 있으며 메소드 내에서도 생성할 수 있다. 이러한 자유도는 "관리" 측면에서는 매우 불편하다. 예를 들어 기존에는 객체에 대한 생성 이후 즉각 정상적인 동작이 가능했었는데, 어느 순간 초기화 작업이 필요해졌다고 가정하자. 그러면 이제 객체를 생성하는 코드들을 일일이 찾아가서 수정해 주어야 한다. 이것은 중복코드를 발생시킬 뿐더러 버그가 발생할 가능성도 높이게 된다. 만약 위와 같이 Factory Method를 구현한 별도의 생성 클래스를 두게 되면 생성 후 초기화 작업이 추가되는 경우에도 Factory Method에 단 한번만 초기화 작업을 넣어주면 모든 수정이 완료된다. 새로운 구현 객체가 추가되거나 기존의 객체가 사라진 경우 일단 ContractFactory의 Factory Method만 추가/삭제 해주고 나면 그 이후에는 다른 코드들이 모두 에러 처리되고 컴파일 불가능 해질 것이기 때문에 손쉽게 문제를 찾아 고칠 수 있게 된다.








Posted by 이세영2
,