다시 앞서의 예로 돌아가보자.

class Task{

    public void method(){}

}


class Client{

    Task task = new Task();    // 1

    public void work(){

        task.method();         // 2

    }

}

책임의 종류를 따르자면 Task 클래스는 구현의 책임을 가지고 있다. 현재 명시적인 기능을 구현하지는 않았지만, Client 입장에서 Task는 어떤 기능을 수행하길 바라는 대상이다. Client 클래스는 1에 의해서 Task 객체를 생성하는 책임을 가지고 있다. 그러면서 2와 같이 Task 객체의 메소드를 이용함으로써 의존하고 있다.

만일 만들고자 하는 소프트웨어가 간단히 구구단 정도 출력하는 일이라면 지금의 구조로도 충분히 훌륭한 소프트웨어를 작성할 수 있다. Task 객체에 구구단 출력 기능을 구현하고, 구현된 구구단 출력 메소드를 Client 객체가 호출해 주면 되기 때문이다. 그리고 위의 예에서는 다른 어떤 보호 장치도 없이 Task 객체에 Client가 직접 의존하고 있다. 이것은 마치 Task 객체가 절대 사라지지도, 변경되지도 않을 것임을 확신하고 있는 것이나 다름 없다. 아무래도 Task 객체는 Client 객체와 매우 밀접한 관계에 있는 듯 하다. 그렇지 않고서야 서로 어떤 안전장치도 없이 의존할 수 있겠는가?

이와 같은 상황을 조금 더 이해하기 쉽게 하기 위해서 현실 세계의 예를 하나 도입해 보도록 하겠다. 잠시 스스로가 작은 음식점 주인이 되었다고 가정해보자. 적은 자본을 투자한 관계로 우선 혼자서 가게를 내기로 한다. 혼자 가게를 낸 상황이라면 가게의 모든 일을 주인인 내가 다 해야 하는 상황이다. 위의 예에서 보면 Client가 Task 객체에 의존하지 않고 혼자서 일을 모두 처리하는 상황이다. 시간이 조금 지나서 가게를 찾는 사람도 늘고, 아무리 음식점이라도 전문성이 중요하기 때문에 나보다 더 나은 주방장을 구해 일을 해보기로 한다. 그러면 주인인 나는 고용된 주방장에게 일을 맡기는 형태로 가게를 운영하게 될 것이다. 위의 예에서 Task 객체를 생성하고 메소드를 호출하는 과정과 유사하다. 하지만 뭔가 좀 위태로워 보인다. 주방장을 고용하는데 있어서 어떠한 안전장치도 없다. 음식점의 예에서 보면 취직한 주방장이 가게를 갑작스레 그만 둔다거나, 성실성이 부족하여 종종 가게에 나타나지 않는다거나, 음식 솜씨가 좋다고 해서 고용했는데 맛이 형편 없다거나 하는 위험한 상황이 있을 수도 있는데 말이다. 이런 상황에서 주인인 내가 안전하게 주방장을 고용할 수 있는 방법은 무엇일까? 바로 주방장과 계약을 하는 것이다. 가게 주인인 나는 주방장을 고용하기 위한 일련의 계약서를 작성해 둔다. 출퇴근 시간, 월급여, 경력 사항, 업무 내역과 같이 이슈가 될만한 사항들을 고용의 조건으로 만들어 두는 것이다. 이렇게 되면 좋은 점이 두가지 생긴다. 하나는 고용 조건에 합의한 주방장과 업무 시 직접 주방장이 할 수 있는 일을 물어보고 일하지 않아도 된다. 단지 계약서에 나열된 업무를 중심으로 일을 시키기만 해도 계약에 합의한 주방장은 당연히 그 일을 할 수 있을 것이다. 또 만일 기존의 주방장이 일을 그만두고 다른 주방장을 고용하게 되더라도 계약서에 합의 하기만 한다면 실제 업무 지시 방식을 바꾸지 않아도 새로운 주방장과 일하는데 문제가 없을 것이다.

