본문 바로가기

Computer Sci./Design Pattern

<GoF의 디자인패턴> 1. 서론

내가 지금까지 알던 디자인 패턴은 이런 종류였다.

요즘에 실무에서 프론트엔드 개발을 하기 시작하면서, 과거에 혼자서 프로젝트를 할 때와 다른 점들이 몇 가지가 보이기 시작했다. 그 중에 하나가 디자인 패턴인데, 아직도 나는 디자인 패턴을 왜 써야 하고 또 잘 쓰려면 어떻게 써야 하는지에 대해서 잘 모른다. 그래서 주말에 조금씩 디자인 패턴을 공부해 보기로 했다. 교재는 <GoF의 디자인 패턴>이고 디자인 패턴 관련된 책 중에서 오랫동안 많은 사람들한테 읽혀진 책 중 하나이다. 책의 내용을 정리하면서, 최근에 추가되거나 수정된 내용들을 적절하게 업데이트 하는 식으로 공부 및 포스팅을 작성해 보려고 한다.

 

개발자들은 혼자서 프로젝트를 하는 경우보다 여럿이 함께 하는 경우가 훨씬 더 많다. 그리고 혼자 하더라도 그 사람이 처음부터 끝까지 프로젝트를 책임지고 한다는 보장은 없다. 그렇기 때문에 소프트웨어 개발을 할 때, 개발자들이 자신의 방식대로 가지각색으로 하는 것 보다는 뭔가 일정한 규칙성을 가지고 개발을 하면 생산성 측면에서 유리할 수 밖에 없다. 또한 단순히 개발을 빨리 할 수 있는 것 뿐만 아니라, 자신이 예전에 짰던 코드 혹은 남이 짠 코드를 빠르게 이해하고 유지보수하는 측면에서도 상당한 시간을 아낄 수 있다. 결국 디자인 패턴은 소프트웨어 엔지니어들이 올바른 설계를 빠르게 만들 수 있게 해주고, 또 반복적으로 일어날 수 있는 문제들을 예방하거나 빠르게 해결할 수 있는 방법을 제시해 준다는 점에서 충분히 사용할 만한 가치가 있다.

 

건축가인 크리스토퍼 알렉산더는 "각 디자인 패턴은 기존 환경 내에서 반복적으로 일어나는 문제들을 설명한 후, 그 문제들에 대한 해법의 핵심을 설명해 준다"고 말한다. 디자인 패턴은 단순하게 연결 리스트나 해시 테이블을 클래스로 표현하고 그것 자체로 다시 쓸 수 있도록 설계하는 문제를 어떻게 푸느냐에 관한 것이 아니다. 디자인 패턴은 말 그대로 패턴으로 표현될 수 있는 소프트웨어 개발에서 자주 사용되는 설계 방법들이 어떤 문제를 지금까지 자주 만들었고 그 문제를 어떻게 해결할 수 있을지에 대한 설계를 객체와 클래스로 나타낸 설명이다.

 

 

우리가 많이 들어본 MVC(Model-View-Controller) 패턴을 가지고 설명을 해 보려고 한다. MVC 패턴은 3가지 객체로 구성되어 있다. 모델(Model)은 응용 프로그램 객체이고, 뷰(View)는 스크린에 모델을 디스플레이 하는 방법이며, 컨트롤러(Controller)는 사용자 인터페이스가 사용자 입력에 반응하는 방법을 정의한다. 이전에는 이 객체들을 모두 하나의 객체로 처리하였지만, 유연성과 재사용성을 증가시키기 위해 MVC는 이들 간의 결합도를 없앤다. 

 

뷰와 모델은 서로 등록/통지(subscribe/notify) 프로토콜을 만들어서 종속성을 없애준다. 그래야 뷰는 어떠한 데이터를 모델로부터 받든지 항상 일정한 규칙의 외형을 유지하고 모델은 데이터가 바뀔 때 마다 뷰에 데이터만 온전하게 전달해 주면 된다. 이 역할 분리가 제대로 될 경우 하나의 모델을 가지고 여러 뷰를 보여주는 것이 가능하게 된다. 객체는 변경 반영이 필요한 다른 객체들을 알 필요가 없게끔 객체를 분리한다. 이러한 설계를 일반화 한 것이 감시자 패턴이다.

사용자가 이러한 뷰에 대해서 어떠한 이벤트를 부여할 수도 있다.(클릭, 값을 입력, 커서 갖다대기 등) 그러한 경우 MVC에서는 컨트롤러 객체를 통해 반응 방법을 캡슐화 한다. 컨트롤러의 클래스 계층을 통해 기존 방식과 다른 방식을 새로운 컨트롤러로 정의할 수 있다. 특정 대응 전략을 구현하기 위해서는 뷰 클래스가 컨트롤러 서브클래스의 인스턴스를 사용한다면, 다른 전략을 구현하기 위해 현재의 컨트롤러 인스턴스를 다른 종류의 컨트롤러 인스턴스로 대체만 하면 된다. 이러한 뷰와 컨트롤러의 관계는 전략 패턴의 한 예시이다. 전략 패턴은 알고리즘을 표현하는 객체로 정적 또는 동적으로 알고리즘을 대체하고자 할 때 유용한 방식이다.

 

