2 Jul 2019

디자인 패턴 00 - Introduction

객체지향 소프트웨어 설계의 어려움

객체지향 소프트웨어를 설계한다는 것은 쉬운 일이 아니다. 특히 재사용할 수 있는 객체지향 소프트웨어를 만드는 것은 더 어렵다. 적절한 객체를 식별하고, 올바른 크기의 클래스와 인터페이스를 정의할 수 있어야 하며, 클래스 간의 상속이나 클래스들 간의 관계를 설정할 수 있어야 한다.

설계는 당면한 문제를 해결할 수 있어야 하지만, 앞으로도 생길 수 있는 문제나 요구사항들도 수용할 수 있도록 일반적이고 포괄적이어야 한다. 재설계를 하지 않아도 다시 사용할 수 있어야 하고, 아니면 최소한의 수정을 통해 다시 사용할 수 있어야 하는 설계여야 한다.

처음부터 유연하고 재사용 가능한 설계를 정확히 하기에는 불가능에 가깝다. 이런 문제에 대해 경험자들은 처음부터 설계를 재시작하려고 하지 않는다. 대신에 이전에 사용했던 해결책을 다시 적용해본다. 그리고 좋은 방법을 찾아냈다면 그 방법을 반복해서 계속 사용하게 된다. 이런 경험을 통해 많은 객체지향 시스템에서는 클래스 패턴이나 객체들 간의 상호 작용 방식이 반복됨을 알게 된다. 이러한 반복된 패턴들은 특정 설계의 문제점들을 해결해주고, 유연하고 재사용 가능한 객체지향 소프트웨어를 만들어준다.

다들 이러한 경험이 있을 것이다. 설계를 하는 동안에 어디서 봤는지는 기억이 안나지만, 이런 경우를 전에 본 적이 있는 느낌을 받은 적이 있을 것이다. 만약 그런 경험을 토대로 어떻게 문제를 해결했는지 기억이 난다면 아마 처음부터 다시 만들지 않고도 설계를 그대로 적용할 수 있을 것이다.

이런 소프트웨어 설계에서 얻는 경험을 기록하고 앞으로도 비슷한 문제에 대해 빠르게 좋은 설계를 적용할 수 있도록 하는 것이 디자인 패턴이다. 디자인 패턴을 사용하면 좋은 설계나 아키텍처를 재사용하기 쉬울 뿐만 아니라, 패턴을 통해 시스템의 유지보수나 문서화도 개선할 수 있다. 또한 클래스의 명세나 객체 간의 상호작용, 설계의 의도까지 명확히 드러낼 수 있다.


디자인 패턴이란?

디자인 패턴이란 기존 환경에서 반복적으로 일어나는 문제를 설명한 후, 그 문제들에 대해 해법의 핵심을 설명해준다. 연결리스트나 해시테이블을 클래스로 표현하고 재사용할 수 있도록 하는 설계하는 문제를 풀거나 응용 프로그램, 서브시스템을 지원하는 복잡한 설계에 대해 말하는 것이 아니다. 디자인 패턴은 특정한 전후 관계에서 일반적인 설계 문제를 해결하기 위해 상호교류하는 수정 가능한 객체와 클래스들에 대한 설명이다.

하나의 디자인 패턴은 재사용 가능한 객체지향 설계를 만들기 위해 유용한 공통의 설계 구조에서 주요 요소들을 식별하여 이들에게 적당한 이름을 주고 추상화한다. 그리고 패턴에 사용되는 클래스와 인스턴스들의 역할과 그들 간의 관계를 정의한다.

하나의 패턴에는 다음의 네 가지 요소가 포함된다.

  1. 패턴 이름: 특정 단어로 설계 문제와 해법을 서술한다. 패턴에 이름을 부여함으로써, 설계 어휘를 늘리고 설계의 의도를 명확히 표현할 수 있게 된다. 개발자들 간의 의사소통이 원활해질 뿐만 아니라 설계에 대한 생각을 쉽게 할 수 있다.
  2. 문제: 언제 패턴을 사용하는가를 서술하며, 해결할 문제와 그 배경을 설명한다.
  3. 해법: 설계를 구성하는 요소들과 그 들간의 관계, 책임, 협력관계를 설명한다. 하지만 특정한 문제를 해결하기 위한 해법을 말하는 것은 아니다. 패턴은 다양한 경우에 적용할 수 있는 템플릿이다.
    • 디자인 패턴은 문제에 대한 추상적인 설명을 제공하고, 문제를 해결하기 위해 클래스나 객체들의 나열 방법을 제공한다.
  4. 결과: 패턴을 적용함으로써 얻는 결과와 장단점을 설명한다. 패턴의 결과는 시스템의 유연성과 확장성, 이식성에 커다란 영향을 준다. 패턴을 적용한 설계의 결과들을 잘 정리해두면 나중에 패턴을 이해하거나 평가하는 데 도움이 된다.

어떤 설계를 결정할 때 그 설계를 선택함으로써 얻는 결과는 비용과 효과를 바라보는 측면에서는 가장 중요한 부분이다.

디자인 패턴들은 각자 맡은 객체지향 설계 문제에 집중하고, 언제 패턴을 적용할지, 다른 설계 제약을 고려하여 패턴을 적용할 수 있는지, 적용시 어떤 결과가 발생하는지 설명한다.


디자인 패턴들의 종류