객체지향으로 넘어와서 음식점 주인과 주방장의 관계 사이에 있던 "계약서"라는 개념이 무엇일까? 바로 interface이다. 이와 관련해서는 정확한 개념이 필요하기 때문에 잠시 정확한 개념을 설명하고자 한다.
앞서 지휘자와 개별 연주자들 간의 관계에서 "연주자"라는 개념을 도입했었다. 연주자는 개별 연주자의 차이점을 배제하고 공통점만 취한 추상적인 개념이다. 이와 상응하는 개념이 객체지향 언어에서의 interface이다. 그리고 가게 주인과 주방장 사이에 "계약서" 개념을 도입했는데 이 역시 interface를 염두해 둔 개념이다. 이들 두 예와 interface는 어떤 관계가 있을까? 우선 "연주자" 개념 역시 "계약서" 개념과 동일하다는 점을 설명할 필요가 있다. 지휘자 - 개별 연주자 간에는 직접적인 계약 관계가 필요하지는 않지만, 적어도 연주가 성공할 수 있으려면 "연주자" 개념은 강제성을 띄어야 한다. 연주하세요라고 말했을 때 연주를 시작하고, 연주를 멈추세요라고 말했을 때 멈추지 않으면 연주는 아름답게 이뤄지지 않을 것이다. 그런 강제성을 직접적으로 명시해야 할 필요가 있다면 그것이 "계약서"가 되는 것이다. 자 그럼 interface와 계약서의 관계를 이야기해 보자.
interface는 추상 공개 메소드(abstract public method)들의 집합으로 이루어진 추상 클래스(abstract class)이다. 만약 Java처럼 interface라는 명시적인 대상이 없는 언어를 다루고 있다면 추상 공개 메소드만 선언된 추상 클래스를 떠올리면 된다. 추상 클래스란 인스턴스화(객체화)가 불가능한 클래스다. interface는 추상 클래스이므로 interface 역시 객체화시킬 수 없다. 추상 공개 메소드는 구현부가 없는 메소드이다. 구현부가 없으므로 당연히 동작시킬 수 없다. 자기 자신은 구현부가 없지만 만일 interface를 상속한 경우(Java에서는 이를 상속이라 부르지 않고 구현(implements)이라고 부른다. 앞으로는 Java의 개념에 따라서 구현이라고 부르도록 하겠다.) 추상 공개 메소드의 구현부를 구현해야 할 의무가 생긴다. 추상 공개 메소드를 구현하고 싶지 않은 하위 클래스는 추상 클래스여야만 한다.
이러한 개념들에 비추어 보면, interface는 이를를 구현하는 하위 클래스들에게 정해진 공개 메소드를 꼭 갖추도록 강제하는 역할을 한다는 점을 알 수 있다. interface 스스로는 직접적인 구현을 제공하지 않는다.
이런 interface의 특징은 두가지 특성을 가져다 준다. 우선 interface를 구현하는 구체적인 객체에게는 강제성을 주면서도 자율성을 보장한다. 강제성은 interface가 선언한 추상 공개 메소드를 꼭 구현하도록 한다는 점에서 그렇다. 대신 interface는 어떤 형태로도 구현을 가이드 하지 않기 때문에 구체적인 객체는 어떤 방식으로든 자유롭게 구현할 수 있다. 이는 연주자가 연주하세요와 멈추세요에 반응해야 하는 것은 강제이지만, 개별 연주자들의 실제 연주 내용(구현)은 서로의 악기에 맞게 자율적으로 할 수 있다는 것과 같다. 이를 통해서 얻을 수 있는 이점은 개별 연주자의 범위를 넓힐 수 있다는 것이다. 단지 연주하세요와 멈추세요에만 응답할 수 있다면 서양의 악기든 동양의 악기든 상관하지 않고 모든 악기 연주자들을 구현해도 좋다. 이는 다양한 요구사항을 반영할 수 있는 역할을 한다. interface가 주는 또 하나의 특성은 구체적인 구현 객체들을 모르더라도 interface를 구현한 객체들이라면 모두 사용할 수 있다는 것이다. interface를 구현한 모든 객체들은 interface가 제공하는 공개 메소드를 구현해야만 한다. 이것은 강제적이다. 따라서 사용하는 측에서는 interface만 보고 객체를 다루더라도 전혀 지장이 없다. 사용하는 측에서 매우 다양해질 수 있는 구체적인 구현 객체를 모르고도 그 객체들을 다룰 수 있다는 점은 유연성에 매우 큰 이득이다. 

계약 책임의 도입

이제 우리의 예제에 계약 책임을 도입해볼 차례다. 계약 책임은 interface가 가지고 있다. 따라서 interface를 선언할 필요가 있다. 그리고 구현 책임을 가진 Task 객체는 그 interface를 구현해야 한다. 마지막으로 사용자인 Client 객체는 Task 객체를 직접 사용하지 않고 interface를 이용해서 Task 객체를 다루어야 한다. 이와 같은 형태로 코드를 수정하면 아래와 같아진다.

interface Contract{

    public void method();

}

class Task implements Contract{

    public void method(){}

}

class Client{

    Contract task = new Task();    // 1

    public void function(){

        task.method();             // 2

    }

}


interface가 계약이라는 점을 명시하기 위해서 interface의 명칭(symbol)은 Contract라고 붙였다. 그리고 이 계약서에는 method()라는 공개 추상 메소드를 구현하도록 명시되어 있다. 따라서 Contract interface를 구현하는 클래스는 항상 method()도 구현해 주어야 한다. 위의 예제에서는 Task 클래스가 Contract interface를 구현하고 있다. 따라서 Task 클래스는 method()를 역시 구현하고 있다. Task 객체를 사용하고자 하는 클래스인 Client 클래스는 "계약"에 따라서 Task를 사용해야 한다. 따라서 기존에는 Task 참조 변수를 선언하였으나 이제는 Contract 참조 변수로 대체되었다.(1번 라인) 하지만 실제 사용할 객체는 Task 타입이므로 Task 객체를 생성하여 Contract 참조 변수로 참조하도록 되어 있다. 2번 라인의 메소드 호출은 일면 이전의 코드와 같아 보인다. 하지만 task 참조 변수는 그 타입이 Contract로 변경되어 있다. 따라서 2번 라인의 호출은 직접 Task 객체의 메소드를 호출하는 것이 아니라 Contract 타입의 method()를 호출하는 것이다. 이와 같은 관계를 이해하기 위해서 다음의 예제도 한번 살펴보자.

interface Contract{

    public void method();

}

class Task2 implements Contract{

    public void method(){ /* something else */ }

}

class Client{

    Contract task = new Task2();

    public void function(){

        task.method();            // 메소드 호출

    }

}