디자인 패턴은 종류가 23가지나 되는데, 저마다 추상화 수준이 천차만별이다. 따라서 이러한 패턴을 좀 더 효율적으로 사용하기 위해서는 두 가지 기준을 가지고 분류하게 된다. 첫 번째 분류 기준은 목적이다. 이는 패턴이 무엇을 하는지를 정의한다. 패턴은 생성, 구조, 행동 중 한 가지의 목적을 가진다.

 

  • 생성 패턴 : 객체의 생성 과정에 관여

  • 구조 패턴 : 클래스나 객체의 합성에 관한 패턴

  • 행동 패턴 : 클래스나 객체들이 상호작용하는 방법과 책임을 분산하는 방법

 

두 번째 기준은 범위이다. 패턴을 주로 클래스에 적용하는지, 아니면 객체에 적용하는지를 구분할 때 사용한다. 

 

  • 클래스 패턴 : 클래스와 서브클래스 간의 관련성을 다루는 패턴, 컴파일 타임에 정적으로 결정됨.

  • 객체 패턴 : 객체 관련성을 다루는 패턴, 런타임에 변경할 수 있으며 동적인 성격을 가짐.

 

 

디자인 패턴은 객체 지향 설계자들이 매일 부딪히게 되는 많은 문제들을 다양한 방법으로 해결해 준다. 이번에는 디자인 패턴이 어떤 해결책을 제시해 주는지에 대해서 설명해 보려고 한다.

 

객체지향 프로그래밍은 객체로 만들며, 객체는 데이터와 이 데이터에 연산을 가하는 프로시저(procedure)를 함께 묶은 단위이다. 프로시저는 일반적으로 메서드라고 한다. 객체는 요청을 받거나 메시지를 받으면 메서드를 통해 연산을 수행한다. 이 때 요청은 객체가 연산을 실행하게 하는 유일한 방법이고, 연산은 객체의 내부 데이터의 상태를 변경하는 유일한 방법이다. 이렇게 접근의 제약으로 객체의 내부 상태는 캡슐화(encapsulate)된다고 한다. 

 

객체 지향 설계 시 시스템을 구성할 객체를 분할하는 작업은 어렵다. 여러가지 요인을 고려해야 하는데, 여기에는 캡슐화, 크기 정하기, 종속성, 유연성, 성능, 진화, 재사용성 등이 있다. 이에 따라 객체지향 설계 방법론은 서로 다른 방법으로 접근한다. 명사와 동사를 추출해서 각각을 클래스와 연산으로 만드는 방법도 있고, 시스템의 협력 관계나 책임성을 중심으로 설계하는 방법도 있다. 하지만 여기에는 정답이 없고, 따라서 디자인 패턴은 이러한 결정을 할 때, 즉 덜 명확한 추상적 개념과 이것을 잡아낸 객체를 알아보는 데 도움을 준다.

 

객체가 선언하는 모든 연산은 연산의 이름, 매개변수로 받아들이는 객체들, 연산의 반환 값을 명세한다. 이들은 연산의 시그니처라고 한다. 인터페이스(interface)는 객체가 정의하는 연산의 모든 시그니처들을 일컫는 말로 객체의 인터페이스는 객체가 받아서 처리할 수 있는 연산의 집합이다. 인터페이스 개념은 객체지향 시스템에서 가장 기본적인 개념이다. 객체는 인터페이스로 자신을 드러낸다. 외부에서 객체를 알 수 있는 방법은 인터페이스밖에 없기 때문에 인터페이스를 통해서만 처리를 요청할 수 있다. 객체의 인터페이스는 구현에 대해서는 전혀 알려주지 않기 때문에 서로 다른 객체는 인터페이스에서 정의한 요청의 구현 방법을 자유롭게 선택할 수 있다.

 

객체는 요청이 들어왔을 때 런타임 시 연결되어 처리되는데 이를 동적 바인딩(dynamic binding)이라고 한다. 동적 바인딩은 요청이 어떻게 구현되어 어떤 결과를 만들어 낼지를 런타임에 결정할 수 있음을 의미한다. 이를 통해 프로그램이 기대하는 객체를 동일한 인터페이스를 갖는 다른 객체로 대체할 수 있게 한다. 이러한 대체성을 다형성(polymorphism)이라고 한다. 다형성은 사용자의 정의를 단순화하고 객체 간의 결합도를 없애며, 프로그램 실행 중에는 서로 간의 관련성을 다양화 할 수 있게 해준다. 사용자는 어떤 특정 인터페이스를 제공하는 객체에게 요청하는 것으로 프로그래밍하지만, 런타임에 그 객체를 동일한 인터페이스를 제공하는 다른 객체로 대체할 수 있다.

 