다음은 자주 사용되는 디자인 패턴들에 대해 간략한 설명이다.

  • 추상 팩토리(Abstract Factory): 구체적인 클래스를 사용하지 않고, 서로 관련성이 있는 객체들의 집합을 생성하거나 서로 독립적인 객체들의 집합을 생성하는 인터페이스를 제공한다.
  • 적응자(Adapter): 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하는 패턴으로, 호환성이 없는 인터페이스로 인해 함께 동작할 수 없는 클래스들이 함께 동작하도록 해준다.
  • 가교(Bridge): 구현부에서 추상층을 분리하여 각자 독립적으로 변형할 수 있는 패턴이다.
  • 빌더(Builder): 복합 객체의 생성 과정과 표현 방법을 분리하여, 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있도록 한다.
  • 책임 연쇄(Chain Of Responsibility): 요청을 처리할 수 있는 기회를 하나 이상의 객체에 부여하여, 요청을 보내는 객체와 그 요청을 처리하는 객체 사이의 결합을 피하는 패턴이다. 요청을 받을 수 있는 객체를 연쇄적으로 묶고, 실제 요청을 처리할 객체를 만날 때까지 객체 고리를 따라 요청을 전달한다.
  • 명령(Command): 요청을 객체의 형태로 캡슐화하여, 서로 요청이 다른 사용자의 매개변수화, 요청 저장 또는 로깅, 연산의 취소를 지원하는 패턴이다.
  • 복합체(Composite): 객체들의 관계를 트리 구조로 구성하여 부분-전체 계층을 표현하는 패턴으로, 클라이언트가 단일 객체와 복합 객체를 모두 동일한 인터페이스로 다룰 수 있도록 해준다.
  • 장식자(Decorator): 주어진 상황, 용도에 따라 어떤 객체에 책임, 기능을 덧붙이는 패턴으로 기능 확장이 필요할 때 상속을 통한 서브클래싱 대신 쓸 수 있는 방법이다.
  • 퍼사드(Facade): 서브시스템에 있는 인터페이스 집합에 대해 하나의 통합된 인터페이스를 제공하는 패턴으로 서브시스템을 더 잘 사용하기 위해 상위 수준의 인터페이스를 정의한다.
  • 팩토리 메서드(Factory Method): 객체를 생성하는 인터페이스는 미리 정의하되, 실제 인스턴스를 만들 클래스의 결정은 서브클래스 쪽에서 내리는 패턴이다.
  • 플라이급(Flyweight): 크기가 작은 객체가 여러 개 있을 경우, 공유를 통해 이들을 효율적으로 지원하는 패턴이다.
  • 해석자(Interpreter): 주어진 언어에 대해 그 언어의 문법을 위한 표현 수단을 정의하고, 이와 아울러 그 표현 수단을 사용해 해당 언어로 작성된 문장을 해석하는 해석기를 정의하는 패턴이다.
  • 반복자(Iterator): 내부 표현부를 노출하지 않고, 어떤 객체 집합에 속한 원소를 순차적으로 접근할 수 있는 방법을 제공하는 패턴이다.
  • 중재자(Mediator): 한 집합에 속한 객체들의 상호작용을 캡슐화하는 객체를 정의하는 패턴이다. 객체들이 직접 서로 참조하지 못하도록 함으로써 객체들 간의 소결합(loose coupling)을 촉진시키고, 객체들의 상호작용을 독립적으로 다양화할 수 있게 해준다.
  • 메멘토(Memento): 캡슐화를 위배하지 않은 채, 어떤 객체의 내부 상태를 잡아내고 실체화시켜, 이후에 해당 객체가 그 상태로 다시 돌아올 수 있도록 하는 패턴이다.
  • 감시자(Observer): 객체 사이에 일 대 다의 의존 관계를 정의해 두어, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지받아 자동으로 갱신될 수 있도록 하는 패턴이다.
  • 원형(Prototype): 생성할 객체의 종류를 명세화하는 데 원형이 되는 예시물을 이용하고, 그 원형을 복사함으로써 새로운 객체를 생성하는 패턴이다.
  • 프록시(Proxy): 어떤 다른 객체로 접근하는 것을 통제하기 위해 그 객체의 대리자(surrogate) 또는 자리채움자(placeholder)를 제공하는 패턴이다.
  • 단일체(Singleton): 어떤 클래스의 인스턴스는 오직 하나임을 보장하며, 이 인스턴스에 접근할 수 있는 전역적인 접촉점을 제공하는 패턴이다.
  • 상태(State): 객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게끔 하는 패턴으로, 이를 통해 객체가 마치 자신의 클래스를 동적으로 바꾸는 것처럼 보일 수 있다.
  • 전략(Strategy): 동일 계열의 알고리즘 군을 정의하고, 각각의 알고리즘을 캡슐화하며, 이들을 상호교환이 가능하도록 만드는 패턴이다.
  • 템플릿 메서드(Template Method): 객체의 연산에는 알고리즘의 뼈대만 구현하고, 각 단계에서 수행할 구체적인 처리는 서브클래스에서 구현하는 패턴이다. 알고리즘의 구조 자체는 그대로 두고, 알고리즘 각 단계의 처리는 서브클래스에서 재정의할 수 있게 한다.
  • 방문자(Visitor): 객체 구조를 이루는 원소에 대해 수행할 연산을 표현하는 패턴으로, 연산을 적용할 원소의 클래스를 변경하지 않고도 새로운 연산을 정의할 수 있게 한다.