위의 예제에서는 Task 클래스 대신 Task2 클래스를 선언하고 사용하고 있다. Task2 클래스 역시 Contract interface를 구현하고 있으므로 method()도 동일하게 구현하고 있다. 하지만 실제 동작은 기존의 Task 클래스와는 다르게 선언했다고 가정해 보자.
이제 Task2를 사용하기 위해서 Client 클래스에서는 Task2 객체를 생성하여 Contract 타입의 참조 변수인 task에 할당한다. 그리고 메소드 호출 부분으로 넘어가보자. 이전의 예에서와는 다르게 task 참조변수에는 Task2 객체가 참조되어 있다. 따라서 메소드 호출 부분에서 메소드를 호출한 경우 Task2 객체의 메소드가 호출된다. 즉, 이전의 예에서 할당된 객체는 Task 객체였고, 이번읜 Task2 객체이므로 실제 호출되는 메소드는 달라지지만 둘 다 같은 형태로 호출이 된다. 이는 참조를 직접 Task 객체나 Task2 객체로 하지 않고 Contract 타입으로 참조했기 때문에 가능한 일이다.
이를 통해 얻은 것은 무엇일까? 만일 필요에 의해서 Task 객체나 Task2 객체 또는 또 다른 Contract interface를 구현한 객체로 바꾸게 되더라도 function() 메소드가 수정될 필요가 없어졌다. 즉 우리가 바라던 유연성이 확보된 것이다. Contract 타입의 객체는 그 "계약"만 제대로 지킨다면 무수히 늘어날 수 있으므로 무수히 많은 요구사항을 반영할 수 있는 구조가 된 것이다. 

클래스 다이어그램을 통한 이해와 여전한 문제점

그러면 이제껏 진행했던 내용을 UML의 클래스 다이어그램을 통해 이해해보자. 클래스 다이어그램은 실제 동작시 객체간의 유기적인 협력 관계를 표현하기에는 적절하지 않지만(유기적인 협력 관계는 UML의 시퀀스 다이어그램을 이용하는 편이 좋다), 우리가 하고 있는 작업, 즉 의존 관계에 대한 관리를 하는데는 매우 유용한 다이어그램이다. 클래스 다이어그램을 단순화해서 보면 클래스(+ interface)와 관계(association, dependency, generalization)로 나누어 볼 수 있다. 관계는 연관, 의존, 일반화 등으로 나누어질 수 있지만, 단순하게 보자면 어떤 클래스와 다른 클래스가 관계로 연결되어 있다면 그 둘 중 적어도 하나에는 상대 클래스가 코드 상에 등장한다는 것을 의미한다. 그래서 우리가 하는 작업을 통해서 다른 클래스와의 관계가 잘 정리되었는지를 확인하는데 매우 유용하다.

위의 다이어그램은 이 글에서 작성한 코드를 클래스 다이어그램으로 나타낸 것이다. Client의 입장에서는 여러 개별 Task들을 직접 다루기보다는 Contract interface를 도입해서 간접적으로 다루도록 만들었다. 여기서 가장 좋은 것은 Client가 Task들과 아무런 관계를 가지지 않는 것이다. 하지만 위 다이어그램에서도 보듯이 Client는 Task들의 생성자 메소드를 호출함으로써 생성(create)을 수행하고 있다. Task들을 전혀 모르고도 사용할 수 있어야만 의존관계를 완벽하게 끊어낼 수 있다. 더 나은 구조를 만들어내기 위해서는 별도의 작업이 필요하다.


Posted by 이세영2
,

우리의 목표는 객체지향 언어를 이용해서 유연성 있는 소프트웨어를 개발하는 것이다. 소프트웨어가 유연하다는 것은 다양한 요구사항 변화에도 대처가 가능하다는 의미이다. 요구사항 변화는 소프트웨어에 추가, 제거, 변경과 같은 수정을 가져온다. 즉, 유연성은 소프트웨어가 얼마나 손쉽게 수정될 수 있느냐에 달려 있다. 이미 요구사항의 변화를 쉽게 반영할 수 있는 객체지향의 4대 특성에 대해서 이미 살펴봤다. 그리고 객체지향의 4대 특성이 반영된 일반적인 구조에 대해서도 살펴봤다. 지휘자 - 연주자 - 개별 연주자로 연결되는 구조를 통해서 객체지향 언어가 어떻게 다양한 요구사항을 동일하게 취급하는지 살펴 보았다. 하지만 이제 객체지향 언어를 이용하여 완성된 형태의 유연성을 제공하기 위해 알아 두어야 할 요소에 대해 설명할 시간이다.


의존(Dependency)과 책임(Responsibility)

의존과 책임은 객체지향 4대 특성을 통해 확보한 유연성을 더욱 강화시키기 위해 필수적으로 알아 두어야 할 요소이다. 그리고 의존과 책임은 별도로 설명하기 곤란할 정도로 밀접한 관계가 있는 개념들이다. 이 개념들을 실제 코드를 통해 이해해 보도록 하자.

class Task{

    public void method(){}

}


class Client{

    Task task = new Task();    // 1

    public void work(){

        task.method();         // 2

    }

} 

위의 코드에는 두 개의 클래스가 있다. 하나는 Task 클래스이고, 다른 하나는 Task 클래스를 사용하는 Client 클래스이다. Client 클래스에서는 1 처럼 Task 객체를 "생성"하고 있다. 그리고 2번 처럼 그 객체의 메소드를 호출하고 있다. 메소드 호출을 객체지향 용어로는 "메시징"이라고 부른다. 그리고 메소드 호출을 통해 다른 객체를 "사용하는" 것을 객체지향 용어로는 위임(Delegation)이라고 한다. 본래 Client 객체가 해야 할 일이지만 (다양한 이유로 인하여) Task 객체에게 맡긴다는 의미이다. 이 전체를 한마디로 표현하자면 Client 객체가 Task 객체를 생성하고, 일부 기능을 위임한 것이다. 이 구조에서 구체적으로 어떤 기능을 위임했는지는 중요하지 않다. 다만 기능의 종류와 상관 없이 일반적으로 만들어지는 구조라는 것이 중요하다.

