우리의 목표는 객체지향 언어를 이용해서 유연성 있는 소프트웨어를 개발하는 것이다. 소프트웨어가 유연하다는 것은 다양한 요구사항 변화에도 대처가 가능하다는 의미이다. 요구사항 변화는 소프트웨어에 추가, 제거, 변경과 같은 수정을 가져온다. 즉, 유연성은 소프트웨어가 얼마나 손쉽게 수정될 수 있느냐에 달려 있다. 이미 요구사항의 변화를 쉽게 반영할 수 있는 객체지향의 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
,