한번 만들어진 소프트웨어는 거기에 그냥 머물지 않고 점차 발전한다. 더 많은 요구사항을 수용하면서 점점 더 복잡해진다. 처음에는 구현 - 계약 - 생성 책임을 가진 객체들을 분리시키는 것 만으로도 이미 충분하다고 생각할지도 모른다. 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
,