또한 Client와 Task 객체간의 관계를 의존관계라고 부른다. 좀 더 정확하게 말하면 Client 객체가 Task 객체에 의존하는 것이다. 위의 코드에서 의존은 두 번 발생한다. 우선 Task 객체를 생성하는 1번 코드에 의해 의존한다. 또 2번의 method() 호출을 통해서도 의존한다. A가 B에 의존한다는 말은 간단하게 이야기 하면 A의 코드 상에 B의 요소가 등장한다는 말이다. 1에서는 Task 객체의 타입이 등장하고, 생성자 메소드 호출이 등장한다. 2에서는 method()라는 Task 객체의 메소드 호출이 등장한다. 이것이 의존으로 불리는 이유는 다른 객체의 요소가 등장하는 순간부터 그 객체가 없어지면 안되기 때문이다. 만일 의존 대상인 Task 클래스가 사라지면 Client 객체는 바로 컴파일이 불가능해지고 동작할 수 없는 상태가 된다.

이 의존 관계가 내포하는 문제를 조금 더 깊이 다뤄보자. 위의 코드처럼 Client가 Task에 의존하는 상태, 즉 Client 클래스 코드에 Task 클래스 코드가 등장하는 상황에서는 Task 클래스의 변경이 Client 코드에 영향을 미치게 된다. 앞서 Task 클래스가 사라지게 되는 경우도 이에 해당한다. 만약 의미를 명확하게 하기 위해서 Task 클래스의 명칭을 바꾼다고 하자. 그러면 Client 코드에서도 Task의 변경된 명칭을 사용해야만 한다. Task의 method()라는 이름의 메소드 역시 의미를 명확하게 하려는 이유로 변경될 수 있다. 그러면 2번 코드 처럼 해당 메소드를 호출하는 코드 역시 변경해 주어야 한다. 만약 Task의 기본 생성자 메소드를 대신하여 인자를 받는 생성자 메소드가 추가되었거나, 기본 생성자 메소드의 가시성을 낮춰서 외부에서는 Task 객체를 생성하지 못하도록 만들었다면 1번의 코드도 그에 맞춰 수정되어야 한다. 위의 예에서는 Task 클래스에 의존하는 곳이 단 두 곳 뿐이지만, 상황에 따라서는 코드들을 모두 변경하기 버거울 정도로 많은 곳에서 의존이 발생할 수도 있다.

이것은 소프트웨어 개발자들이 바라지 않는 일이다. 우리가 Task를 수정해야겠다고 느끼는 시기는 Task를 수정해야 할 이유가 있을 때이다. Task라는 클래스명의 의미가 모호하다고 생각될 경우, method()라는 메소드 이름이 모호하다고 생각될 경우, Task 객체가 해야 할 일이 변경될 경우, 새로운 메소드를 추가하거나 기존 메소드를 제거할 경우 등이 Task 클래스를 수정하게 되는 상황이다. Task 클래스를 수정하는 이유는 이를 사용하는 Client 클래스와는 전혀 상관 없는 일이다. 하지만 위의 코드에서 보듯이 Client의 코드가 Task의 코드에 의존하고 있을 경우 Client의 코드가 수정되는 것은 자명하다. 즉, 수정을 해야할 이유는 Task 코드에서 발생했는데 엉뚱하게 이를 사용하는 Client 코드가 수정되어야 하는 상황이 된 것이다. 

만약 사용되는 객체(또는 요소)로 인하여 사용하는 객체가 수정되는 이와 같은 일이 발생하지 않도록 하려면 어떻게 해야 할까? 단 한가지 답만 존재한다면 사용되는 대상이 수정될 가능성을 낮추는 것이다. 그렇다면 이 "수정될 가능성"을 어떻게 판단할 수 있을까? 이 답은 로버트 C. 마틴이 밝힌 객체지향 설계의 5원칙(SOLID)에 들어 있다. 


책임(Responsibility)의 정의

객체지향 설계의 5 원칙(SOLID) 중에서 첫번째 원칙은 단일 책임의 원칙이다. 단일 책임의 원칙(Single Responsibility Principle)은 객체지향의 구성요소가 단 하나의 책임만을 가진다는 원칙이다. 그러면 책임(Responsibility)이란 무엇인가? 단일 책임 원칙에서는 책임을 다음과 같이 정의 한다.


"변경하려는 이유"


즉 우리가 찾던 "사용하려는 대상이 수정될 가능성"을 "책임"이라고 부를 수 있다는 것이다. 어떤 객체가 다른 객체에 의존하는 이유는 무엇인가? 의존의 대상이 되는 객체에 어떤 "책임"이 부여 되어 있고, 그 책임을 이용하고 싶기 때문이다. 그래서 책임에 대해 덧붙이자면 기능이라고 생각할 수도 있다. 이용해야 할 이유, 즉 기능이 있어야 이용하고 싶기 때문이다. 그리고 기능이 있어야 그 기능을 수정하려는 이유도 생긴다. 아무 기능도 없다면 의존할 필요도 없고, 수정할 필요도 없다. 


그러면 실제 구현 속에서 책임을 찾아보자.

구현의 대상이 필드라면 필드의 가시성, 타입 및 명칭이 변경의 대상이 된다. 또한 필드의 초기값도 변경의 대상이 될 수 있다. 필드 중에서도 객체 참조의 경우에는 참조할 객체의 할당 방법도 변경의 대상이 된다. 예를 들어 필드의 선언과 함께 객체를 생성해서 할당한 경우라면 참조할 객체의 종류 역시 변경될 수 있다.

메소드는 메소드의 시그니쳐(signature)와 메소드 내부 구현이 변경될 수 있다. 메소드의 가시성, 리턴 타입, 메소드 명칭, 파라메터, 메소드의 내부가 변경의 대상이다.