디자인 패턴 조직화하기

패턴을 분류하는 기준은 두 가지가 있다.

첫 번째로는 패턴들의 목적으로, 다시 말해 패턴이 무엇을 하는지 정의하는 것이다. 패턴들은 생성, 구조, 행동 중 하나의 목적을 갖는다. 생성 패턴은 객체의 생성과정에 관여하는 것이고, 구조 패턴은 클래스나 객체의 합성에 대한 패턴이다. 그리고 행동 패턴은 클래스나 객체들이 상호작용하는 방법과 책임을 분산하는 방법을 정의한다.

두 번째로는 패턴들의 범위로, 클래스에 적용되는 것인지 아니면 각 객체에 적용되는 것인지를 구분하는 것이다. 클래스 패턴은 클래스와 서브클래스 간의 관련성을 다룬다. 여기서 말하는 관련성은 주로 상속으로, 컴파일 타임에 정적으로 결정된다. 객체 패턴은 객체들간의 관련성을 다루며 런타임에 변경할 수 있어서 동적인 성격을 갖는다.

  • 생성
    • 클래스 패턴: 팩토리 메서드
      • “생성 클래스” 패턴은 객체를 생성하는 책임의 일부를 서브 클래스가 담당하도록 한다.
    • 객체 패턴: 추상 팩토리, 빌더, 원형(Prototype), 단일체(Singleton)
      • “생성 객체” 패턴은 객체를 생성하는 책임을 다른 객체로 위임한다.
  • 구조
    • 클래스 패턴: 적응자(Adapter)
      • “구조 클래스” 패턴은 상속을 이용하여 클래스를 복합한다.
    • 객체 패턴: 적응자(Adapter), 가교(Bridge), 복합체(Composite), 장식자(Decorator), 퍼사드(Facade), 플라이급(Flyweight), 프록시(Proxy)
      • “구조 객체” 패턴은 객체를 합성하는 방법을 정의한다.
  • 행동
    • 클래스 패턴: 해석자(Interpreter), 템플릿 메서드
      • “행동 클래스” 패턴은 상속을 이용하여 알고리즘과 제어 흐름을 표현한다.
    • 객체 패턴: 책임 연쇄(Chain Of Responsibility), 명령(Command), 해석자(Interpreter), 중재자(Mediator), 메멘토(Memento), 감시자(Observer), 상태(State), 전략(Strategy), 방문자(Visitor)
      • “행동 객체” 패턴은 하나의 작업을 수행하기 위해 객체 집합이 어떻게 협력하는지 표현한다.

패턴을 조직화하는 방법에는 여러 가지가 있다. 일부 패턴은 함께 사용할 때도 있는데, 예를 들어 복합체(Composite) 패턴은 반복자(Iterator) 패턴과 방문자(Visitor) 패턴을 함께 사용할 때가 많다. 또 어떤 패턴은 다른 패턴의 대안이 되기도 한다. 원형(Prototype) 패턴은 추상 팩토리 패턴의 대안 패턴이다. 복합체(Composite) 패턴과 장식자(Decorator) 패턴과 같이, 패턴 간의 의도는 다르지만 결과적으로는 유사한 설계 구조를 만드는 패턴도 있다.

다음은 각 디자인 패턴들 간의 관계를 표현하는 그림이다.

00.png


디자인 패턴을 이용하여 문제를 푸는 방법

디자인 패턴은 객체지향 설계자들이 매일 부딪히게 되는 많은 문제를 다양한 방법으로 해결해 준다. 여기서는 이러한 몇 가지 문제를 제시하고, 이 문제에 대해 디자인 패턴이 어떠한 해결책을 제시하는지 살펴본다.


적당한 객체 찾기

객체는 데이터와 이 데이터에 대한 연산을 수행하는 프로시저를 함께 묶은 단위로, 객체는 클라이언트로부터 요청을 받으면 연산을 수행한다.

클라이언트의 요청은 객체가 연산을 수행할 수 있게 하는 유일한 방법이고, 연산은 객체의 내부 데이터를 변경하는 유일한 방법이다. 이러한 접근의 제약 사항으로 객체의 내부 상태는 캡슐화된다고 한다. 객체 외부에서는 객체의 내부 데이터에 직접 접근이 불가능하다.

객체지향 설계의 가장 어려운 부분은 시스템을 구성할 객체의 분할을 결정하는 것이다. 캡슐화나 클래스의 크기, 종속성, 유연성, 성능, 재사용성 등 여러 요인을 고려해야 하므로 매우 어려운 작업이다. 이 모두를 어떻게 고려하는가에 따라 서로 다른 방법으로 객체를 분할할 수 있다.

디자인 패턴은 객체를 모델링할 때, 덜 명확한 추상적 개념과 이것을 잡아내는 객체를 알아보는데 도움울 줄 수 있다. 처음 객체지향 프로그래밍을 하는 개발자들에게는 어떤 루틴이나 알고리즘을 객체로 꾸미는 것은 자연스러운 일이 아니다. 하지만 유연한 설계를 만드는데 있어서는 반드시 필요하다. 전략 패턴은 상호교환이 가능한 알고리즘을 어떻게 구현할지를 설명한다. 상태 패턴은 대상들의 각 상태를 객체로 표현한다.


객체의 크기 결정

