이전 글에서 우리는 구현 책임을 가진 객체와 이를 사용하는 객체와의 의존 관계를 어떻게 더 좋은 관계로 바꾸는지를 알아 보았다. 구현 객체를 직접 사용하는 것은 위험하다. 구현 객체가 어떤 방식으로 수정될지 모르고, 만일 수정되면 사용하는 쪽의 코드 역시 수정되어야 하기 때문이다. 또한 다른 구현 객체를 사용하고자 할 경우에는 다른 구현 객체를 사용하는 코드를 중복으로 만들어 내야만 한다. 이렇게 중복이 많고 수정 가능성이 높은 코드가 좋은 품질의 소프트웨어를 만들어 낼 수는 없다. 그래서 중간에 계약 책임을 가진 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
,