클래스는 타입, 필드, 메소드를 가지고 있다. 따라서 클래스는 (가독성에 따라) 자신의 타입명이 변경될 수도 있고, 다른 클래스를 상속 받거나 interface를 구현하게 됨으로써 자신이 가질 수 있는 타입이 변경될 수도 있다. 또 클래스는 필드가 변경 될 수도, 메소드가 변경될 수도 있다. 

구현의 대상이 interface라면 interface에 선언된 메소드가 변경의 대상이 될 수 있다.


단일 책임 원칙에서 정의하는 책임을 그대로 따른다면 위에서 열거한 변경의 대상은 모두 다 책임이 된다. 이들을 모두 개별적인 책임으로 정의하고 각 책임에 맞는 대응책을 모색하기에는 너무 많은 양이다. 별로 중요하지 않은 책임을 드러내놓고 논의하는 것은 시간 낭비에 불과하다. 적어도 객체지향을 통한 설계나 구현을 위해 의미 있는 정보를 제공해 줄 수 있는 수준이 되어야 한다. 또 너무 유사한 것들을 일일이 나열하면서 설명하는 것도 의미가 없는 일이다. 비슷한 것들은 하나의 개념으로 통합 시킬 필요가 있다. 그래서 중요도, 유사성, 실용성 등을 고려하여 책임을 적절히 분류해 보고자 한다.

책임을 종류별로 분류하고자 하는 목적을 이야기 할 필요가 있다. 다시 말하지만 우리는 객체지향 언어를 통해서 유연한 소프트웨어를 만들고자 한다. 유연한 소프트웨어를 만들기 위해서 추상화된 대상을 통해서 다양한 요구사항의 구체적인 차이점을 감추도록 했다. 이것만으로도 훌륭하지만 구성요소간의 의존성이 여전히 문제로 남아 있다. 다른 객체를 이용하는 객체는 이용되는 객체의 변경에 영향을 받는다. 이를 해결하는 방법은 객체들이 수정될 이유가 적어지게 만드는 것이다. 객체들이 수정될 이유가 바로 책임이다. 만약 어떤 객체가 너무 많은 책임을 가지고 있다면, 이 책임을 분할할 필요가 있다. 책임이 적어진다는 것은 변경할 이유가 적어진다는 것을 의미한다. 의존하는 대상이 변경될 이유가 적어지면 사용하는 쪽의 코드가 수정될 가능성도 함께 적어진다.

그래도 한가지 의문이 더 남는다. 결국 많은 책임을 분할하여 여러 객체나 요소로 분산하자는 얘기인데, 그렇게 되면 결국 의존이 여러 대상으로 분산될 뿐, 전체적인 의존의 복잡성은 그대로 유지되는 것이 아니냐는 것이다. 물론 상황에 따라서는 의존성이 줄어들기보다 객체나 요소가 늘어나는 이유로 인하여 관계가 더 복잡해 보일 수도 있다. 하지만 책임을 나눌 경우 크게 두가지에서 이득을 얻을 수 있다. 

첫째로 책임이 적절하게 여러 대상으로 분할된 경우가 여러 책임이 하나의 대상에 밀집해 있는 경우보다 덜 수정된다는 점이다. 만일 여러 책임이 하나의 클래스에 몰려 있다고 가정해 보자. 이 경우에는 종종 여러 책임별로 메소드 단위, 속성 단위로 잘 구분되지 않는 경우가 많이 있다. 즉, 하나의 메소드가 두가지 책임을 위해서 사용될 경우도 있고, 두가지 이상의 책임이 하나의 메소드 흐름 속에 뒤죽박죽 섞여 있을 수도 있다. 책임을 면밀히 생각해 보지 않았기 때문에 이들이 섞여 있어도 그렇다는 것을 잘 모르는 경우가 많다. 이 때 어떤 이유로 대상을 수정하게 된다면 어떤 일이 벌어질까? 하나의 목적으로 수정을 시작했는데, 그 목적과는 관계없는 코드들이 중간중간에 섞여 있을 것이다. 이는 수정에 집중하기 어려운 상황을 만든다. 그리고 하나의 책임만을 수정했음에도 불구하고 다른 책임을 잘못 건드려서 다른 목적에도 맞지 않게 수정될 가능성도 있다. 이것은 단일 책임 원칙에 의해 책임이 잘 분할된 여러 요소들을 개별적으로 수정할 때에 비해 매우 비효율적일 수 밖에 없다.

둘째로 책임이 분할되어 있을 경우 중복 요소의 확인이 쉽고, 이를 통해 한차원 높은 추상화를 발휘할 수 있다는 점이다. 책임을 분할해 보면 책임이 다른 객체로 분할되거나 별도의 메소드로 분할되게 된다. 이렇게 대상들이 작아지면 전체를 이해하기가 수월해진다. 그리고 작은 단위들은 서로 유사성을 띄는 경우가 많다. 완성된 자동차를 종류별로 비교하면 동일성을 찾기가 어렵지만, 부품단위로 뜯어보면 같은 것을 발견할 가능성이 높아지는 것과 같은 이치이다. 설령 조금씩 다르다고 해도 부품들의 목적이 같다면 둘을 통일 시켜 하나의 요소로 만들기도 쉽다. 같은 세단이라도 완성차끼리 호환시키기는 어렵지만, 세단과 SUV 사이라고 해도 부품별로 놓고 보면 나사, 프레임, 바퀴, 선루프 등 부품별로는 얼마든지 동일성을 유지시킬 수 있는 여지가 있다. 이와 같은 이유로 더 많은 요소의 동일성을 확보하고, 이를 통해 더 많은 유연성을 확보하는 것이 가능해 진다.