객체지향 프로그래밍에 있어서 적절한 객체의 크기는 어떻게 결정할 수 있을까?

퍼사드(Facade) 패턴은 서브시스템을 어떻게 객체로 표현할 수 있는지를 설명하고, 플라이급(Flyweight) 패턴은 규모는 작지만 개수는 많은 객체를 다루는 방법을 설명한다.

또 어떤 패턴들은 객체를 좀 더 작은 규모의 객체로 분할하는 구체적인 방법을 다루기도 한다. 추상 팩토리 패턴과 빌더 패턴은 다른 객체를 생성하는 책임만 있는 객체를 만들어 낸다. 방문자(Visitor) 패턴과 명령(Command) 패턴은 클라이언트의 요청을 자신이 처리하는 것이 아니라, 다른 객체나 객체 집합이 요청을 처리하여 구현하도록 책임지는 객체를 만들어낸다.


객체 인터페이스 명세

객체가 선언하는 모든 연산은 연산의 이름, 매개변수로 받아들이는 객체, 연산의 반환 값을 명세하는데 이를 연산의 시그니처(Signature)라고 부른다. 인터페이스는 객체가 정의하는 연산의 모든 시그니처를 일컫는 말로, 객체의 인터페이스는 객체가 받아서 처리할 수 있는 연산의 집합이다.

타입은 특정 인터페이스를 나타낼 때 사용하는 이름이다. 객체가 Window 타입을 갖는다는 것은 Window 인터페이스에 정의된 모든 연산을 처리할 수 있다는 것을 말한다. 객체는 여러 타입을 가질 수 있으며, 서로 다른 객체가 하나의 타입을 가질 수도 있다.

인터페이스 개념은 객체지향 시스템에서 가장 기본적인 것이다. 객체는 인터페이스로 자신을 드러낸다. 외부에서 객체를 알 수 있는 방법은 인터페이스 밖에 없으므로, 인터페이스를 통해서만 처리를 요청할 수 있다.

객체의 인터페이스는 구현에 대해서는 전혀 알리지 않는다. 따라서 같은 타입을 가지는 서로 다른 객체는 인터페이스에 정의한 연산 처리 방법을 다르게 구현될 수 있다. 어떤 요청이 전달되면 이를 받는 객체에 따라 수행되는 처리 방식이 달라진다. 어떤 요청과 그 요청을 처리할 객체를 프로그램 실행 중, 런타임에 연결 짓는 것을 동적 바인딩이라고 한다. 이러한 동적 바인딩은 특정 요청에 대해 처리할 수 있는 객체를 대체할 수 있게 해주며, 이러한 대체성을 다형성이라고 하는데 이는 객체지향 시스템의 핵심 개념이다.

디자인 패턴은 인터페이스에 정의해야 하는 중요한 요소가 무엇이고, 어떤 종류의 데이터를 주고받아야 하는지 식별하여 인터페이스를 정의하도록 도와준다. 메멘토(Memento) 패턴은 객체의 내부 상태를 어떻게 저장하고 캡슐화해야 되는지를 정의함으로써 객체가 나중에 그 상태로 복구할 수 있는 방법을 알려준다.

또한 디자인 패턴은 인터페이스 간의 관련성도 정의한다. 특히 클래스 간에 유사한 인터페이스를 정의하거나 클래스의 인터페이스에 여러 가지 제약을 정의할 수도 있다. 장식자(Decorator) 패턴과 프록시(Proxy) 패턴은 장식되고 중재되는 타겟 객체와 동일한 인터페이스를 갖도록 장식자 / 프록시 객체의 인터페이스를 정의한다. 방문자(Visitor) 패턴에서 방문자 인터페이스는 방문자 객체가 방문하는 객체들의 인터페이스를 그 방문자 인터페이스에 모두 반영하도록 한다.


객체 구현 명세하기

객체는 클래스를 인스턴스로 만듦으로써 생성된다. 클래스의 인스턴스화 과정은 객체의 내부 데이터(인스턴스 변수)에 대한 공간을 할당하고, 이 데이터들을 연산과 관련짓는 것이다. 이를 통해 인스턴스를 얻을 수 있다.

서브 클래스는 기존 클래스에 기반을 둔 클래스 상속을 사용하여 정의할 수 있다. 서브 클래스가 부모 클래스를 상속하면, 부모 클래스가 갖는 모든 데이터와 연산을 가지게 된다.

추상 클래스는 모든 서브 클래스 사이의 공통되는 인터페이스를 정의한다. 정의한 모든 연산이나 일부 연산의 구현을 서브 클래스에게 맡긴다. 따라서 모든 연산이 추상 클래스로 구현된 것이 아니므로, 추상 클래스로 인스턴스를 생성할 수 없다. 이에 비해 추상 클래스가 아닌 모든 클래스를 구체 클래스라 한다.

서브 클래스는 부모 클래스가 정의한 행동을 재정의하여 연산의 구현을 바꿀 수 있는데, 이를 오버라이드라고 한다.

믹스인 클래스는 다른 클래스들에게 선택적인 인터페이스 혹은 기능을 제공하려는 목적을 가진 클래스이다.


구현에 따르지 않고, 인터페이스에 따르는 프로그래밍

클래스 상속은 기본적으로 부모 클래스에서 정의한 구현을 재사용하여 응용프로그램의 기능성을 확장하려는 메커니즘이다. 이미 있는 것을 이용하여 새로운 객체를 빨리 정의해보려는 것이다.