디자인 패턴은 인터페이스에 정의해야 하는 중요 요소가 무엇이고 어떤 종류의 데이터를 주고받아야 하는지 식별하여 인터페이스를 정의하도록 도와준다. 그리고 때론 인터페이스에 넣지 말아야 할 것이 무엇인지를 알려주기도 한다. 예를 들면 메멘토 패턴은 객체의 내부 상태를 어떻게 저장하고 캡슐화해야 하는지를 정의함으로서 객체가 나중에 그 상태로 복구할 수 있는 방법을 알려준다. 또한 디자인 패턴은 인터페이스 간의 관련성도 정의한다. 특히 클래스 간에 유사한 인터페이스를 정의하도록 하거나 클래스의 인터페이스에 여러가지 제약을 정의한다. 예를 들면, 방문자 패턴에서 방문자 인터페이스는 방문자 객체가 방문하는 객체들의 클래스 인터페이스를 그 방문자 인터페이스에 모두 반영하도록 한다.

 

지금부터는 객체를 구현하는 방법에 대해 알아본다. 대표적으로 객체를 구현하는 방법은 클래스에서 정의하는 것이다. 클래스는 객체의 내부 데이터와 표현 방법을 명세하고, 그 객체가 수행할 연산을 정의한다. 객체는 클래스를 인스턴스로 만듦으로써 생성된다. 즉, 객체는 클래스의 인스턴스인 것이다. 클래스의 인스턴스화 과정은 객체의 내부 데이터(인스턴스 변수)에 대한 공간을 할당하고, 이 데이터들을 연산과 관련짓는 것이다.

 

새로운 클래스는 기존 클래스에 기반을 둔 클래스 상속을 통해 정의할 수 있다. 서브 클래스(자식 클래스라고도 한다)가 부모 클래스를 상속하면, 부모 클래스가 갖는 모든 데이터와 연산을 서브 클래스가 갖게 된다. 추상 클래스는 모든 서브 클래스 사이의 공통적인 인터페이스를 정의한다. 추상 클래스는 정의한 모든 연산이나 일부 연산의 구현을 서브 클래스에게 넘긴다. 주의해야 할 점은 추상 클래스에서 정의한 모든 연산이 구현된 것이 아니므로, 추상 클래스는 인스턴스를 생성할 수 없다는 점이다. 서브 클래스는 부모 클래스가 정의한 행동을 재정의하거나 정제할 수 있다. 이렇게 부모 클래스에서 정의한 연산의 구현을 바꾸는 작업을 오버라이드라고 한다. 믹스인 클래스는 다른 클래스에게 선택적인 인터페이스 혹은 기능을 제공하려는 목적을 가진 클래스이다.  인스턴스로 만들 의도가 없다는 점에서 추상 클래스와 비슷하다.

 

클래스와 타입(인터페이스)의 차이는 명확하게 알고 넘어가는 것이 좋다. 객체의 클래스는 그 객체가 어떻게 구현되느냐를 정의한다. 반면 객체의 타입은 그 객체의 인터페이스, 즉 그 객체가 응답할 수 있는 요청의 집합을 정의한다. 하나의 객체가 여러 타입을 가질 수 있고 서로 다른 클래스의 객체들이 동일한 타입을 가질 수 있다. C++ 같은 언어에서 클래스는 객체의 타입과 구현 모두를 의미한다. 컴파일러는 변수에 할당된 객체 타입이 변수 타입의 서브클래스인지 점검하지 않는다.

 

클래스 상속과 인터페이스 상속의 차이에 대해서 알아보자. 클래스 상속은 객체의 구현을 정의할 때 이미 정의된 객체의 구현을 바탕으로 한다. 다른 말로 하면 코드와 내부 표현 구조를 공유하는 메커니즘인 것이다. 반면 인터페이스 상속(서브 타이핑)은 어떤 객체가 다른 객체 대신에 사용될 수 있는 경우를 지정하는 메커니즘이다. 인터페이스 상속 관계에 있다면 프로그램에는 슈퍼 타입으로 정의하지만 런타임에 서브타입의 객체로 대체할 수 있다. 많은 언어가 이 두 개념을 구분하지 않기 때문에 두 개념을 혼동하기가 쉽다.

 