책임을 잘 분류하면 다음과 같은 효과를 얻을 수 있다. 우선 단일 책임 원칙을 어떻게 위반하고 있는지를 확인할 수 있다. 어떤 구현 요소(interface, 클래스, 메소드 등)가 여러 종류의 책임을 함께 가지고 있음을 분석해낼 수 있고, 어떤 책임을 분리하여 단순화할지에 대한 전략을 세울 수 있다. 또 책임의 종류에 따라 어떤 형태로 책임의 분할을 수행할 수 있을지를 알 수 있다. 그리고 이런 분할 방식을 이용하여 어떤 소프트웨어를 작성하더라도 정형화 된 형태의 설계나 구현을 할 수 있게 된다. 이것은 개발의 속도를 향상 시키는데 매우 큰 도움이 된다. 마지막으로 객체지향 소프트웨어가 어떤 형태로 기능을 확장해 나가야 할지를 가이드 할 수 있다. 기능이 확장된다는 것은 책임이 증가한다는 것을 의미한다. 책임이 증가되면 단일 책임 원칙을 지키기 위해 분할이 필요하다. 이 때 책임의 종류를 명확하게 이해하고 있으면 쉽게 책임을 분할 할 수 있고, 단일 책임 원칙을 유지시킴으로써 좋은 소프트웨어 구조를 유지할 수 있다.


생성 책임

객체의 생성은 매우 중요한 작업 중의 하나이다. 객체를 생성하는 과정의 중요성 때문에 GoF의 디자인 패턴에도 5가지 종류의 생성 패턴이 존재한다. 또한 생성 과정에서 만나는 다양한 문제점들을 해결하기 위해서 많은 소프트웨어들에서 Factory Method를 이용한다.

이 생성 책임은 세부적으로 다음과 같은 작업들이 정상적으로 이루어지도록 만든다. 첫번째는 적절한 구체 객체를 생성하는 것이다. 생성 패턴이나 Factory Method에서는 사용자가 사용하는 생성자 객체 혹은 생성 메소드에 따라서, 또는 생성 메소드에 의해 전달되는 파라메터에 따라서 다른 객체를 생성하고 제공한다. 생성 책임을 지고 있는 모듈로부터 적절한 객체를 제공 받음으로써 어플리케이션이 적절한 동작을 취할 수 있도록 한다. 두번째로 객체의 타입을 감춘다. 구체 객체를 생성했다고 해도 그 구체 객체의 타입으로 사용하지 않는 편이 좋을 경우가 있다. 대표적으로 다형성을 통해 구현된 일련의 객체들을 받아 사용하는 경우이다. 이 때 생성 책임을 지는 모듈은 구체 객체의 타입을 감추고 추상 타입으로 리턴해 줌으로써 객체를 사용하려는 쪽과 생성된 구체 객체간의 관계가 생성되는 것을 막아준다. 세번째로 객체가 생성되고 나서 사용되기 전에 해줘야 할 작업들을 실행해준다. 대표적으로 초기화 작업이나 이벤트 수신을 위해 생성된 객체를 다른 객체에 등록하는 작업 등이다. 네번째로 객체의 생성에서 사용 전까지 있을 수 있는 동기화 문제를 방지해 주는 역할을 한다. 다섯번째로 객체가 생성되면서 가져야 할 초기 값들을 할당해준다.


조립 책임

현대 소프트웨어처럼 요구사항이 다양화 되기 전까지는 객체를 조립한다는 개념이 두드러지지 않았다. 프레임워크, 즉 어떤 어플리케이션으로 진화하든지 상관 없이 충분한 기능을 지원해 줄 수 있도록 설계된 소프트웨어 플랫폼들에서나 주로 고민하던 부분이다. 하지만 객체지향 언어에서 의존성 관리를 통한 유연성 확보 개념이 널리 퍼지고, 엔터프라이즈 애플리케이션이 더 많은 산업 분야에 적용되고, 같은 기능의 소프트웨어를 대량으로 판매하기 보다는 사용자의 요구에 맞춰 다른 기능들을 제공하는 방식을 더 많이 지원해야 하는 상황에서 이 조립 책임이 대두되었다. 아마도 앞으로 인공지능 분야가 더더욱 발전하고 소프트웨어가 더 많은 데이터를 손쉽게 수집할 수 있는 플랫폼을 개발하는 방향으로 나가게 된다면 이 조립 책임은 더욱더 비중이 커지게 될 것이다.

Spring 프레임워크의 의존성 주입(Dependency Injection), 넷빈즈의 룩업(Lookup) 서비스 객체, 서비스 로케이터(Service Locator) 등과 같이 객체의 조립이라는 과정은 독립적이고 핵심적인 요소이다.


구현 책임

구현의 책임은 구체 객체가 가진 책임이다. 즉 특정 요구사항에 맞게 실제 동작을 수행할 수 있는 메소드를 가지고 있는 것이다. 좀 더 구체적으로 보면 공개 메소드가 구현 책임의 대상이 된다. 이 공개 메소드는 interface나 상위 클래스를 상속함으로써 구현이 강제 되기도 하고, 자체적으로 공개 메소드로 설정하고 제공되기도 한다. 어쨌든 구체 객체는 이들 공개 메소드에 대하여 구현함으로써 실제 작업을 수행하는 책임을 가진다.


계약 책임