그러나 구현의 재사용이 전부는 아니다. 상속을 적절히 이용하면 모든 클래스가 인터페이스나 추상 클래스를 상속하도록 하여 공통된 연산을 정의할 수 있도록 한다. 이는 부모 클래스에 정의된 모든 연산을 처리할 수 있다는 말이며, 인터페이스를 통해 객체 간의 관계를 설정하면 다형성을 활용하여 런타임시에 구현 객체를 바꿀 수도 있다.

인터페이스나 추상 클래스를 통해 객체 간의 관계를 정의하고, 객체를 다룰 때 얻을 수 있는 이점은 다음과 같다.

  1. 사용자가 원하는 인터페이스를 구현했으면, 특정 객체 타입에 대해 알아야 할 필요도 없다.
  2. 사용자가 객체들을 구현하는 클래스를 알 필요가 없고, 단지 인터페이스가 무엇인지만 알면 된다.

이를 통해 서브 시스템 간의 구현 종속성을 없앨 수 있다. 즉, 다음과 같은 재사용 가능한 객체지향 개발 원칙이 나오는 것이다.

구현이 아닌 인터페이스에 따라 프로그래밍한다. 서로 다른 클래스가 서로 종속적으로 연결되지 않도록 인터페이스를 통해 느슨하게 연결하도록 한다.

따라서 어떤 변수(객체)를 구체 클래스의 인스턴스로 바로 선언하는 것은 피해야 한다. 대신 인터페이스나 추상 클래스를 통해 인스턴스를 선언하는 것이 좋다. 이렇게 정의하는 것이 디자인 패턴의 일반적인 방식이며 형태이다.

다만 추상 팩토리나 빌더, 팩토리 메서드, 원형 패턴 및 단일체(Singleton) 패턴에서는 구체 클래스에서 인스턴스를 생성한다. 단, 이들 패턴은 객체 생성 과정을 추상화함으로써 인스턴스화 진행시, 인터페이스와 구현을 연결하는 다른 방법을 제시하는 것이다. 이를 통해 생성 패턴들도 인터페이스를 통해 객체들 간의 관계를 느슨하게 연결하도록 보장한다.


재사용을 실현 가능한 것으로


상속 대 합성

객체지향 시스템에서 기능의 재사용을 위해 구사하는 가장 대표적인 기법은 클래스 상속과 객체 합성이다.

클래스 상속은 부모 클래스를 상속함으로써 클래스의 구현을 정의하는 것이다. 서브 클래스를 통한 구현의 재사용을 화이트박스 재사용(white-box reuse)라고 하는데, 상속을 받으면 부모 클래스의 내부가 서브 클래스로 공개되므로 화이트박스인 셈이다.

객체 합성은 클래스 상속에 대한 대안으로, 서로 다른 객체들을 여러 개 붙여서 새로운 기능 혹은 객체를 구성하는 것이다. 객체를 합성하기 위해서는 합성에 들어가는 객체들의 인터페이스를 명확히 정의해야 한다. 이런 스타일의 재사용을 블랙박스 재사용(black-box reuse)라고 하는데, 객체들끼리 내부는 공개되지 않고 인터페이스를 통해서만 연결되기 때문이다.

상속과 합성은 서로 장단점을 가지고 있다. 클래스 상속은 컴파일 시점에 정적으로 정의되고, 프로그래밍 언어가 지원하므로 그대로 사용하면 된다. 단점으로는 런타임에 상속받은 부모 클래스의 구현을 변경할 수는 없다. 또 부모 클래스의 구현이 서브 클래스로 다 드러나기 때문에, 서브 클래스는 부모 클래스의 구현에 종속된다. 부모 클래스가 변경되면, 서브 클래스에 어떤 부작용이 발생할지 알 수가 없다. 따라서 상속은 캡슐화를 파괴한다고 주장하는 사람도 있다. 이러한 종속성은 유연성과 재사용성을 떨어뜨리는데, 이를 해결하는 방법은 인터페이스나 추상 클래스만 상속하는 것이다. 이들에게는 구현이 애초에 없기 때문이다.

객체 합성은 한 객체가 다른 객체를 참조함으로써 런타임에 동적으로 관계가 결정된다. 합성은 객체가 다른 객체의 인터페이스만 바라보므로, 인터페이스 정의에 많은 주의를 기울여야 한다. 객체들이 느슨하게 결합되므로 이들 간의 종속성은 현저히 줄어든다.

클래스 상속보다 객체 합성을 더 선호하는 이유는 각 클래스의 캡슐화를 유지할 수 있고, 작업시 각 클래스에 집중할 수 있기 때문이다. 클래스와 클래스의 계층이 작게 유지될 수 있으므로, 아주 커다란 클래스로 자랄 가능성은 적다. 객체 합성으로 설계되면 시스템의 행동은 클래스에 정의된 정적인 내용보다는 런타임에 드러나는 객체의 관계에 따라 달라질 수 있다.

객체 합성이 클래스 상속보다 더 나은 방법이다. 구현의 종속성을 줄일 수 있고, 느슨하게 객체들을 결합함으로써 유연성을 확보할 수 있다.

설계자들은 재사용 기법으로 상속을 많이 쓰지만, 객체 합성을 통해 더욱 재사용 가능한 설계를 만들 수 있다. 디자인 패턴을 공부하다보면, 객체 합성이 정말 많은 부분에 적용되어 있는 것을 알 수 있다.