클래스 상속은 기본적으로 부모 클래스에서 정의한 구현을 재사용하여 응용프로그램의 기능성을 확장하려는 메커니즘이다. 따라서 새로운 구현을 하는데 비용이 들지 않는다. 하지만 구현의 재사용이 전부는 아니다. 상속이 가진 다른 기능들 중에는 동일한 인터페이스를 갖는 객체군을 정의하는 것이 있다. 객체군을 정의하는 것은 중요한데, 그 이유는 이것으로 다형성을 끌어낼 수 있기 때문이다. 상속을 적절하게 이용하면, 모든 클래스는 추상 클래스를 상속하도록 하여 인터페이스를 공유할 수 있게 된다. 추상 클래스를 정의하고 인터페이스 개념으로 객체를 다룰 때 얻을 수 있는 두 가지 이점은 다음과 같다.

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

이렇게 되면 서브 시스템 간의 종속성이 없어지며, 이러한 이유로 구현이 아닌 인터페이스에 따라 프로그래밍 하라 라는 객체지향 개발 원칙이 나오게 된 것이다.

 

재사용을 하기 위한 방법으로 여러가지가 있는데 여기에서는 상속, 합성, 위임, 그리고 매개변수화된 타입(제네릭)에 대해서 알아본다. 대표적인 방법은 위에서 언급한 클래스 상속이며, 화이트박스 재사용이라고도 한다. 이는 서브클래싱, 즉 다른 부모 클래스에서 상속받아 한 클래스의 구현을 정의하는 것으로 "화이트박스" 처럼 내부를 볼 수 있다는 점에서 이렇게 명명이 되었다. 또 다른 방법은 객체 합성으로 블랙박스 재사용이라고 한다. 다른 객체를 여러개 붙여서 새로운 기능 혹은 객체를 구성하는 것이다. 객체의 내부는 공개되지 않고(블랙박스) 인터페이스를 통해서만 재사용이 된다.

 

상속과 합성은 서로 장단점을 가지고 있다. 클래스 상속은 컴파일 시점에서 정적으로 정의되고 프로그래밍 언어가 직접 지원하므로 그대로 사용하면 된다. 반면 클래스 상속의 단점으로는 런타임에 상속받은 부모 클래스의 구현을 변경할 수 없으며(상속은 컴파일 시점에 결정되기 때문), 서브클래스는 부모 클래스가 정의한 물리적 표현을 전부 혹은 일부 상속받기에 캡슐화를 파괴한다고 볼 수도 있다는 점이 있다.

 

객체 합성은 한 객체가 다른 객체에 대한 참조자를 얻는 방식으로 런타임에 동적으로 정의된다. 합성은 객체가 다른 객체의 인터페이스만을 바라보게 하기 때문에, 인터페이스 정의에 더 많은 주의를 기울여야 한다. 객체는 인터페이스에서만 접근하므로 캡슐화를 유지할 수 있다. 그리고 객체는 인터페이스에 맞춰 구현되므로 구현 사이의 종속성은 확실히 줄어든다. 이러한 이유로 객체 합성을 클래스 상속보다 더 선호하는 경우가 많다.

 

위임은 합성을 상속만큼 강력하게 만드는 방법이다. 위임에서는 두 객체가 하나의 요청을 처리한다. 수신 객체가 연산의 처리를 위임자에게 보내는데, 이는 서브 클래스가 부모 클래스에게 요청을 전달하는 것과 유사한 방식이다. 상속에서는 상속받은 연산이 늘 수신 객체를 참조하게 되는데, 위임과 동일한 효과를 얻으려면 수신 객체는 대리자에게 자신을 매개변수로 전달해서 위임된 연산이 수신자를 참조하게 해야 한다. 위임의 장점은 런타임에 행동의 복합을 가능하게 하고, 복합하는 방식도 변경해 준다는 점이다. 반면 위임이 갖는 단점은 동적이며, 매개변수화된 소프트웨어는 정적인 경우보다 더 구조를 이해하기 어렵다는 점이다. 런타임에도 비효율적일 수 있다.

 

기능에 재사용에 이용할 수 있는 다른 방법이 매개변수화된 타입으로 다른 이름으로는 제네릭이라고 불린다. 이 기법은 타입을 정의할 때 타입이 사용하는 다른 모든 타입을 다 지정하지 않은 채 정의한다. 그리고 미리 정의하지 않은 타입은 매개변수로 제공한다. 지금까지 객체지향 시스템에서 행동을 복합할 수 있는 방법 세가지를 알아보았다. 이를 정리하면서 이번 포스팅을 마무리를 해 보고자 한다.

  1. 서브 클래스에 의해 연산을 구현하는 방법 : 상속
  2. 정렬 루틴으로 전달된 객체 : 합성
  3. 제네릭으로 정의한 클래스의 인자로 원소를 비교할 함수 이름을 명시 : 매개변수화

 

 

참고자료

  1. <GoF의 디자인패턴> 에릭 감마 외 3인 저