계약 책임은 interface에 대한 책임이다. 계약이라는 이야기가 매우 생소할 수도 있다. 하지만 어떤 interface를 구현하는 객체들이 구체적인 구현에 있어서 자율성을 가지고 있다는 사실은 이미 알고 있을 것이다. 객체를 사용하는 쪽에서는 구체적인 객체가 어떤 방식으로 동작하는지에 대해서는 관여하지 않는다. 이를 통해 다형성이 유지되고 유연한 구조의 소프트웨어가 탄생할 수 있다. 하지만 이와 함께 interface는 행위를 강제하는 역할을 한다. 이것을 현실 세계와 비교해 보면 "계약" 관계와 그 유사성을 찾을 수 있다. 계약서를 들이 미는 쪽은 사용자 측이고, 계약을 이행해야 하는 측은 구체적인 구현 객체이다. 그리고 계약서에 해당하는 것은 interface이다. 사용자 측에서는 계약서, 즉 interface를 구현하기만 한다면 구체적인 객체가 어떤 일을 하든지 상관하지 않는다. 대신 계약서(interface)는 반드시 지켜져야만 한다. 

이런 개념으로 interface를 이해하면 리스코프 치환의 원칙이 자연스럽게 지켜진다. 사용자 측에서는 언제나 interface를 통해서만 객체를 취급한다. 구체적인 객체들은 interface의 구현을 강제 당하는 셈이지만, 그 방법에 대해서는 최대한 자유를 누릴 수 있다. 이런 특성들을 개념적으로 잘 이해할 수 있도록 하기 위해서 interface에 계약 책임을 부여하고자 한다.


상태 책임

상태 책임은 필드와 필드의 가시성, 그리고 getter/setter에 대한 책임이다. 어떤 객체에 필드를 선언할 때는 메소드를 선언할 때보다 훨씬 신중해야 한다. 필드는 구현을 도와주는 도구가 아니다. 오히려 가능한 한 선언을 피해야 하고, 일단 선언 되어 있다면 가능한 한 가시성을 낮춤으로써 외부로 유출되는 것을 방지해야 한다. 일단 선언된 필드는 필드를 가지고 있는 객체에 책임을 뒤집어 씌운다. 필드는 필드 스스로 연산을 수행할 능력이 없기 때문이다. 다른 객체들은 특정 필드가 선언된 객체에게 해당 필드의 값을 요구하거나(getter), 어떤 필드 값을 저장하라고 시키거나(setter), 그 필드와 연관된 연산을 수행할 것을 요구할 수 있다. 그리고 특히 getter 메소드는 필드의 값을 외부로 누출시켜 필드에 의해 발생하는 로직들(특히 조건문과 같은)을 전파시키는 역할을 하게 된다. 따라서 단순히 필드를 노출시키는 것만 아니라 필드를 통해 다른 객체들이 하고자 하는 일들을 예상하고 그 일들을 미리 구현해야 한다.


이로써 책임의 분류가 끝났다. 이제 책임의 분류가 어떤 효과를 나타내는지 확인해 볼 차례다.

Posted by 이세영2
,

설계를 하다보면 종종 상위의 인터페이스나 상위 클래스는 외부에 public으로 노출을 시키되, 하위의 구체 클래스들은 외부에 노출시키지 않아야 하는 경우가 있다. 이는 정보 은닉을 위한 좋은 설계 방법 중 하나이다. 이를 위해서 보통 하위의 구체 클래스들을 package private class(JAVA에서 접근자 키워드가 없이 선언되는 클래스)로 선언한다. 이렇게 하면 같은 패키지 내에서는 해당 클래스에 접근할 수 있어도 다른 패키지에서는 package private 클래스를 접근할 수 없으므로 오직 인터페이스나 상위 클래스를 이용할 수 밖에 없다. 따라서 하위 클래스들의 변경이나 추가와 같은 유연성이 보장된다.

하지만 문제는 package private class에 대한 Unit Test를 작성하는 일은 쉽지 않다는 점이다. 일반적으로 제품 코드와 유닛 테스트 코드는 분리되는 것이 좋다. 제품 코드와 유닛 테스트 코드가 같은 곳, 즉 같은 패키지 내에 존재하면 제품의 출시를 위해서 유닛 테스트 코드를 골라내는 작업이 필요하다. 또 제품 코드를 이해하기 위해서 소스를 뒤적이다 보면 유닛 테스트 코드가 함께 섞여 있어서 불편함을 겪게 된다. 그래서 일반적으로 제품 코드와 유닛 테스트 코드를 분리하여 두는데, 이렇게 되면 제품 코드와 유닛 테스트 코드의 패키지가 분리되면서 package private인 요소들에 대해서는 유닛 테스트가 접근할 수 없게 된다. 이렇게 되면 간접적으로 해당 요소들을 테스트해야 하는데 이는 매우 번거로운 작업이 된다.

이클립스에서는 이 문제를 해결할 수 있는 좋은 솔루션을 제공해 준다. 바로 소스 폴더를 여러개 등록하는 것이다.

일단 해결하고자 하는 문제를 명확하게 보여주기 위해서 다음과 같은 예제를 작성해 보겠다.


Shape 및 ShapeFactory 소스 코드

// Shape.java

public interface Shape {

    public void draw();

}

class Triangle implements Shape{

    public void draw(){ System.out.println("draw triangle"); }

}

class Circle implements Shape{

    public void draw(){ System.out.println("draw circle"); }

}

// ShapeFactory.java

public class ShapeFactory {

    public Shape createTriangle(){ return new Triangle(); }

    public Shape createCircle(){ return new Circle(); }

} 

우선 외부에 노출시키고자 하는 인터페이스인 Shape을 정의한다. 그리고 이 인터페이스는 외부에 노출될 것이므로 public interface로 선언하였다. 그리고 하위 클래스인 Triangle과 Circle 클래스들을 구현한다. 이들 클래스는 외부에 노출시키지 않을 것이다. 따라서 접근자가 없는 클래스, 즉 package private 클래스로 선언이 된다.