위임

위임(delegation)은 합성을 상속만큼 강력하게 만드는 방법이다. 위임에서는 두 객체가 하나의 요청을 처리하는데, 요청을 수신한 객체가 연산의 처리를 다른 객체로 위임한다.

예를 들어 어느 특정 클래스를 서브 클래스로 만드는 대신에 필요한 기능이 담긴 다른 클래스의 인스턴스를 자신의 인스턴스 변수로 삼고, 필요시 그 변수(다른 클래스의 객체)로 위임하는 것이다.

위임의 가장 중요한 장점은 런타임에 행동의 복합을 가능하게 하고, 복합하는 방식도 변경할 수 있다는 것이다. 만약 다른 기능이 필요할 경우에는 객체만 바꾸면 된다.

위임이 갖는 단점은 객체 합성을 통해 소프트웨어 설계의 유연성을 보장하는 방법과 동일하게, 상속을 통해 정적으로 소프트웨어 구조를 정의한 것보다 이해하기가 더 어렵다는 것이다. 하지만 이런 위임이 만들어내는 복잡함보다 구현의 재사용, 유연성의 효과가 더 크다면 그 설계는 사용하기 좋은 설계이다.

몇 개의 디자인 패턴은 위임을 부분적으로 사용한다. 상태, 전략, 방문자 패턴에서 위임 방식을 사용한다. 상태 패턴에서는 객체는 현재 상태를 표현하는 상태 객체에 요청의 처리를 위임한다. 전략 패턴에서는 객체가 요청을 수행하는 전략 객체에게 특정 요청을 위임한다. 이 두 패턴의 목적은 처리를 전달하는 객체를 변경하지 않고, 객체의 행동을 변경할 수 있게 하자는 것이다. 방문자 패턴에서는 객체 구조의 각 요소에 수행하는 연산은 언제나 방문자 객체에게 위임하는 연산이다.

위임에 전적으로 의존하는 패턴도 있는데, 중재자(Mediator) 패턴은 객체 간의 교류를 중재하는 객체를 도입하여, 중재자 객체가 다른 객체로 연산을 전달하도록 구현한다. 이 때 자기 자신을 참조자로 함께 보내고, 위임받은 객체는 참조자를 통해 위임을 요청한 객체로 다시 메시지를 보내어 필요한 데이터를 얻어가게 함으로써 위임을 구현한다. 책임 연쇄 패턴은 한 객체에서 다른 객체로 고리를 따라 요청의 처리를 위임한다.


상속 대 매개변수화된 타입

기능의 재사용에 이용할 수 있는 방법으로 매개변수화된 타입(parameterized type)가 있다. 자바에서는 제네릭, C++ 에서는 템플릿이라고 한다. 매개변수화된 타입은 객체지향 시스템에서 행동을 복합할 수 있는 세 번째 방법으로, 어지간한 설계는 클래스 상속이나 객체 합성, 매개변수화된 타입 중 하나를 사용한다.

예를 들어, 원소들을 비교하기 위한 정렬 루틴을 설계하는 세 가지 방법을 비교해보자.

  1. 상속: 서브 클래스에 의해 연산을 구현하는 방법 (템플릿 메서드 패턴)
  2. 합성: 정렬 루틴을 다른 객체로 위임하는 방법 (전략 패턴)
  3. 매개변수화: C++ 템플릿이나 제네릭으로 정의한 클래스의 인자로 원소를 비교할 함수 이름을 명시

객체 합성은 런타임에 행동을 변경할 수 있지만, 위임하므로 비효율적일 수 있다. 상속이 연산에 대한 기본 행동을 부모 클래스가 제공하고 이를 서브 클래스에서 재정의하는 것이라면, 매개변수화된 타입은 클래스가 사용하는 타입을 변경하게 하는 것이다.


런타임 및 컴파일 타임의 구조를 관계짓기

객체지향 프로그램의 실행 구조는 종종 코드 구조와 일치하지 않는다. 코드 구조는 컴파일 시점에 확정되는 것이고, 이 구조에는 고정된 상속 클래스 관계들을 포함한다. 그러나 프로그램의 런타임 구조는 교류하는 객체들에 따라 달라질 수 있다. 이 두 구조는 전혀 다른 별개의 독립성을 가진다.

객체 관계 중에는 집합(aggregation)와 인지(acquaintance)라는 것이 있는데, 집합은 한 객체가 다른 객체를 소유하거나, 그것에 책임을 진다는 뜻이다. 보통 한 객체가 다른 객체를 포함(having)한다거나, 다른 객체의 부분(part of)이 된다고 하는데, 여기에는 그 객체들의 생존주기가 똑같다는 의미도 있다.

객체 인지는 한 객체가 다른 객체에 대해 알고 있음을 의미한다. 이를 연관(association) 관계 또는 사용(using) 관계라고도 하는데, 연산을 위임할 수 있지만 서로에 대해 책임을 지지는 않는다. 특히 인지 관계는 객체들 사이의 결합도가 약하다.

그런데 프로그래밍 언어에서 집합 관계와 인지 관계를 구분하기가 까다롭다. 언어적 차원에서 이 둘을 구분하지는 않기 때문이다. 그러므로 이들 관계는 언어의 처리 방식이 아닌 사용 목적에 따라 결정해야 한다.

이런 차이는 코드 상에서는 구분하기 어렵지만, 서로 다른 중요한 의미를 갖는다. 집합 관계는 인지 관계보다는 강력한 영속성의 개념을 갖는다. 이에 비해, 인지 관계는 동적으로 객체들의 관계가 자주 바뀌게 된다.


변화에 대비한 설계

재사용을 최대화하기 위해서는 새로운 요구사항과 기존 요구사항에 발생한 변경을 예측하여 시스템의 설계가 진화할 수 있도록 해야 한다. 디자인 패턴은 독립적으로 시스템의 구조를 변경할 수 있도록 하게함으로써, 요구사항에 의한 변화에 잘 순응할 수 있도록 도와준다.

디자인 패턴을 써서 재설계를 할 수밖에 없게 하는 이유를 몇 가지 정리한다.

  1. 특정 클래스에서 객체 생성하는 경우
    • 객체를 생성시, 클래스로 바로 정의하면 특정 인터페이스가 아닌 클래스에 종속된다. 이런 종속은 앞으로의 변화를 잘 수용하지 못하므로, 객체를 직접 생성하는 경우를 피해야 한다.
    • 추상 팩토리, 팩토리 메서드, 원형
  2. 특정 연산에 의존성이 있는 경우
    • 특정 연산을 사용하면 요청을 만족하는 한 가지 방법에만 얽매이게 된다. 요청의 처리 방법을 직접 코딩하는 방식을 피하면, 요청 처리 방법을 쉽게 변경할 수 있다.
    • 책임 연쇄, 명령(Command)
  3. 하드웨어나 소프트웨어 플랫폼에 종속적인 경우
    • 소프트웨어나 하드웨어 플랫폼에 종속되면 다른 플랫폼에 이식하기가 어렵다. 이런 플랫폼 종속성을 제거하는 것은 시스템 설계에 있어 매우 중요하다.
    • 추상 팩토리, 가교(Bridge)
  4. 객체의 표현이나 구현에 대한 의존성
    • 사용자가 객체의 표현 방법이나 저장, 구현, 위치에 대해 모두 알고 있다면 객체를 변경시 사용자 코드도 함께 변경해야 한다. 이러한 정보는 가급적 사용자로 노출되어서는 안된다.
    • 추상 팩토리, 가교(Bridge), 메멘토(Memento), 프록시
  5. 알고리즘 의존성
    • 알고리즘 자체를 확장하거나 변경할 수도 있는데, 특정 알고리즘에 종속된 객체는 알고리즘 변경시, 객체도 변경해야 한다. 그러므로 변경이 가능한 알고리즘은 분리하는 것이 바람직하다.
    • 빌더, 반복자, 전략, 템플릿 메서드, 방문자
  6. 높은 결합도
    • 높은 결합도를 가지는 클래스들은 독립적으로 재사용하기 어렵다. 이런 클래스는 크기도 커서 하나를 수정하기 위해서는 클래스 전체를 이해해야 하고, 다른 많은 클래스도 변경해야 될 수도 있다. 약한 결합도는 클래스 자체의 재사용을 가능케 한다. 이를 위해 인터페이스나 추상 클래스 수준으로 객체간의 관계를 정의하거나 계층화시키는 방법으로 낮은 결합도의 시스템을 만들 수 있다.
    • 추상 팩토리, 가교(Bridge), 책임 연쇄, 명령(Command), 퍼사드(Facade), 중재자(Mediator), 감시자(Observer)
  7. 상속을 통해 기능 확장
    • 상속을 통한 기능 재정의는 서브 클래스가 최상위 부모 클래스까지 모두 이해하고 있어야 한다. 하나의 연산을 재정의하기 위해서는 상속받는 연산을 호출하는 때가 언제인지, 제약 사항은 무엇인지 다 알고 있어야 한다. 이는 부모 클래스에 대한 종속성을 심화시키며, 객체 합성과 위임으로 훨씬 유연하게 대처할 수 있다. 물론 객체 합성을 많이 사용한 시스템은 이해하기 어려워지므로, 많은 디자인 패턴은 상속과 합성의 두 가지 방법을 적절히 조합하여 사용한다.
    • 가교(Bridge), 책임 연쇄, 장식자(Decorator), 감시자(Observer), 전략
  8. 클래스 변경이 불편
    • 클래스 변경하는 작업이 때로는 단순하지 않을 때가 있는데, 예를 들어 어떤 부분을 변경하면 기존 서브 클래스의 다수를 수정해야 될 때가 있다. 디자인 패턴에서는 이런 환경에서 클래스를 수정하는 방법을 제시한다.
    • 적응자(Adapter), 장식자(Decorator), 방문자


생성 패턴

생성 패턴은 인스턴스를 만드는 절차를 추상화하는 패턴이다. 이 범주에 속하는 패턴들은 객체를 생성/합성하는 방법이나 객체의 표현 방법을 시스템과 분리해준다. 시스템이 어떤 구체 클래스를 사용할지에 대한 정보를 캡슐화하며, 이들 클래스의 인스턴스들이 어떻게 만들고 서로 연결되는지를 사용자로부터 숨겨준다.

생성 패턴을 통해, 무엇이 생성되고 누가 생성하며, 어떻게 생성되는지, 언제 생성할 것인지를 결정하는데 유연성을 확보할 수 있다.