그리고 외부에 Triangle 클래스와 Circle 클래스를 노출시키지 않을 수 있으려면 생성자 호출을 대신할 Factory 클래스를 제공해 주어야 한다. ShapeFactory 클래스는 Shape 클래스와 같은 패키지 내에 있으면서 외부의 요청에 따라 Triangle 객체와 Circle 객체를 생성하여 제공해 준다. 이 때 구체적인 타입을 감출 수 있도록 Shape 타입으로 상위 타입 캐스팅을 한 후에 제공한다. 이를 통해 Triangle과 Circle 클래스가 외부로 노출되는 것이 차단된다.


이제 유닛 테스트를 구현해야 할 차례다. ShapeFactory가 각 API에 따라서 제대로 된 Triangle 객체와 Circle 객체를 생성하는지를 테스트 해보고자 한다. 그렇다면 아래와 같은 테스트 클래스를 구현해 볼 수 있다.


ShapeFactoryTest.java

public class ShapeFactoryTest {

    ShapeFactory sut;

    @Before

    public void setUp(){

        sut = new ShapeFactory();

    }

    @Test

    public void testCreateRectangle(){

        Shape shape = sut.createTriangle();

        assertTrue(shape instanceof Triangle); // --- 1

    }

    @Test

    public void testCreateCircle(){

        Shape shape = sut.createCircle();

        assertTrue(shape instanceof Circle);   // --- 2

    }

} 

1과 2에서와 같이 Triangle 객체와 Circle 객체가 제대로 생성되었는지를 확인할 필요가 있다. 그런데 Triangle과 Circle은 모두 package private 클래스들이다. 따라서 이 유닛 테스트 코드와 제품 코드가 분리되어 있더라도 이들에 접근 할 수 있는 방법이 필요하다.


제품코드와 테스트 코드의 소스 폴더 분리하기

Proeject Explorer / New / New JavaProject를 클릭하면 아래와 같은 다이얼로그가 나올 것이다. 


여기서 바로 finish를 누르지 말고 Configure default 버튼을 누른다. 그러면 아래와 같은 다이얼로그가 생성된다.

붉은색 박스처럼 src 대신 src/java/main을 입력하고 확인을 누른다. 그리고 나서 Next 버튼을 누르면 아래와 같이 화면이 바뀌게 된다.


여기서 오른쪽 클릭을 하고 New Source Folder 버튼을 누른다. 그러면 아래와 같은 다이얼로그가 생성된다.


여기에 Folder name에 src/test/main을 입력하고 확인을 누른다.

그리고 나서 Finish를 누르면 프로젝트 생성이 완료된다. 폴더 구성이 아래와 같다면 제대로 생성이 된 것이다.


일반적으로는 src라는 폴더 하나만 있는데 우리가 만든 프로젝트는 src/java/main과 src/test/main 폴더가 더 추가되어 있다. 그리고 이들 모두가 새로운 패키지를 생성하는 것이 가능하다. 이 두 폴더 중에서 src/java/main 폴더에는 제품 코드를 추가하고 src/test/main에는 유닛 테스트 코드를 추가하면 된다.

이렇게 소스 폴더를 둘로 나눠 놓은 이유는 물론 제품 코드와 유닛 테스트 코드를 분리시키기 위해서이다. 그런데 다른 방법으로도 둘을 분리할 수 있음에도 굳이 방법을 사용한 이유는 저 두 폴더에서 생성한 동일한 경로의 패키지는 같은 패키지로 취급되기 때문이다. 같은 패키지로 취급된다는 말은 위에서 package private 클래스에 대한 유닛 테스트와 Shape 및 ShapeFactory에 대한 소스 코드를 각각의 폴더에 분리하여 넣어도 둘의 패키지 경로만 같게 설정하여 주면 테스트가 정상 동작한다는 것을 의미한다.

이를 확인하기 위해서 두 폴더에 각각 com 이라는 경로의 패키지를 만들어 본다. 그러면 아래와 같이 될 것이다.


이제 위의 패키지에는 Shape.java와 ShapeFactory.java를 넣고, 아래 패키지에는 ShapeFactoryTest.java를 넣는다. 그리고 유닛 테스트를 실행해 보면 된다.

같은 테스트 코드라고 해도 다른 패키지 경로에 넣으면 Triangle 과 Circle 클래스가 등장하는 곳에서 에러가 발생한다.


사실 이러한 소스 폴더 분리 방식은 이미 Spring Framework에서도 적용하고 있는 방식이다. 만일 이와 같이 package private class들에 대한 테스트가 필요하다면(일반적으로 모든 프로젝트에서 그렇겠지만) 꼭 위와 같이 같은 패키지 경로들을 만들어 테스트 코드를 넣는 것이 좋다. 그래야만 보다 손쉽게 테스트 코드를 작성할 수 있다. 만일 Spring이 아닌 일반 프로젝트를 생성한다고 해도 유닛 테스트를 작성해야 하는 경우라면 미리 위와 같은 형태로 소스 폴더를 구성해 두는 편이 유닛 테스트를 작성하기에 용이할 것이다.

한가지만 더 이야기 하자면, protected 메소드 역시 같은 방식으로 테스트가 가능하다. protected 메소드는 package private과 같이 같은 패키지 내의 다른 클래스에서도 접근이 가능하다. 따라서 위와 같은 형태로 소스 경로를 분리한 후 테스트를 수행해 볼 수 있다.



Posted by 이세영2
,