클래스 생성 패턴이 인스턴스로 만들 클래스를 다양하게 만들기 위한 용도로 상속을 이용하는 한편, 객체 생성 패턴은 인스턴스화 작업을 다른 객체에게 떠넘길 수도 있다.

생성 패턴들은 여러 개인데, 상황에 따라 상호보완적일 수도 있고 경쟁적일 수도 있다. 즉, 문제를 해결하는데에 있어서 어느 생성 패턴을 사용할지 고민을 해야 한다. 생성 패턴 간에는 매우 밀접한 관련성이 있다.


구조 패턴

구조 패턴은 더 큰 구조를 형성하기 위해 어떻게 클래스와 객체를 합성하는가와 관련된 패턴이다.

구조 패턴에서 구조나 동작 방법을 살펴보면 각 패턴들끼리 구현 방식이 거의 비슷하다. 사실 코드와 객체를 구조화하기 위해 언어가 제공하는 작은 범위의 개념 (상속이나 참조자를 통한 객체 합성)을 이용하므로, 각 패턴의 구조가 비슷하다.


적응자(Adapter) 패턴과 가교(Bridge) 패턴

이 두 패턴은 둘 다 다른 객체에 대한 직접 접근 대신, 우회적인 방법으로 접근하여 유연성을 증대시킨다. 이 두 패턴은 비슷해보이지만 큰 차이는 목적이 무엇인가이다.

적응자 패턴의 목적은 이미 존재하는 두 인터페이스 간의 불일치를 해결하는 것이다. 따라서 가교 패턴과는 다르게 이 적응자 패턴을 사용할 때는 추상 개념, 기능과 구현을 독립적으로 발전시키는 방법은 고려하지 않는다. 이에 비해 가교 패턴은 추상 개념, 기능과 구현을 따로 발전시키고 서로 연결시키려는 것이 주 목적이다.

이 차이로 인해 적응자 패턴과 가교 패턴의 적용 시점이 다르다. 적응자 패턴은 이미 개발이 완료되어 운영 중인 클래스들 사이에서 호환되지 않는 부분이 있을 때 적용하면 큰 효과를 볼 수 있고, 가교 패턴은 보통 개발자가 추상적 개념을 구현하는 방법이 여러가지이고, 이를 각 독립적으로 진화할 수 있음을 파악한 상태에서 적용한다. 그래서 대부분 적응자 패턴은 설계가 완료된 후, 가교 패턴은 완료되기 전에 적용된다.

퍼사드 패턴에서 퍼사드는 여러 클래스에 대한 적응자로 볼 수도 있지만, 적응자는 기존 인터페이스를 재사용하여 서로 다른 두 인터페이스를 일치시키기 위해 노력하는 객체이고, 퍼사드는 여러 객체를 모아 새로운 인터페이스를 정의하려는 것이 주목적이다.


복합체 패턴, 장식자 패턴, 프록시 패턴

복합체 패턴과 장식자 패턴은 같은 타입의 인스턴스를 가리킬 수 있다는 점, 여러 객체를 조직화하기 위해 재귀적 객체 합성 기법을 사용한다는 점에서 구조가 비슷하다. 특히 장식자 객체가 객체를 하나만 가지는 약화된 복합체 패턴의 복합 객체로도 보여서 이 두 패턴의 구조는 비슷해보이지만 그 목적은 전혀 다르다.

장식자 패턴은 상속을 사용하지 않고 객체에 새로운 부가 기능을 추가하는 것이 그 목적이다. 이에 비해 복합체 패턴은 클래스 구조화에 초점을 맞춘 것으로 관련된 객체를 통일된 인터페이스로 다룰 수 있도록 일관성에 초점을 맞춘다.

프록시 패턴 또한 장식자 패턴과 비슷한데, 모두 같은 타입의 인스턴스를 가리켜 간접적으로 접근하게 한다는 점, 실제 객체에 대한 참조자를 관리하는 공통점이 있지만 역시 두 패턴의 목적은 다르다.

프록시는 장식자와는 다르게 동적으로 어떤 기능을 추가 / 제거하지는 않는다. 프록시의 목적은 서비스를 제공하는 대상에 대한 참조자를 직접 관리하는 불편함을 해결하려는 것이다. 장식자 패턴은 동적으로 기능을 추가하는 것에 목적을 두므로 재귀적 객체의 합성은 매우 중요한 요소이다. 하지만 프록시 패턴에게는 별로 의미가 없다.

구조는 비슷해보여도 패턴 간의 이런 차이는 매우 중요하다. 패턴들끼리 각기 서로 다른 객체지향 설계의 문제를 해결하려는 목적이 있기 때문이다.


행동 패턴

행동 패턴은 어떤 처리의 책임을 어느 객체에 할당하는 것이 좋은지, 알고리즘을 어느 객체에 정의하는 것이 좋은지를 다룬다. 애플리케이션에 따라 행동이 다른 객체의 책임으로 두거나 수행해야 할 알고리즘이 대체되어야 할 때가 있는데 이러한 변화의 개념을 만족시키는 것이 행동 패턴이다.

행동 패턴은 클래스나 객체에 대한 패턴을 정의하는 것이 아니라, 그들 간의 교류 방법에 대해 정의한다. 이러한 패턴들은 런타임에 수행하기 어려운 복잡한 제어 구조를 패턴화시킨 것이다.


Tags:
Stats:
0 comments