무엇이든 다룰 수 있어야 하는 한국인 프로그래머와 객체지향

 

Korean Programmer

C++인터페이스(Interface)

 

미켈란젤로가 183 평방 미터나 되는 시스티나 성당의 천장 벽화를 그릴 때에도 천장의 한 구석에 조그마한 인물을 꼼꼼히 그려 넣었다. 누가 알아보겠냐는 질문에 미켈란젤로는 내가 알지라고 답했다. 우리는 개발을 하면서 적게는 바로 옆 동료가, 많게는 세상에 수많은 개발자들이 개발하는 라이브러리나 프로그램을 제작 할 수 있다. 물론 개발을 하면서 다른 어떠한 요소보다 주어진 상황을 좁히고 요구 명세를 수렴 시키는 것이 중요하다. 하지만 미켈란 젤로가 그러 했듯이 현재 요구사항에 딱 맞는 프로그램을 완성하는 것 만이 끝은 아니다. 제작될 프로그램이나 라이브러리에서 사용자를 고려하여 확장성에 물꼬를 틔워 주는 일은 개발자에게 있어 시스티나 성당 구석의 조그마한 인물을 그려 주는 일만큼이나 중요하다. 객체 지향 언어에서 확장성의 핵심에 있는 인터페이스의 정의를 알아보고 이의 실례를 들어보자.

 

명수 | byeguns@sw블로그.넷 / http://www.swblog.net

필자는 현재 삼성전자 메모리사업부의 플래시 소프트웨어 개발 관련 연구원으로 플래시 메모리와 관련된 파일 시스템과 커널 드라이버들을 제작하고 있다. 컴퓨터 공학을 전공하고 Haskell Scheme등의 Functional language를 좋아하며, 학부 시절, C++과 관련하여 3개의 논문과 대회 수상을 한 경험이 있다. 올해 들어와서 Class와는 전혀 다른 작업을 통해 조금은 OOP와 거리가 멀어진 길을 걷고 있지만, 항상 언어에 대한 갈등은 지우지 못하는 이제 막 걸음마를 시작한 부족한 그릇의 개발자이다.

 

 

필자가 대한민국 개발자와 객체지향 이야기라는 주제로 칼럼을 쓴지도 벌써 10개월이 되었다. 2007년 마지막 기고 글에 들어가기 전에 필자의 바쁜 생활을 잘 이해 해주시고, 옆에서 너그러이 지켜 봐주신 마소 문경수 기자님에게 가장 먼저 고맙다는 인사를 하고 싶다.

객체 지향 언어에서 고급 기술들이 하나하나 꽃이었다면 이제 그 꽃을 좀 더 편리하도록 만들어주는 기술을 마지막으로 대한만국 개발자와 객체 지향 이야기를 마무리 하고자 한다. 설계자, 사용자 모두에게 있어서 편리함을 제공하는 인터페이스 기술을 알아보고 C++에서는 어떻게 적용 할 수 있는지 그 실례를 살펴 보자.

 

추상 클래스(Abstraction Class)

이제껏 우리가 상속을 설계하고 또 디자인 이슈들을 살펴 보면서 만들어 내었던 클래스들을 다시 한번 정리 해보자. <그림 1>에서처럼 객체 지향 패러다임에서 클래스는 구체 클래스(Concrete Class)와 추상 클래스(Abstract Class)로 나눌 수 있다. 구체 클래스는 구현된 시스템에서의 클래스 계층도에 따라서 그것이 단말 클래스(Leaf class)인지 아니면 또 다른 상속 관계에 있는 비단말 클래스(Non-leaf class)인지로 구분 된다. 여기서 우리가 집어 보고 갈 것이 추상 클래스에 대한 고찰이다.

사용자 삽입 이미지

<그림 1>  객체 지향 패러다임에서 클래스 계층.

 

 

추상 클래스라는 용어는 때로 약간의 혼돈을 주기도 한다. 왜냐면 추상 클래스라는 용어가 두 가지 정도의 의미를 내포하고 있기 때문에 단지 추상클래스로 구현하라고 하면 설계도나 또는 코드의 문맥에서 이 추상 클래스가 갖는 의미를 찾아야 할 것이다. 추상 클래스의 가장 보편적인 교과서적인 의미는 오브젝트를 인스턴시에이션 시키지 않는 클래스를 말한다. 이러한 의미의 추상 클래스는 <그림 1>에서 쉽게 파악 할 수 있다. 추상 클래스는 Object Instance라는 객체를 통해 인스턴시에이션을 가지지 않기 때문에 구체 클래스와 달리 그 실체가 없다. 다시 말해 프로그램에서 직접적으로 객체를 만들지 않는 클래스로 단순히 클래스 계층도에서 중복되는 코드들을 떠맡기 위해 사용되거나 하위 클래스의 인터페이스를 명세 하기 위해 사용되는 클래스이다. 이렇게 사용되는 예를 살펴 보자. 우리가 흔하게 사용되는 MFC에서 추상 클래스는 CObject라는 이름으로 존재 한다.

 

class AFX_NOVTABLE CObject

{

public:

 

// 런타임 클래스를 인출, 즉 실행시에 클래스에 대한 정보를 쿼리 할 수 있다.

         virtual CRuntimeClass* GetRuntimeClass() const;

         virtual ~CObject() = 0;  // 가상 소멸자.(가상 소멸자에 대한 이야기는 step 9를 참조)

 

         //  메모리 관련 operator들을 오버로딩.

         void* PASCAL operator new(size_t nSize);

 

 

// Attributes

public:

         BOOL IsSerializable() const;

         // 런타임시 클래스 식별

 

         BOOL IsKindOf(const CRuntimeClass* pClass) const;

 

// Overridables

// 데이터 저장 및 로딩을 수행

         virtual void Serialize(CArchive& ar);

 

 

};

<코드 1>  MFC 추상 클래스.

 

MFC의 설계를 살펴 보자는 것이 목적이 아니므로 조금 간추려서 그 내용을 보도록 하자. CObject는 순수 가상함수(현 코드에서는 소멸자)를 최소 한 개를 가지는 ABC이다.(개인적으로 MFC의 설계는 시대에 비하여 아주 잘 정의 된 계층도라고 생각한다. 이에 대해서는 개발자들마다 여러 의견이 존재 할 수 있지만, 필자에게 RTTI가 지원 되지 않던 시절에 그를 위한 런타임 클래스의 스킴을 넣은 것으로 보나 개발자들을 편리를 위해 시리얼라이즈와 같은 것들을 디파인하고 각기 다른 가상 함수를 클래스 위저드를 통해 폼과 연동 시킨 것은 객체지향에 대한 충분한 이해와 경험을 바탕으로 한 대가들의 훌륭한 아키텍트 결과물이라고 생각한다.) 또 시리얼라이즈와 같은 개발자를 위한 극도의 인터페이스의 의미가 훨씬 강하다는 것은 이 순수 가상함수를 통해 알 수 있다. 이러한 CObject는 추상 클래스로서 하위 클래스의 인터페이스를 명세 하는 목적을 가지고 있다. 하위 클래스의 인터페이스를 명세한다는 이야기는 CObject를 통해 파생되는 모든 클래스에서 사용 될 수 있는 메서드의 각 시그니쳐를 정의 한다라고 할 수 있다. CObject로부터 상속을 받게 되는 개발자의 모든 코드는 데이터 저장 및 로딩을 수행하기 위해서 시리얼라이즈를 사용 할 수 있고 이러한 시리얼라이즈는 아카이브를 통해서 가능 하다는 것을 미리 정의 하는 것이다.

 

인스턴시에이션(Instantiation) 존재하는 추상 클래스(Abstraction class)

이상하게 들릴지도 모르겠지만, 추상클래스의 첫 번째 의미가 인스턴시에이션을 시키지 않는 일관된 메서드들의 행동에 대한 정의라고 한다면 이와 반대로 클래스를 인스턴시에이션을 시켜서 행동들을 정의 하는 추상 클래스가 그 두 번째 의미가 된다. 인스턴시에이션을 통해 추상 클래스를 지원하는 최초 언어는 Ada에서 였다. Ada에서는 상속을 통한 코드의 재사용이 불가능 하였기 때문에 추상 클래스를 통해서 코드를 재사용하는 변칙(?)기법을 가지고 있었다. 필자나 독자들이 좀 더 쉽게 이해 할 수 있는 예제는 C++의 템플릿(Template)클래스이다. 템플릿 클래스들은 그 이름 그대로 여러 종류의 클래스를 대표하는 범용적인 클래스로서 개발자의 코딩 부담을 줄여 주기 위해 존재 한다. 템플릿이 가장 잘 정의되어 사용되는 예는 STL과 같은 표준 템플릿 라이브러리 등이다. 템플릿은 Ada에서 그러하듯이 기본적으로 상속을 통한 코드 재사용으로 볼 수 없다. 예를 들어 C++경우를 생각 해보자. 범용적인 Queue를 사용하기 위해 우선적으로 Queue에 대한 클래스를 정의 한다(물론 우리가 부르는 표준 템플릿 라이브러리는 이러한 클래스 정의를 다 하고 나서 우리에게 보여주는 덩어리이기 때문에 인스턴스 클래스로서 재정의 하기만 하면 된다.) 정의된 클래스를 가지고 우리는 Queue<int>, Queue<float>, Queue<UserClass>식으로 Queue의 클래스에 인스턴스 클래스를 작성하여 사용한다. 다른 매개인자(Parameter)를 갖는 클래스(Parameterized class),범용 클래스(Generic class)와 같은 이름으로 불리기도 하는 템플릿은 이렇게 인스턴스 클래스를 작성하여 쓰는 것이 일반적인데, 인스턴스 클래스가 인스턴스 객체가 아님을 주의 해야 한다.인스턴스 클래스는 bound element라고 부르기도 하는데 추상 클래스가 타입(Type)을 인자(Argument)처럼 처리하기 때문에 인스턴스 클래스를 선언하는 행위는 인스턴스 객체가 될 수 없다. 템플릿은 우리가 처음 이야기 했던 인스턴시에이션을 하지 않는 추상 클래스와 인스턴시에이션 문제 외에 현저하게 다른 특징을 가지는데 그것은 추상 클래스와 관련되는 시스템의 작업이 모두 정적으로(Static하게) 컴파일 타임에 이루어진다는 것이다. 템플릿과 같은 추상 클래스가 컴파일 타임에 정적으로 모두 할당 되어 버린다는 것은 이를 추상 클래스로 볼 수 있는 가에 대한 질문을 남긴다. 우리가 이제까지 계속 언급해오던 폴리모피즘을 위한 동적 바인딩 시스템을 사용 할 수 없게 되고 상속과 다른 계통의 정제화과정(refinement)을 통해 코드를 재사용하게 되는 템플릿 클래스가 추상 클래스로 정의 될 수 있는 것인가 하는 의문이 생기긴 하지만 현재는 추상 클래스로 인정하고 있는 추세이다.

 

 

 

템플릿(Template) 형태의 추상 클래스에 대한 고찰.

C++에서 추상 클래스가 도입 된 것은 C++3.0 버전 이후 시점이다. 앞서 언급 되었던 것과 같이 C++에 템플릿 형태의 추상 클래스가 도입된 것은 템플릿의 기능이 개발자의 코딩 분량을 현저하게 감소 시키기 때문이다. 코딩 분량의 감소라는 매력과 자료구조와 특정 알고리즘을 몰라도 개발을 할 수 있다는 것은 초보 개발자에게 엄청난 메리트로 작용 할 수 있다. 하지만 우리는 여기서 간과 하지 않아야 하는 점이 있는데 그것은 코딩 분량은 줄지만 실제 코드 사이즈 즉, 오브젝트 코드의 분량이 줄어 드는 것은 아니라는 것이다. 컴파일러는 템플릿의 실체를 생성하기 위해 매크로 확장(Macro extension)방식을 이용 한다. 이러한 매크로 확장 방식은 알고 있다시피 단지 매크로를 전처리시에 교체하는 역할 밖에 하지 않기 때문에 실행 시의 이미지 사이즈는 템플릿을 사용하지 않고 잘 설계된 프로그램 보다 템플릿을 사용한 시스템이 크다. 이러한 이미지 사이즈 문제뿐만 아니라 프로젝트 운용 입장에서도 어려움을 야기 할 수 있다. UserClass int에 대한 큐 클래스를 제작할 때 Queue<int>, Queue<UserClass>와 같이 템플릿을 사용하여 구현 하는 것 보다 상속의 기능을 이용하여 시스템을 구성하는 것이 훨씬 유연하다. 그 이유는 int, UserClass와 같은 타입에 연관되는 연산들, 예를 들어 Insert()와 같은 연산이 수행 하는 실질적인 행위가 각 타입에 따라 이질적인 형태를 취할 수 있기 때문이다. 만약 각 타입의 클래스에서 insert와 같은 연산이 차이가 없는 비슷한 형태의 골격을 유지한다면 템플릿을 사용하여도 큰 문제가 없지만 서로 다른 양식에 따라 구현 되는 경우에는 템플릿의 사용이 도움이 되지 않는다는 것이다. 또한 템플릿에 들어가는 타입들은 서로 특별한 관련이 없어도 같은 형태의 템플릿 클래스를 통하여 구현이 되기 때문에 LSP문제를 근본적으로 떠 안을 수 밖에 없다.

더욱이 동적 바인딩을 통한 유연성을 확보하기 위해서는 템플릿 사용은 자제 되는 것이 올바르다. 템플릿은 초보 개발자에게는 마법처럼 보일 수 있으나 컴파일러가 매크로 확장을 통해서 단지 코드를 복사하는 사실이라는 것을 안다면 객체지향을 하면서 Copy & paste를 하는 C코드와 특별히 다를 바가 없다는 것을 알게 될 것이다. 실지로 템플릿을 대신에 각 클래스의 골격을 생성하거나 복사해주는 역할을 하는 CASE(Computer Aided Software Engineering) 도구 등을 통해 같은 효과를 취할 수 있다

 

템플릿(Template) 객체지향인가?

필자가 학창 시절에 한참 목에 핏대를 올려가며 이야기 했던 부분이 바로 템플릿이다. 필자는 범용컨테이너를 모르던 시절 대부분의 자료구조를 다 만들어서 사용 했다. 물론 잘 만들어서가 아니라 단지 STL이나 ATL과 같은 템플릿을 몰랐기 때문이다. (물론 현재에는 템플릿을 사용하기 싶어도 사용 할 수 없는 임베디드 영역으로 들어와 버렸기 때문에 그 마법 같은 개발 시간 단축을 경험할 수는 없다.) STL 내부를 조금이라도 공부해본 독자들은 알겠지만, STL이 얼마나 잘 만들어 졌는지를 조금이라도 경험했을 때, 필자는 소프트웨어에서 침범할 수 없는 네버랜드를 본 기분이었다.

지금도 STL과 같은 잘 만들어진 템플릿을 사용하는 것이 검증되지 않은 컨테이너를 만들어서 사용하는 것보다 훨씬 유용하고 어설픈 자료구조 및 알고리즘 보다 정확하고 명확한 퍼포먼스를 보장한다고 생각한다. 하지만 STL과 같은 모태가 되는 템플릿은 객체 지향으로 분류하기는 좀 애매하지 않는가 하는 것이 필자의 견해이다. 탬플릿 형태는 추상화 클래스에 대한 고찰에서도 이야기 되었듯, 재사용 성을 상속이 아닌 정제화를 통해 한다는 것과 정적 바인딩을 사용하지 못한다는 치명적인 약점 이외에도 객체 지향의 원류인 SmallTalk에서 템플릿을 지원 하지 않는 다는 점을 우리는 주목 해볼 필요가 있다. Smalltalk에서 템플릿을 지원하지 않는 이유는 템플릿이 없이 템플릿 보다 더 훌륭한 범용 컨테이너를 제작 할 수 있기 때문이다.

그렇다고 템플릿이 객체지향 언어에서 다중 상속의 사용 만큼 배제되어야 한다는 이야기는 아니다. 템플릿의 유용성은 ATL/COM환경에서 많은 템플릿을 통해 프로그래머의 부담을 경감 시켜 주고 있으며(물론 유사한 EJB(Enterprise Java Beans)환경에서 컴포넌트 제작이 템플릿을 사용하고 있지 않다는 점도 고려 해봐야 할 사항이다) STL과 같은 막강한 라이브러리들은 개발자가 소프트웨어에서 빈번하게 사용되는 자료구조와 알고리즘을 벗어나 좀 더 클래스간의 관계를 통한 시스템 구성을 구현하는데 도움을 준다는 점과 쉽게 개발을 할 수 있도록 도와 준다는 점에서 객체지향에서 없어서는 안될 존재 이다.

하지만 정적 바인딩과 같은 치명적 템플릿 클래스 구현 약점은 C++이 극복해야 할 문제이다. 템플릿을 지원 하지 않는다는 Java의 경우도 Generic programming이라는 타이틀 아래 Java Generics Java 5.0부터 이와 비슷한 기능을 지원 하고 있다. Java Generics가 타입캐스팅에 대한 문제와 컴파일 타임 시 타입 안정성을 제공하는 것과 함께 템플릿 클래스의 정보 은닉 문제(Encapsulation) C++과는 다른 정책을 가지고 있는 것도 C++이 앞으로 발전 할 수 있는 방향을 제시 하고 있지만, Java Generics를 사용하는 클래스들의 모든 인스턴스들은 컴파일과 런타임시 바인딩을 고려 하고 있다는 것은 매우 고무적인 사실이다.(물론 역으로 이야기 해서 Java와 같은 언어가 Java Generics라는 이름을 붙여 C++의 템플릿 기능을 흉내 냈다는 데서 템플릿의 유용성을 다시 한번 확인 해볼 수 있는 기회이기도 하다.)

 

객체지향에 있어서 인터페이스(Interface for OOP)

인터페이스라는 용어는 객체지향과 관련 없이 사용되기 때문에 더더욱 혼돈스러울 가능성이 있다. 일반적인 의미로 사용되는 인터페이스는 컴퓨터 시스템을 구성하는 요소들을 연결하는 점의 뜻이고 객체 지향과 관련하여 인터페이스는 소프트웨어를 작성하는 도구로 사용 되기 때문에 객체 지향 패러다임에서 사용되는 전자의 인터페이스와는 그 정의가 사뭇 다르다. 이전 스텝에서 언급된 바와 같이 객체 지향 문화에서는 하나의 용어가 서로 다른 의미로 사용되는 경우가 자주 있으며 객체 지향에 있어서 인터페이스도 문맥에 따라 조금씩 다른 의미를 한다.

 

스펙으로서의 의미

프로그래밍 시에 사용되는 인터페이스라는 용어가 가지는 하나의 의미는 API에서 사용되는 인터페이스의 의미이다. 이것은 사용자 관점에서 공급된 소프트웨어의 사용법을 나타내는 스펙과 같은 것으로 일종의 Export SDK 정의들과 같다. SDK(Software Development Kit)는 특정 플랫폼에서 응용 프로그램을 개발 하기 위한 함수 집합을 의미하는데 라이브러리 형태로, 제공되는 함수의 용도 목적, 사용법을 말하며 윈도우에서는 Win32 API들이 그러한 예이다. 이러한 스펙은 소프트웨어의 공급자와 소비자를 분리 함에 있어서 구현을 소비자의 사용 측면으로부터 분리하여 감춤에 그 목적이 있고 이러한 정보 은닉을 전제로 한 스펙 생성은 대규모의 프로젝트를 이행하거나 관리 할 때 매우 유용하게 쓰인다.

즉 객체 지향에 있어서 인터페이스라고 함은 어떠한 클래스의 스펙(Specification)라는 용어 대용으로 볼 수 있다.

 

View의 관점

시스템을 개발 하거나 설계를 하다 보면 특정 객체가 가지는 속성 이외에 몇몇 사용자가 그 객체를 바라보는 뷰(View에 의해) 특별한 명세를 제공 해야 하는 경우가 있다. 그 이유는 특정한 객체가 자료 추상화의 원칙에 의해 원래 실 세계에서 가지는 모든 속성과 행동을 정의 하지 못하기 때문이다. 이렇게 어떤 객체가 속하는 클래스에 정의된 기능 이외에 특정한 기능이 필요 할 때 사용되는 추상화 도구가 바로 인터페이스이다.

예를 들어보자. 필자는 개발자로서 직장인이라는 계층 군에 들어가고, 직장인은 봉급을 받거나 일을 하고 문서를 작성하는 행위등이 정의 되어 있다. <그림 >에서 처럼 사람으로 상속을 받아 일을 하는 개발자라는 클래스가 비로서 필자의 클래스가 될 것이다. 하지만 만약 독자들이 필자를 본다면 C++에 대한 글을 쓰는 개발자 정도와 글을 쓰기 위해 swblog.net이라는 소프트웨어 블로그를 운영하는 개발자정도의 특정한 요구명세가 생길 수 있다. 그렇다고 해서 글을 쓰거나 블로그를 운영하는 메서드를 개발자 클래스에 삽입 할 수는 없다. 왜냐하면 모든 개발자가 글을 쓰거

나 블로그를 운영하지는 않기 때문이다.

 

사용자 삽입 이미지

<그림 2>  개발자로서 Interface 고려된 모델.

 

 <그림 2>을 보고 필시 어떤 독자들은 어? 다중 상속인데, 라고 이야기 할 지도 모르겠다. 바로 이전 스텝 9에서 다중 상속의 사용시 그 모델을 사용하지 않는 쪽으로 권고 했기 때문에 더욱 의아해 할 수 있다. 하지만 <그림 2>는 스텝9에서 논의 되었던 다중 상속과 본질자체가 다르다. <그림 2>는 자바의 인터페이스를 통한 다중 상속처럼 이해 될 수 있는데 이에 대해서는 상속에 대하여 구현 상속과 인터페이스 상속, 이 두 가지에 대한 본질적 의미를 이해해야 한다.

 구현 상속은 일반적 의미의 상속과 일맥상통하는데 상속을 통하여 자식 클래스가 부모 클래스의 모든 속성과 메서드 내용을 물려 받는 것을 이야기 한다. 이러한 경우는 사실 구현 모두를 물려 받는 것으로 볼 수 있다. 자식 클래스가 인스턴시에이션이 이루어짐과 동시에 부모 클래스의 데이터 멤버들이 모두 생성되고 생성자에 의해 초기화 되며 메스들의 구현 부분도 그대로 상속되기 때문에 자식 클래스에서 특별히 상위 클래스의 메서드를 오버라이드 하지 않더라도 상속받은 메서드를 그대로 사용 할 수 있다. 이에 반해 인터페이스 상속은 상위 클래스의 데이터 멤버와 메서드를 모두 물려 받는 것이 아니고 부모 클래스의 명세만을 물려 받는 것이다. 명세만을 물려 받는 다는 것은 앞서 말한 스펙의로서의 인터페이스 의미에서 언급 하였듯이 부모 클래스에서 정의된 멤버함수들의 시그니쳐만 물려 받는 것으로 자식 클래스에서는 반드시 부모 클래스에서 정의된 메서드들을 구현 해주어야 한다.

 인터페이스 상속과 구현 상속 일부를 결합한 <그림 2>의 계층도는 개발자와 필자라는 두 가지 클래스를 구현 상속으로 이용하는 것이 아니기 때문에 다중 상속이라고 볼 수 없다. 저번 스텝에서도 잠시 언급 하였듯이 JAVA C#이 다중 상속을 사용하기 위해 인터페이스를 도입했다라고 소개 하거나 인터페이스를 통해 다중 상속을 구현 할 수 있다는 말은 이러한 이유에서 잘못된 설명임을 알 수 있다.

 

범용 자료구조 구현을 위한 인터페이스의 사용

 자료구조를 사용 함에 있어서 우리는 무수한 고민들을 해왔다. 왜냐하면 자료 구조가 행동해야 하는 정의는 특별하게 변함이 없이 일정한데 이 자료 구조의 속성이 계속 변화하기 때문에 우리는 같은 요구사항을 가지는 자료 구조에 대해서도 여러 가지 구현을 생성 해야 했다. 이 문제를 다시 생각 해보자. 본질적으로 어떤 자료구조의 행동을 정의 하는데 있어서 자료구조가 다루어야 하는 속성이 미리 정해 져야 하는 것이 올바른가?

 

C++은 자료 구조의 행동에 대한 정의와 속성을 분리를 템플릿이라는 기술을 통하여 아주 깨끗하게 처리 하였다. 이러한 템플릿의 사용은 범용적인 자료 구조를 구현 하는데 있어서 매우 편리하게 동작 한다. 하지만 앞서 언급 했듯이 만약 Queue와 같은 범용 자료구조에 정수(int), 실수(float), 스트링(string), 사용자 클래스등이 계속 번갈아 가면서 입력 되어야 한다면 어떻게 하겠는가? 템플릿과 같은 방법으로는 이를 해결 할 수 없다. 인터페이스를 사용한다면 템플릿과 달리 동적으로 정수, 실수, 스트링, 사용자 클래스등을 다룰 수 있는 Queue를 정의 할 수 있다.

우선 Queue의 기본적인 행동을 정의할 구체 클래스(Concrete Class)를 생성하고 이 구체 클래스가 처리할 내부 원소인 Entry를 인터페이스로 구현하게 되면 Entry을 상속하여 각 실수, 스트링, 사용자 클래스 등을 구현하면 완전한 범용 자료 구조가 만들어 질 수 있다. 우선 인터페이스를 사용 할 수 있는 자바를 통해서 이러한 Queue를 생성해보자.

 

//

//      범용자료구조로클래스의속성을행동으로부터분리

//

interface QueueEntry {

        public QueueEntry      GetNextEnt();

        public void            SetNextItem(QueueEntry qEnt)

};

 

//

//      미리정의된인터페이를통하여행동만분리정의

//

class Queue {

        QueueEntry tailEnt;

        QueueEntry headEnt;

 

        public Queue()

        {

               tailEnt = null;

               headEnt = null;

        };

 

        public Push(QueueEntry qEnt) 

        {

               if(tailEnt == null)

               {

                       headEnt = qEnt;

                       tailEnt = qEnt;

               }

               else

               {

                       tailEnt.SetNextItem(qEnt);

               }

        }

 

        public QueueEntry Pop()

        {

               QueueEntry qEnt = headEnt;

               if(headEnt == null)

               {

                       java.lang.System.exit(-1);

               }

               headEnt = headEnt->GetNextEnt();

               return qEnt;

        }

};

<코드 2>  인터페이스를 이용한 범용 Queue Java 코드

 

 

Java의 코드라고 하더라도 인터페이스를 통하여 제작한 QueueEntry라는 자료 구조와 Queue라는 알고리즘이 분리 된 것을 알아 보는데 아무런 문제가 없을 것이다. 우리는 Queue라는 클래스를 사용 하는데 있어서 특별한 자료 구조 타입을 고려 하지 않고 일관적인 방법을 통해 Push, Pop과 같은 행동을 취할 수 있다.

 

사용자 삽입 이미지

 

<그림 3> 범용 큐의 UML

 

 

 

CBSE(Component Based Software Engineering)에서의 Interface사용

 C++의 인터페이스 응용으로 넘어가기 전에 인터페이스가 빛을 바라고 있는 부분을 마지막으로 살펴 보자. CBSE는 사실 그 범위가 객체 지향 패러다임에서 언급 할 수 있는 범위를 넘어선다. 하지만 COM, CORBA, EJB와 같은 컴포넌트 기술에서 인터페이스가 어떤 의미를 가지는 지 한번 상기 시킬 필요는 있다. CBSE에서 인터페이스는 클라이언트가 콤포넌트 객체의 서비스를 엑세스 할 수 있는 수단을 제공하고 한다. 서버로 동작하는 컴포넌트는 그 컴포넌트가 제공해야 하는 인터페이스들을 실제 구현 해야 한다. 객체 지향 언어에서 말하는 구현과 유사하다. 이와 반대로 클라이언트로 동작 하는 컴포넌트들은 서버 컴포넌트의 인터페이스를 기초로 응용, 작성되어야 한다. 인터페이스를 이용하는 기법들은 COM, CORBA, EJB에서 서로 유사한 부분을 가지고 있다. 우리가 추상 클래스로 인터페이스를 명세하는 것과 유사하게 CBSE의 컴포넌트 기술들은 IDL(Interface Definition Language)를 통하여 명세 한다.

 

[Object, uuid(40F4F436-8A41-4FF2-95C4-A1FDF60A152D)]

Interface IMicroSorftware : IUnkown {

        Import unknown.idl;

        HRESULT GetPaper([out] short * result);

        HRESULT SendPaper([in] short input);

       

}

<코드 3>  COM,  IDL 일부

 

<코드 3>에서 명세 된 IMicroSoftware 인터페이스를 구현하는 서버의 경우 <코드 3>에서 정의된 GetPaper SetPater의 함수를 통해서 그 행동을 정의 할 수 있다. 이 컴포넌트를 개발하는 개발자는 구현 내역을 클라이언트에게 전혀 공개 할 필요가 없다. 물론 이를 반대로 이야기 하면 이 컴포넌트를 사용하여 개발하게 되는 클라언트 개발자는 IDL외에 관련된 구현 내역을 전혀 알 필요가 없고, IDL만을 가지고 개발을 하게 되며 서버 컴포넌트 구현 시에 사용되었던 헤더 파일이나 구현된 바이너리 코드와 링크할 필요가 전혀 없다. 이러한 점에서 볼 때 CBSE에서 인터페이스 사용은 정보 은닉의 수단으로서의 의미가 강하다. (물론 이 부분도 많은 논란의 여지가 있으나 개발자 입장에서 봤을 때 가장 현실적인 의미가 정보 은닉 수단 인 것 같다.) 실질 적인 사용자 입장에서는 CoCreateInstance, QueryInterface와 같은 메서들을 제외 하면 객체 지향 프로그램을 작성 하듯이 코딩 할 수 있다. 예를 들면 IMaso->GetPaper와 같은 식으로 말이다.

여기서 이야기 하고자 하는 것은 COM, EJB와 같은 CBSE를 논하자는 것이 아니라 컴포넌트 기술이 인터페이스를 이용하여 컴포넌트가 제공하는 서비스를 외부에 공개하므로 인터페이스 사용법에 친숙해질 필요가 있다는 것이다. 대부분의 전문가들은 앞으로 컴포넌트를 기술을 이용한 개발이 보편화 될 것으로 예상하고 있고, 이 기술에 필수 요소가 인터페이스라는 점은 여러 C++개발자들에게 좀 더 고무적인 일이 될 수 있다.

 

C++에 있어서 인터페이스(Interface)

잠시 쉬어가는 의미로 이 질문에 대하여 대답 해보자. 여러분은 C++로 시스템을 작성하면서 몇 개의 인터페이스를 정의하였는가?. 최소한 이 글을 읽고 있는 독자라면 1, 2, 또는 , 나는 사용자를 위해 C++에서 많은 인터페이스를 열어 줬는데라는 답변은 나오지 않아야 한다. 왜냐하면 정확히 이야기 하여 C++은 인터페이스를 가지고 있지 않기 때문이다.

C로부터 개발을 진행하여 C++로 전향하게 된 개발자뿐만 아니라 대부분의 C++ 개발자들은 인터페이스 사용에 대한 포괄적인 지식이나 개념 이해를 가지고 있지 않은 경우가 많다. C++ 프로그래밍 시 Java에서와 같이 인터페이스에 대한 지식 및 개념 이해가 없이도 충분히 프로그램이 가능하기 때문이다. 우리의 C++은 왜 인터페이스를 사용하지 않은 것일까?

객체 지향에서의 인터페이스 절에서도 이야기 하였듯이 사물에 대한 특정 View를 제공하는 인터페이스 사용은 객체 지향에서 매우 멋진 도구임이 틀림 없음에도 불구하고 C++에는 인터페이스를 사용하지 않고도 코딩이 가능하다.

 C++에는 인터페이스라는 도구가 채택되지 못했는데 굳이 변명을 늘어 놓으라면 C++이라는 언어의 태생 자체의 결함이라고 할 수 있다. 스텝 1에서 이야기 했던 적이 있는데, C++의 초기 설계 목적은 C 언어를 기반으로 하는 객체 지향 언어를 만드는 것이 아니었다. 스트로우스트럽(Stroustrup) C++을 처음 릴리즈할때는 1980년도 벨 연구소에서 단지 C언어에 자료 추상화 기능을 추가 하고자 한 것이다. 이때 C++의 이름은 C with Data Abstraction이다. (1980년에서 27년이 지났는데도 아직까지 몇몇 개발자는 C++ C With Data Abstraction처럼 사용 하고 있다는 사실이 C++이 객체 지향에 대한 개념이 얼마나 성숙해지기 전에 C개발자에게 개발 되어 보급되었는지를 알 수 있다.) C++ Java처럼 언어의 구현 목적이 객체 지향 패러다임을 구현하기 위한 목적으로 만들어 진 것이 아니라 구조적 언어에 추가로 객체 지향 패러다임을 적용한 언어이기 때문에 인터페이스의 도입이 이루어지지 않았고 그 시기가(C++ Release 3.0이 배포 되는 1990)너무 일렀기 때문에 인터페이스의 도입이 이루어지지 않았다. C++을 사용하면서 인터페이스를 사용하지 않고도 충분한 시스템을 제작 할 수 있는 것은 코딩을 통해 문제를 해결 할 때 반드시 인터페이스를 사용 할 필요는 없기 때문이다. 그도 그럴 것이 C와 같은 절차적 언어를 사용 하더라도 단지 그 표현 방법이 다를 뿐이지 구현에 문제가 되는 것은 아닌데다가 C++은 절차적 언어를 그 태생으로 하고 있기 때문에 절차적 방법을 충분히 이용 할 수 있다. 한술 더 떠서 C++에서는 다중 상속이라는 것을 지원 하기 때문에 이를 이용하여 여러 가지 뷰를 생성 할 수 있다.

 템플릿을 사용 하지 않고 앞서 구현한 Java Queue C++로 구현 한다면 어떠한 식으로 표현 해야 할까?

전자에서 언급 되었듯이 C++에서는 Java에서와 같이 Interface와 같은 도구가 존재 하지 않기 때문에 Java에서처럼 명시적으로 Interface를 만들 수 없다. 하지만 언제나 그랬듯이 C++은 지원하지 않는 기능에 대하여 모방할 수 있는 기초 모델들을 지원 하는데 Interface를 구현 하기 위하여 C++에서는 가상 함수와 다중 상속을 이용 할 수 있다.

 

class QueueEntry {

public :

        //

        //      순수가상함수로서인터페이스기능을수행하도록함.

        //

        virtual QueueEntry*    GetNextEnt()                  = 0;

        virtual void           SetNextEnt(QueueEntry *qEnt)  = 0;

};

 

class Queue {

private :

        //

        //      인터페이스사용을위한포인터.

        //

        QueueEntry             *headEnt;

        QueueEntry             *tailEnt;

 

private :

        Queue();

        ~Queue();

public :

        virtual void           Push(QueueEntry* qEnt);

        virtual QueueEntry*    Pop();

};

<코드 4>  Java 범용 Queue C++ 옮긴

 

 먼저 Interface로 정의될 클래스의 메서드들을 전부 순수 가상 함수로 선언한다. 우리가 알아야 할 것은 순수 가상 함수를 사용하여 만든 Queue 클래스는 명시적으로 인터페이스를 사용 한 것이 아니라 단지 인터페이스를 모방한 모델링의 한 방법이라는 것이다. 몇몇 C++ 개발자들은 순수 가상 함수를 선언 할 때 메서드 뒤에 붙게 되는  =0 을 오인하여 순수 가상 함수가 몸체가 없어야 하는 것으로 알고 있는 경우가 있다. C++에서 순수 가상 함수는 대부분이 구현을 하지 않지만 구현을 못하는 것은 아니다. 순수 가상 함수를 꼭 구현 하는 경우가 있는데 그것은 순수 가상 함수로 구현된 소멸자 이다. 추상 클래스 절에 소개 되어 있는 CObject ~CObject가 좋은 예이다.

 C++의 순수 가상 함수의 정확한 뜻은 순수 가상 함수를 선언이 있는 클래스는 추상 클래스이고 이 추상 클래스로부터 상속을 받아 구체화를 하게 되는 구체 클래스는 선언된 순수 가상 함수를 일반적인 가상 함수로 반드시 구현 해주어야 한다는 것이다. 그런 의미에서 순수 가상 함수를 사용한 인터페이스 구현하는데 있어서 좋은 도구로 사용 할 수 있다.

 범용 자료 구조를 가지는 Queue의 바디는 아래와 같은 코드 구조를 가질 것이다.

 

void Queue::Push(QueueEntry* qEnt) {

       

        if(headEnt == NULL)

        {

               headEnt = qEnt;

               tailEnt = qEnt;

        }

        else

        {

               tailEnt->SetNextEnt(qEnt);

        }

}

<코드 5> 범용 Queue Push

 

 

보시다시피 코드 자체는 Java Interface구현과 특별한 차이 없이 이를 구현 할 수 있다.

그렇다면 구현상속만으로도 충분히 시스템을 갖출 수 있는 C++에서 이러한 인터페이스를 사용 할 것인가? 하는 문제가 있다. 가상 함수와 다중 상속을 이용하여 인터페이스를 모델링하고 이를 사용 하는 것이 얼마나 이득일 것인지는 이를 사용할 개발자와 독자의 몫이다. 하지만 분명한 것은 여러분이 다른 개발자에게 제공해야 하는 코어나 엔진등을 제작 한다면 그것은 템플릿과 같은 형태를 통해서 확장을 해주는 것보다 인터페이스를 통해서 확장의 물꼬를 터놓는 것이 훨씬 바람직하다.

 

맺음말

 그간 대한민국 개발자와 객체 지향 이야기를 지켜 봐 주신 독자 여러분, 감사 드립니다. 지금 이 시간에도 똑딱거리는 초침과 수많은 고민을 함께 해결 하고 계실 많은 개발자분 들에게 10개월 동안 부족하디 부족한 글을 쓰게 된 것을 송구스럽게 생각합니다. 칼럼에서 언급한 내용이 C++의 모든 기술들을 서술 하지는 못했지만, C++을 좀 더 객체 지향스럽게 사용하는 것에 대하여 조금이나마 서로 고민해보는 좋은 기회가 되었을 것으로 생각합니다. 사실 C++의 달콤한 기교들은 실제로 바쁜 일정 속의 업무에서 구현, 그 뒤편으로 사라지기 쉽습니다. 더욱이 C++ 다운 C++C언어가 주류를 이루는 임베디드 개발자에게는 더더욱 먼 이야기일 것입니다. 하지만 그러한 개발 환경도 서서히 변화를 거쳐 갈 것이라는 사실은 틀림이 없습니다.

불과 얼마 전, 코볼이나 포트란이 주류를 이루던 시절에는 C언어가 주류가 될지, 그 누구도 예상하지 못했습니다. 기존 언어들을 통해서 만들어진 많은 시스템들이 새로운 언어를 통해서 다시 태어나는 것은 그 구현의 어려움 이외에도 이제까지 구축 되어온 시스템의 보장된 안정성의 확보, 그리고 관련자들의 밥그릇 싸움 등 여러 가지 이유에서 어려웠을 것입니다. 이러한 이유에서 C언어는 오브젝트 링크 수준에서 타 언어와의 호환을 무기로 예전 시스템을 변경하지 않고 C를 쓸 수 있게 하여 입지를 굳혀 왔으며 결국 현재에는 코볼과 포트란과 같은 언어들을 우리에게 있어 추억 속의 한 장면으로 만들고, 대부분의 많은 시스템들을 장악 했습니다. C++ 또한 마찬가지입니다. C언어가 시스템 구현에 있어 조금씩 입지를 높여갔듯이 C++도 객체 지향이라는 여러 가지 설계의 기교들을 감추고 C언어와 완전한 호환을 통해서 커뮤니티의 분쟁 없이 현재 많은 시스템을 장악해 가고 있습니다. C++ C의 자리를 밀어 내고 어느 정도 입지를 굳힌 다음 조금씩 C++만의 특징들을 추가 해오면서 완성 된 것이 오늘날의 C++입니다. 오히려 C C++의 문법 일부를 가져오는 등 많은 변화를 가졌지요. (예를 들면 // 과 같은 주석의 변화를 말합니다.)

C++이 성장 해왔듯 C++을 사용하지 못하는 시스템 영역에서의 개발자의 역할도 이와 마찬가지라고 생각합니다. C언어 만을 통하여 각 모듈간의 정의를 Object단위로 수행 할 수 있으며 (물론 객체지향의 고급기술들을 완전히 쓸 수는 없지만) C++과 같이 프로젝트 안에서 서로간의 역할을 나누고 정확히 추상화 할 수 있습니다. 주변에서 이런 아키텍트들을 쉽게 만나 볼 수 있는데 가장 큰 예가 GTK WDM과 같은 레이어드 아키텍트(Layered Architect)이겠지요. 더욱이 지금처럼 임베디드 영역에 ALU나 기타 Peripheral들의 속도가 무섭게 발전하고 임베디드 영역의 프로젝트가 거대해지면서 유지 보수의 중요성이 두각 되는 시점에서 각 모듈간의 행동이 정확히 정의 된 여러분의 코드는 반드시 언젠가 빛을 바랄 것을 믿어 의심치 않습니다.

 물론 개발자에게 있어서 중요한 자질은 객체 지향적 패러다임을 익숙하게 다루기 보다는 문제를 정확히 정의 하고 처리 할 수 있는 능력과, 구현을 통하여 하나의 프로그램을 제작하기에 앞서 자신의 코드가 얼마나 빠를 지 퍼포먼스를 예측하고 관리하는 능력 등 훨씬 중요한 부분들도 있습니다. MS사에서는 이러한 SDE계층(Software Develop Engineering)의 역량에 대해서 정확히 정의 해두고 있습니다. 하지만 그러한 논리적 사고 이외에도 실제 프로젝트를 운용 하는데 있어서는 유지 보수, 요구 사항 관리, 프로세스간의 정확한 문서, 확장성을 고려한 설계 등, 개발자들이 할애 해야 하는 많은 노력과 비용들이 산재해 있습니다. 개발 시간을 단축 하고 좀 더 개발자가 개발에 몰두 할 수 있도록 많은 소소한 작업들을 대신 해주는 객체 지향 언어를 사용 하면서 이를 단지 추상화 도구로만 사용 할 것이 아니라 그 숨은 기능들의 의미를 제대로 파악하고 사용하는 것이 우리 개발자의 몫이 아닐까 합니다.

마지막으로 항상 저에게 있어서 촉매제 역할을 해주시는 박찬익 책임, 부족하기만 한 개발자에게 새로운 영역에 대한 많은 가르침을 주시고 계신 김성철 책임, 장세정 선임님, 그리고 옆에서 묵묵히 지켜 봐준 삼성테크윈 강선정 연구원께 감사 인사 드립니다. 기고는 여기서 끝을 내지만 앞으로도 독자님들과 http://www.swblog.net을 통해 현재 다루지 못한 STL Generics에 대한 비교, C C++의 호환을 위한 테크닉 등, C++에 남겨진 이야기들 과 개발을 하면서 생기는 많은 이야기들을 함께 공유 할 수 있었으면 좋겠습니다.

 

 

 

참조 :

Addison Welsey, Scott Meyers. More Effective C++

배움터, 김태균저, K교수의 객체지향 이야기

 

'Drafts > C++' 카테고리의 다른 글

[STEP 10] C++인터페이스(Interface)  (0) 2008/06/05
[STEP 9] 상속구현 #2, More inheritance  (0) 2008/03/01
[STEP 8] Interitant impelemetation  (0) 2008/02/06
[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06

무엇이든 다룰 수 있어야 하는 한국인 프로그래머와 객체지향

 

Korean Programmer

[STEP 9] More inheritance

 

재사용에 있어서 절차적 언어인 C를 나무 한 토막을 잘라내는데 하루 종일 걸리는 실톱으로 비교하자면 C++의 상속은 전기 톱으로 비유할 수 있다. 하지만 다중 상속에 대해서는 C++의 대가들에게 있어서도 그 사용에 대한 견해가 천차만별이다.  Scot Meyers의 경우 조심해서 사용 하기를 권장하고는 있지만 다중 상속의 장점을 이용한 응용(1997)을 추천을 하고 있다. 하지만 이와는 반대로 Steve McConnell의 경우에는 다중 상속을 전기 톱 중 안전장치가 없는 아주 위험한 1950년대식 전기 톱으로 비유하여 그 사용을 절대 자제하도록 권고 하고 있다. 그럼에도 불구하고 다중 상속이라는 것이 분명 실 세계를 서술 하는데 있어서 명쾌한 부분을 가지고 있는 것은 분명한 사실이다. 또한 다중 상속이 인터페이스와 같은 추상화 툴과 함께 사용되면 템플릿보다 훨씬 강력한 범용 프로그램을 제작 할 수 있다는 장점을 가지고 있다. 이러한 다중 상속에 대한 올바른 이해와 구현 상속에 있어서의 다중 상속을 피해 갈수 있는 대체 모델에 대하여 알아보도록 하자.

 

명수 | byeguns@sw블로그.넷 / http://www.swblog.net

필자는 현재 삼성전자 메모리사업부의 플래시 소프트웨어 개발 관련 연구원으로 플래시 메모리와 관련된 파일 시스템과 커널 드라이버들을 제작하고 있다. 컴퓨터 공학을 전공하고 Haskell Scheme등의 Functional language를 좋아하며, 학부 시절, C++과 관련하여 3개의 논문과 대회 수상을 한 경험이 있다. 올해 들어와서 Class와는 전혀 다른 작업을 통해 조금은 OOP와 거리가 멀어진 길을 걷고 있지만, 항상 언어에 대한 갈등은 지우지 못하는 이제 막 걸음마를 시작한 부족한 그릇의 개발자이다.

 

 

상속은 객체지향적 언어가 이제까지 VOP(Value Oriented Paradigm), FOP(Feature Oriented Paradigm)이나 함수형 언어들과 어깨를 겨루며 각광 받았던 큰 이유 중 하나이다. 그 만큼 소프트웨어 위기를 재사용이라는 강력한 도구를 통하여 극복하게 해준 핵심이라고 볼 수 있다. 상속의 판도라 상자로 불리는 다중 상속에 대한 이슈를 다루기 전에 상속에 있어서의 핵심인 동적 바인딩을 사용하기 위한 가상 함수에 있어서 주의 점을 알아보고 다중 상속을 리모델링 하는 방법에 대하여 알아 보도록 하자. 동적 바인딩에 대한 이야기가 생소 하다면 STEP 3,4절을 참조하기 바란다.

가상 함수를 사용하여 런타임 시에 사용될 메서드를 결정 한다는 것은 객체 지향 언어에서 가장 매력적인 항목이긴 하나 특정한 메서드에 대해서는 그 의미가 조금 위험할 수 있는데 그러한 메서드가 바로 생성자와 소멸자이다.

 

가상의 생성자와 소멸자(Virtual constructor, Destroyer)

모두 알고 있다시피 상속 구조에서의 생성자 호출 구조는 상위 클래스의 생성자로부터 하나씩 호출 되어 하위 클래스의 생성자까지 전달된다. 이와 반대로 소멸자의 경우는 하위 클래스의 소멸자부터 호출 되기 시작하여 루트가 되는 클래스의 소멸자를 호출 함으로써 Call history를 마감하게 된다. C++에서 폴리모피즘을 적용하기 위해 상위 기본 클래스의 타입으로 파생 클래스의 포인터를 변환하여 사용하게 되면 생성자와 소멸자는 두 가지의 문제를 가지게 된다. 하나는 가상 함수로 선언되지 않았을 때의 메모리 누수 발생과 같은 기본적인 문제이고 또 다른 하나는 가상으로 선언 되더라도 생성자와 소멸자가 가지는 Call History때문에 생성자와 소멸자 내부에서 호출 되는 메서들의 바인딩이 미묘한 문제를 가지게 되는 것이다. 더욱이 생성자와 소멸자는 보통 직접 호출 하지 않는 것이 관례이므로 눈에 보이지 않는 곳에서 프로그램에게 치명적인 문제들을 선사할 가능성이 높다. 정적으로 선언된 생성자와 소멸자 문제와 더불어 내부에서 호출 되는 메서드에 의해 치명적 오류를 가지게 되는 상속 클래스를 예로 살펴 보자.

 

class People {

public :

         virtual void DisplayProperty();

         virtual void BuildInfo();

         //

         //       기타메서드들

         //

};

 

class Developer : public People {

private :

         char * m_cName;

         char * m_cId;

        

         //

         //       .. 기타속성들

         //

public :

         Developer(char * cName, char * cId);

         ~Developer();

 

         void BuildDeveloper();

 

         //

         //       개발자속성들을출력

         //

         virtual void DisplayProperty();

};

<코드 1> 정적 바인딩을 하는 생성자와 소멸자를 가진 클래스

 

<코드 1>과 같은 상속 관계를 가지는 Developer클래스의 생성자와 소멸자의 문제를 짚어 보자. Developer의 생성자에서는 개발자의 이름과 고유 아이디를 할당 받는다. 물론 내부적으로 스트링을 저장하는데 자체적으로 char *를 쓰는 경우도 그렇게 흔하지 않겠지만 어디까지나 생성자와 소멸자의 문제를 인식 하기 위한 예제 이므로 독자들의 많은 양해 바란다.

 

Developer::Developer(char * cName, char * cId)

{

         m_cName           = new char[strlen(cName) + 1];

         m_cId             = new char[strlen(cId) + 1];

 

         strcpy(m_cName, cName);

         strcpy(m_cId, cId);

 

         //

         //       DB나 미디어에 저장된 개발자정보를 Load

         //

         BuildDeveloper();

}

 

Developer::~Developer()

{

         delete [] m_cName;

         delete [] m_cId;

}

<코드 2> 문제의 소지를 가지고 있는 생성자와 소멸자.

 

 

이 생성자와 소멸자를 가진 Developer 클래스의 문제는 무엇일까? 이 클래스는 두 가지 폭탄을 앉고 있다. 대부분 이러한 폭탄을 발견 하지 못하는 이유는 개발 1,2년차의 C++개발자들이 C++의 클래스를 단지 구조체와 같은 정보 래퍼 수준에서 사용하기 때문에 문제가 굴어질 이슈들 겪지 못하기 때문이다. 가장 눈에 두드러지게 확인이 되는 부분부터 확인 해보도록 하자. 이 클래스는 고질적으로 메모리 누수 문제를 껴 안고 태어나게 된다. Developer 클래스를 사용하는 예를 보면 좀 더 명확해 질 것이다.

 

void CWorld::CreateInstance()

{

         People *man       = new Developer("basic class", "C++");

        

         man->DisplayProperty();

 

         //

         //       개발자는 죽지 않는다, 무슨 의미 일까?

         //

         delete man;

}

<코드 3> Developer 클래스의 사용시 문제

 

 

상속구조의 소멸자는 가급적 가상으로 선언하자.

 

<코드 3> CreateInstance 메서드내에서 Developer 클래스가 Delete가 되는 순간에 이 클래스에 대한 소멸자는 어떻게 처리 되게 될까를 생각 해보면 현재 Developer클래스의 문제를 알 수 있다. 일단 operator delete가 따로 오버로딩 되어 있지 않으므로 오리지널 delete를 호출 하게 될 것이다. (operator delete에 대한 문제는 마소지 2006 12 C++ 최적화 문제를 살펴 보기 바란다.) delete는 소멸자를 호출 하게 되어 있는데 C++은 우리 Developer클래스의 소멸자를 호출 순서에서 제외하게 된다. 정확히 말해서 제외라고 하기 보다는 C++에서는 Developer의 존재를 알 수 없기 때문에 Developer의 소멸자를 호출 하지 않는 것으로 보는 것이 올바를 것이다. 결과적으로 이 코드는 m_cName m_cId에 대해서 정상적으로 메모리 해제를 하지 못하기 때문에 메모리 누수를 일으키게 된다. <코드 3>과 같은 아무것도 하지 않는 프로그램에서는 크게 영향을 받지 않겠지만, 만약 Developer 클래스를 여러 번 생성하고 삭제 해야 하는 프로그램이라면 얼마 가지 않아 프로그램을 포함하여 전체 시스템이 그대로 멈추게 될 것이다. 그렇다면 왜 C++ Developer의 소멸자를 알 수 없을까? 그렇다 필자가 몇 회에 걸쳐서 귀에 못이 박히도록 이야기한 동적 바인딩 문제 때문이다. 소멸자가 가상함수가 아니기 때문에 People의 뷰를 통해 작업을 수행하기 때문에 소멸자가 호출 될 때 People의 소멸자만 호출하고 Developer클래스의 리소스에 대해서는 메모리 해제를 하지 않은 채로 Developer 클래스의 인스턴스는 사라지게 된다. 이러한 문제는 소멸자를 <코드 4>에서처럼 가상으로 선언 하여 C++에게 Developer의 소멸자를 인식 시킬 수 있다.

 

class Developer : public People {

private :

         char * m_cName;

         char * m_cId;

        

         //

         //       .. 기타속성들

         //

public :

         Developer(char * cName, char * cId);

         //

         //       소멸자를 가상으로 변경했다.

         //

         virtual ~Developer();

 

         void BuildDeveloper();

 

         //

         //       개발자 속성들을 출력

         //

         virtual void DisplayProperty();

};

<코드 4> 엉뚱한 메모리 누수를 막기 위해 생성자를 변경한 경우

 

 

앞에서 말했던 것처럼 <코드 4>에서 우리가 메모리 누수를 막기 위해 사용한 것은 소멸자에 virtual을 사용 한 것 밖에 없다. 이렇게 함으로써 컴파일러는 소멸자의 바인딩 시기를 런타임까지 미룰 것이고 이는 Developer 클래스의 인스턴스가 delete될 때 우리가 기대 하는 것처럼 Developer부터 메모리를 해제하고 상위 부모인 People클래스의 소멸자를 정상적으로 호출할 수 있다.

가상 함수에 의해 메모리 누수를 막는 것으로 하나의 폭탄을 제거 했다면 또 다른 하나의 폭탄은 무엇일까? <코드 3>처럼 클래스를 변경 하더라도 여전히 프로그램은 큰 문제를 안고 있다. 그것은 생성자에게서 호출이 되는 BuildDeveloper의 문제 이다. BuildDeveloper라는 메서드가 Developer의 클래스에서 미디어나 NV(Non volatile)메모리로부터 이전에 저장된 정보를 가져와 설정하기 위해 <코드 5>와 같이 구현되어 있다고 생각 해보자.

 

void Developer::BuildDeveloper()

{

        //

        // 미디어나 DB로부터 정보를 읽어온뒤 정보를 디스플레이 해주는 가상 함수 호출

        //

 

        DisplayProperty();

}

<코드 4> 가상 함수를 호출 하는 형태의 BuildDeveloper 메서드

 

 

 

 구체 클래스가 인스턴시에이션 하면서 가장 먼저 기본 클래스의 생성자를 호출 하는데 이때 생성자에 BuildDeveloper 메서드처럼 가상함수를 호출 하는 부분이 있다면 이 가상함수는 어느 클래스에 바인딩 되어 동작을 수행해야 할까?

 

 가상 함수의 생성자와 소멸자에서는 가상 함수를 쓰지 말자

C++의 가상 함수에서 한가지 유의해야 하는 점은 가상 함수로 선언된 메서드라고 해서 모두 런타임 시에 바인딩 되지는 않는다는 것이다. 그러한 예가 가상 생성자와 소멸자내에서 호출되는 경우이다. 예를 들어 People의 클래스의 인스턴스를 만들 때 호출 되는 생성자가 가상함수 DisplayProperty를 호출 한다고 생각 해보자. 우리가 Developer클래스의 인스턴스를 만들면 가장 먼저 People 클래스의 생성자가 호출 될 것이다. 그때 DisplayProperty는 가상 함수이지만 People 클래스에서 구현된 DisplayProperty를 호출 하여 People의 속성을 출력 할 것이다.

그렇다면 왜 C++에서는 생성자 내부의 가상 함수 호출을 정적으로 처리 하는 것일까? 그것은 생성자 내부의 가상 함수가 동적으로 바인딩 된다면 기초 클래스에서 호출 하는 가상 함수가 아직 초기화 되지 않은 파생 클래스의 리소스를 사용하게 되기 때문이다. 사실 아직 생기지도 않은 객체의 메서드를 쓴다는 것이 말이 되겠는가? 그래서 C++은 생성자나 소멸자에서 가상 함수가 호출 될 경우 더 이상 가상 함수로 보지 않고 이를 동적으로 바인딩 시켜 버리는 것이다.

C#이나 Java와 같은 다른 객체 지향 언어에서는 이러한 문제를 C++과 같이 처리를 해주고 있지 않기 때문에 생성자나 소멸자에서 가상 함수를 호출 하게 되는 경우 치명적 오류를 가져올 가능성이 높다. 그렇다면 C++은 원천적으로 문제를 해결 한 것으로 볼 수 있는가?.

<코드 2>에서 보면 현재 가상으로 선언된 생성자와 소멸자에서 가상 함수를 호출 하는 경우는 없다. 하지만 생성자에서 호출 되는 BuildDeveloper<코드 4>에서처럼 가상 함수를 호출한다 C++이 생성자내에 가상함수의 사용을 무시하더라도 이런 식으로 가상함수가 생성자 자체에서 호출 되는 것이 아니라 생성자가 호출하는 메서드의 내부에서 가상 함수를 호출 하게 되는 경우 동적 바인딩 되게 되는 것이다. 실제로 문제가 되는 과정을 People클래스와 Developer클래스를 가지고 생각 해보자. Developer 클래스를 생성하기 위해서 호출 되는 People 클래스의 생성자의 실행 시에는 분명 Developer 클래스가 인스턴시에션 되기 전이기 때문에 그 인스턴스가 메모리에 잡혀 있지 않다. 하지만 가상함수는 Developer 클래스로 동적 바인딩되기 때문에 생성되지 않은 Developer의 클래스, 즉 초기화 중인 클래스의 메서드에 접근 하려 하고 이는 메모리에 잡히지도 않은 메서드의 주소를 엑세스 하려 하기 때문에 프로그램은 예외상황에 빠지게 된다.

 간혹 여러분은 MFC 프로그래밍을 하면서 클래스의 초기화를 생성자내에서 하게 되면 죽는 것을 종종 겪게 된다. 그래서 MicroSoft에서는 생성자와 소멸자에서 이러한 호출을 피하게 하기 위하여 Init에 관련된 메서드를 따로 제공하고 사용자로부터 InitXXX와 같은 함수에서 초기화를 하도록 하고 있다. Scot Mayer의 경우는 static으로 초기화 메서드를 제공하고 이를 생성자 및 소멸자에게서 사용하게 함으로써 클래스가 메모리에 잡히기 전에 일어나는 오류를 막는 방법을 제공하였다.

하지만 이러한 static 멤버 함수는 실제 클래스에 속하는 메서드라고 하기보다는 전역 함수로 보는 것이 맞다. 객체 지향 패러다임에서 특권을 가진 함수나 구조체 등은 제거 되는 것이 일반적이다. 그럼에도 불구 하고 C++대가들이 이러한 묘수를 제공하는 것은 C++이 그만큼 OOP에 충실하지 못하다는 반증일 수도 있다. 이러한 static의 사용은 생성자와 소멸자의 가상함수 호출 문제 뿐만 아니라 예전 C++ 스펙이 RTTI를 지원 하지 못할 때, MFC에서 객체의 타입을 바로 알아 내주기 위해서도 사용 되었고 아직까지 많은 MFC 응용 프로그램들에 잔존하고 있다.

 

다중 상속(Multiple Inheritance)

다중 상속은 상속 설계를 이야기 하면서 가장 골칫덩이로 간주 된다. Scott Meyers는 다중 상속에 대해서 C++의 다중 상속에 대해서 한가지 명백한 사실은 다중 상속을 사용 한다는 것은 단일 상속에서는 존재하지 않는 복잡성의 판도라의 상자를 연다는 것이다. 라고 소개 한다. 그럼에도 불구 하고 왜 C++에서는 다중 상속이 필요하게 되었는가? 실 세계를 추상화 함에 있어서 단일 상속으로는 부족한 모델들이 간혹 있기 때문이다.(사실 여기에 대해서는 많은 논란의 여지가 있을 수 있다.) 이러한 모델들에 대하여 많은 예제들이 있지만 필자가 본 것 중에서 다중 상속의 필요성에 대해서 가장 잘 표현 하고 있는 것은 김태균 교수가 그의 저서에서 제시하는 박쥐의 상속 모델이다.

박쥐는 이솝 우화에서도 나오듯이 어떨 때는 새의 부류가 되기도 하고 어떨 때는 포유류의 부류가 되기도 한다. 박쥐를 클래스로 모델링 하는 경우 어느 쪽으로 분류 할 수 있을 것인가? 포유류의 정의상 사자, 호랑이와 같은 클래스들은 이빨이 있고 젓이 있기 때문에 쉽게 포유류로 분류될 수 있고 참새나 독수리들은 부리가 있고 날 수 있는 날개가 있으므로 쉽게 조류로 분류 할 수 있다. 하지만 박쥐의 경우 이빨도 있고 날개도 있고 젓도 있으므로 어느 한쪽으로 할당 시키기가 쉽지 않다. 이럴 때 다중 상속은 아주 유용한 도구로서 사용이 가능하다.

즉 이빨과 젓을 가진 것은 포유류 쪽에서 자료 구조와 연산을 가지고 오고 날개를 통한 비행과 같은 행동은 조류의 클래스로부터 상속 받아 박쥐를 구현 할 수 있을 것이다. 이를 좀 더 쉽게 UML로 도식화 하면 아래와 같다.

사용자 삽입 이미지

<그림 1> 다중 상속을 통해 모델링되는 UML

 

언어에 의존적인 다중 상속

 

박쥐 모델처럼 다중상속을 이용함으로써 실 세계에 대한 모델을 매우 단순하게 구현 할 수 있으며 이해하기도 쉽다는 점은 누구도 부인 할 수 없다. 다중 상속은 많은 개발자의 이해관계가 얽혀 있기 때문에 좋다 나쁘다로 단락 지을 수 없지만 대부분의 개발자들은 객체지향적 언어에 숙달되지 않은 프로그래머가 다중 상속을 통하여 시스템을 구현 하는데 있어서 부정적인 입장을 고수한다.

 다중 상속에 대한 부정적인 의견 중 다중 상속의 기능이 언어 의존적이라는 문제는 이 기능이 객체 지향의 본질이라고 볼 수 없다는 것을 부각 시켜 준다. 객체 지향 언어 중에는 다중 상속을 지원하는 언어도 있고 지원하는 언어도 존재 하기 때문에 다중상속으로 모델링된 시스템을 구축할 경우 해당 구현 언어가 다중 상속의 기능을 지원하지 않으면 다중 상속에 대한 부분을 코드로 옮겨지기 복잡해진다. 다중 상속이 언어에 의존적이라는 이야기는 다른 관점에서 보았을 때 이질적인 시스템간의 포팅시에 이식성이 현저히 떨어지는 약점을 가지고 있다. 다시 말해 자바의 경우 다중상속을 원칙적으로 허용하지 않고 있기 때문에 다중 상속이 적용된 C++로 제작된 시스템의 디자인을 자바나 C#으로 옮기는 경우 이 부분을 포팅하기 위해서는 특정 모듈을 구현하는데 까지 걸리는 시간 혹은 그 보다 더 많은 시간이나 비용을 지불 해야 할 가능성이 있다. 물론 자바의 인터페이스를 통해서 다중 상속을 유도 할 수 있으나 이는 전체적인 시스템의 구조 변경이 불가피한 상황임을 부인 할 수 없다. 간혹 자바의 인터페이스가 다중 상속을 구현하기 위한 수단으로 치부하는 개발자들이 있는데 자바의 인터페이스는 다중 상속을 지원하기 위한 기능이나 수단이라기 보다는 실체를 가지지 않는 인터페이스의 특징 때문에 다중 상속이 가능한 것이라고 보는 것이 올바르다. 이는 다음달 기고되는 STEP 10에서 좀 더 자세하게 다루도록 하겠다.

 본론으로 돌아와서 자바나 C#이 인터페이스 등을 통해서 일부 다중 상속을 허용하고 있지만 객체지향의 패러다임의 원칙을 가장 잘 지키고 있는 Smalltalk라는 언어가 OOP의 아버지로 불림에도 불구 하고 다중 상속을 지원하지 않는 것에 주목할 여지가 있다. Smalltalk와 같은 객체지향 언어가 다중 상속을 지원하지 않는 것은 다중 상속이 가지는 특징을 구현 하는데 있어서 다중 상속과 같은 기능을 객체지향의 다른 특징들을 통해서 객체 지향 소프트웨어를 얼마든지 구현 가능 하기 때문이다. 이는 우리가 박쥐 모델을 다중 상속의 기능을 사용을 지양하는 방향으로 모델을 변경하여 어떻게 구현 되는지를 살펴 볼 것이다.

 

다중 상속의 본질적인 문제.

 그러면 단지 다중 상속은 언어에 종속적인 단점 이외에 다른 단점을 가지고 있지 않는가? 만약 단지 언어에 종속적인 문제가 다중 상속의 문제라면 우리는 시스템 구현에서 얼마든지 다중 상속을 사용 할 수 도 있을 것이라 생각 할 수 있다. 시스템을 구현 하면서 언어를 다시 한번 넘나 들며 포팅하게 되는 경우는 실제 포팅이라고 하기 보다 새로운 모델을 가지고 구현하는 정도의 대공사임을 누구나 알고 있고, 실무에서 제작된 시스템을 언어 차원을 넘어 다시 구현하게 되는 경우는 드물기 때문이다. 물론 클라이언트에 따라 여러 언어로 구현되어야 하는 경우도 있지만 대부분의 경우 요구사항이 언어의 플랫폼을 넘나드는 경우는 드물기 때문이다. 안타깝게도 다중 상속은 언어에 대한 종속적인 단점 이외에 본질적으로 치명적인 단점을 가지고 있다.

 <그림 1>의 박쥐의 다중 상속 모델에서 저번 스텝에 디자인 시 이슈가 되는 IS-A관계를 다시 한번 고려 해보자. IS-A 모델조건을 만족 하기 위해서는 박쥐는 포유류인가?"에 대한 질문이 명확한 해답을 가지고 있어야 한다. 하지만 박쥐가 포유류인가 에 대한 질문에 정확히 대답하기는 참 애매모호하다. 박쥐는 날아다니기 때문에 포유류가 아니다 라고 대답 할 수 도 있을 것이다. 더 나아가 다중 상속에 대한 정의대로 박쥐는 포유류이면서 조류이다라는 IS-A 모델을 만족 시킬 수 있겠는가? 결국 상위 클래스의 자료구조와 메서드에 대한 일부분들을 모아 하나의 집합으로 구현하기 위한 다중상속의 사용은 우리가 생각하고 있는 실 세계에 대한 모델링에 대하여 본질적인 문제를 가지고 있는 것이다.

 

다이아몬드 타입 모델링.

 다중 상속을 지원 하는 언어의 구조적 문제를 살펴 보자. 이는 다중 상속의 본질적인 문제로 세계를 못된 객체 지향적 모델링하게 되는 이른바 다이아몬드 타입으로 다중 상속이 이루어진 경우이다. 다이아몬드 타입의 다중 상속의 경우 다른 부모들로부터 다중 상속을 통하여 구현된 클래스의 조부모가 같은 경우이다.

사용자 삽입 이미지

<
그림 2> 전형적인 다이아몬드 타입 모델링

 

  다이어그램에서 Bat 조부모의 메서드를 조류와 포유류의 개의 클래스를 통해서 상속 받게 된다. 다시 말해 동물에 Sleep 포유류 쪽으로 한번 상속 받고 다른 쪽으로는 조류로부터 상속 받았기 때문에 개발자는 Bat Sleep 사용함에 있어서 물려 받은 개의 Sleep() 구분하며 프로그래밍 해야만 한다. 이러한 특성은 Sleep 같은 메서드뿐만 아니라 다이어그램의 성별(Sex) 나이(Age) 같은 데이터 멤버도 똑같이 영향을 미치므로 이러한 다중 상속의 사용시 개발자의 각별한 주의를 요하게 된다.

 

 

다중 상속(Multiple Inheritance) 리모델링

 

다중 상속이 있다면 Aggregation으로 바꾸어라..

그렇다면 다중 상속의 기능을 사용하지 않고 다중 상속으로 모델링 상황을 어떻게 정상적으로 변경 것인가? 대부분의 다중 상속의 경우 가지로 변형 모델링 또는 구현이 가능하다.

 

1.        다중 상속이 이루어진 말단 클래스를 완전히 독립적인 클래스로 제작.

2.        Aggregation 통하여 다중 상속의 부모 클래스 계층을 일부 콤포넌트화

 

 다중 상속이 이루어진 말단 클래스를 완전히 독립적인 클래스로 제작하는 경우 상위 Bat 같은 말단 클래스의 경우 포유류와 조류의 부모 클래스와 무관하게 만들어지게 것이다. 하지만 Bat 가지는 자료 구조 연산을 개별적으로 설계 구현 하게 된다. 이전 스텝에서 논의 바와 같이 이런 식으로 데이터 멤버가 반복 되고 코드가 많은 양의 코드가 중복되게 되는 것은 객체 지향 언어의 재사용 성이라는 강력한 도구를 완전히 무시하게 되는 설계가 있다.

 그러므로 우리는 다중 상속을 새롭게 모델링 하기 위해서는 번째 방법인 Aggregation 통하여 부모에게서 필요한 자료구조 연산을 부분적으로 조합하는 것을 선택 있다. 물론 이러한 Aggregation으로 새로 모델링 하는 부분에 대해서는 설계자 개발자에 취향이나 가치관에 따라서 충분히 다른 모델들로 다중 상속을 표현 있다.


사용자 삽입 이미지

<그림 3> Aggregation 통해 재구성 1

 

<그림 3> 조류로부터 상속을 받아 일반적인 자료구조와 연산을 따오고 포유류로부터 필요한 연산들을 Aggregation 통하여 조합한 구조이다. 여기에 대한 설계자의 생각은 짐작하건대 박쥐는 조류이지만, 박쥐의 일부 기능이 포유류의 기능과 같기 때문에 기능을 이용해야 한다 정도 것이다. 물론 이와 반대로 모델링을 있다.


사용자 삽입 이미지

<
그림 4> Aggregation 통해 재구성 2

 

<그림 4> <그림 3> 다이어그램과 달리 박쥐는 포유류지만 일부 기능이 조류의 기능과 유사하므로 이러한 기능을 Aggregation 통하여 가져 오겠다는 것이다. 이러한 가지 방법의 Aggregation이외에 박쥐는 아예 포유류도 아니고 조류도 아니지만 포유류와 조류의 일부 기능이 각각 유사하기 때문에 <그림 > 같이 일반화나 특수화와 같은 상속 없이 단지 Aggregation으로만 이들을 조합하여 구성하는 케이스가 있을 것이다. 물론 Aggregation 뿐만 아니라 Composition 같이 Bat 클래스의 생명 주기와 같은 라이프 사이클을 가지는 Bird Mammalia 통해 조합할 있겠지만 이것 보다는 Aggregation 포괄적인 의미를 가지므로 범용적인 모델링에 적합할 것이다.

 

사용자 삽입 이미지

<
그림 5> Aggregation 통해 재구성 3

 

 

 다중 상속을 피해 이에 대한 클래스를 리모델링하는 과정을 거쳐 보았지만 이를 정리 해보면 독자나 필자가 주의해야 것은 한가지 이다. 다중 상속이 세계의 문제를 추상화 시키는데 분명 도움이 되는 도구이지만 구현 시에 여러 가지 제약사항 어려움이 따르기 때문에 설계자 개발자는 이에 대한 충분한 경각심을 가져야 하며 논리적 모델링 시에 가급적 <그림 3> 또는 <그림 4> 같이 중요하다고 생각되는 부모 클래스 한쪽으로부터 상속을 취하고 나머지 기능은 Aggregation 통하여 다중 상속 모델을 변형하는 것이 바람직하다.

 

 

 

 

STEP을 마무리 하면서..

간혹 설계나 재사용, 유연성을 고려한 프로그래밍이 비난을 받기도 한다. 그도 그럴 것이 시스템을 설계에 투자한 비용만큼 효과적으로 구현하지 못하는 개발자들이 존재 하기 때문이다. 하지만 설계는 아무리 작은 프로그램을 개발 하더라도 설계라는 단계가 몸에 베이도록 고민 해보는 것이 올바르다. 어느 조직이나 처음에 개발이 이슈화가 되고 개발을 통한 상품이 실체화되기 전까지는 소위 날코딩하는 친구들이 필요하다. 어떤 사람들을 이러한 날코딩몸빵 코딩이라고도 하는데 이러한 코딩을 구사하는 개발자는 조직에서 빨리 상품화가 진행되도록 고무적인 역할을 하게 된다. 하지만 조직이 커지고 상품이 성공 할수록 당연히 막무가내 식 코딩을 하는 개발자 보다 관리자가 필요하게 되고 막연하게 마감 날짜만 기다리며 재촉하는 관리자보다는 체계적인 설계자가 필요하게 된다. 여러분이 최소한 개발자라는 마인드를 가지고 살아가려면 코딩보다는 설계측면에서 그 시스템에서 중요하게 하게 되는 것들을 고려하여 시스템을 구성해보는 경험들이 중요하다. 설계는 단지 재사용성을 높이고 유연한 시스템을 만들기 위한 것이 아니다. 시스템에 있어서 퍼포먼스, 메모리 운용, 코드 이미지 사이즈, 하드웨어의 제약사항등 설계에서 충분히 고려 되어야 할 것들은 많다.

C++이라는 언어는 C와 같은 절차적 언어와 달리 분명히 코딩에 있어서 개발자가 수고해야 하는 많은 부분을 대신 하여 주고 그 대신에 설계에 개발자가 좀 더 많은 시간을 할애 하도록 설계와 구현의 중간 다리 역할을 튼튼하게 해주고 있다. 설계와 구현을 번갈아 가며 시스템을 구성할 수 있는 나선형 개발 프로세스를 가지는 개발 언어를 사용하면서 단지 막무가내 식 코딩을 하는 것은 조금 부끄러운 일이 아닐까 ? 우리가 3회 스텝을 거쳐가며 모델링과 모델링 평가 그리고 모델링 이후 구현 이슈에 대하여 다루어보는 이유도 이러한 소프트웨어 엔지니어링의 마인드를 갖추고자 하는 이유때문이다. 개발자는 다른 사람들이 자신이 한 것을 바탕으로 또 다른 것을 만들 수 있도록 자신의 작업을 설계하려고 노력 해야 한다. 개발자는 쉬지 않고 똑딱대는 프로젝트의 일정 시계의 초침에 굴하지 않고 그 많은 일들을 해내기 위해 노력하는 기예(Craft)가 이다. 만약 여러분이 어떤 문제를 해결 하기 위해 문제를 정의 하고 다이어그램을 그리고 있는 그 순간에 누군가 여러분을 한심하게 평가 한다면 여러분은 고민 할 필요가 없다. 설계를 무시하는 개발자는 단지 일분에 500타를 친다고 해서 하루에 1200라인의 코드를 만들어 낼 것이라고 생각 하는 사람과 다를 바가 없기 때문이다.

 

참조 :

Addison Welsey, Scott Meyers. More Effective C++

배움터, 김태균저, K교수의 객체지향 이야기

 

'Drafts > C++' 카테고리의 다른 글

[STEP 10] C++인터페이스(Interface)  (0) 2008/06/05
[STEP 9] 상속구현 #2, More inheritance  (0) 2008/03/01
[STEP 8] Interitant impelemetation  (0) 2008/02/06
[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06

무엇이든 다룰 수 있어야 하는 한국인 프로그래머와 객체지향

 

Korean Programmer


상속의 구현상의 이슈 #1

 

상속에 의해서 생성된 클래스 계층도에서 배정 연산은 눈에 보이는 것과는 다른 문제들을 일으킬 수 있다. 폴리모피즘에 의해 동적 바인딩 되는 메서드들 때문에 타입간을 넘어 배정 연산을 하게 된다. 대부분의 C++로 넘어온 지 얼마 안 되는 C프로그래머의 경우, operator =에서의 문제는 고리타분한 Deep copy Shadow copy의 문제만 존재 한다고 생각 하기 때문에 운행중인 시스템이 어느 순간 다운되는 불상사를 겪게 된다. 오늘은 이러한 상속에 의해서 생길 수 있는 배정 문제를 살펴 보고 이를 해결 하기 위해 어떻게 구조 변경을 할 수 있는지 생각 해보도록 하자.

 

명수 | byeguns 앳 sw블로그.net/ http://www.swblog.net

필자는 현재 삼성전자 메모리사업부의 플래시 소프트웨어 개발 관련 연구원으로 플래시 메모리와 관련된 파일 시스템과 커널 드라이버들을 제작하고 있다. 컴퓨터 공학을 전공하고 Haskell Scheme등의 Functional language를 좋아하며, 학부 시절, C++과 관련하여 3개의 논문과 대회 수상을 한 경험이 있다. 올해 들어와서 Class와는 전혀 다른 작업을 통해 조금은 OOP와 거리가 멀어진 길을 걷고 있지만, 항상 언어에 대한 갈등은 지우지 못하는 이제 막 걸음마를 시작한 부족한 그릇의 개발자이다.

 

 

폴리모피즘의 배정 문제(Assignment of Polymorphism)

Partial Assignment 및 Mixed-type Assignment문제

이제까지 우리가 제시했던 최상위 부모 클래스인 People이라는 클래스로부터 상속 받은 Client Employee, 개의 클래스의 핸들링에 있어서 재미있는 이슈를 짚어보자. Client Employee 클래스는 People 다른 역할을 하기 때문에 유독 종류의 클래스에 대해서는 특별한 처리가 필요할 것이다. People 사람이라는 특징을 실체화한 것이고 Client Employee 각각 고객이 가지는 특징, 그리고 임직원이 가지는 특징을 세분화 하였다.

 

//

//            EmployeeClientbase class.

//

class People {

public :

              People& operator=(const People& rhsPeople);

};

 

//

//            Client Employee People로부터 상속받아 대입연산자를 오버로딩함.

//

class Client : public People {

public :

              Client& operator=(const Client& rhsClient);

};

 

class Employee : public People {

public :

              Employee& operator=(const Employee& rhsEmployee);

};

<코드 1> 이전 스텝에서 제시된 Class 다이어그램의 구현

 

 

클래스 선언에서 대부분의 내용을 제외하고 우리가 관심이 있는 대입 연산자에 대한 오버로딩 부분만 살펴 보자. 상위 코드의 바디는 각기 자신만의 대입연산자를 가지고서 특정한 동작 수행하도록 정의되어 있을 것이다. Employee Client 계층도 안에 존재 하기 때문에 아래와 같은 코드의 작성 사용이 가능하다.

 

Employee emp1;

Employee emp2;

 

People     *man1         = &emp1;

People     *man2         = &emp2;

 

//            특정 작업을 후에 man1 man2 대입.

//            어떠한 문제가 숨어 있는가?

//

*man1     = *man2

<코드 2>  Partial assignment 문제의

 

 

  코드의 문제점은 기초 클래스의 타입을 가진 포인터를 사용 하여 폴리모피즘의 입장에서 인스턴스들을 다룸에도 불구 하고 대입 연산자에 대한 메서드는 정적 바인딩이 되도록 제작 되어 있다는 것이다. , Employee People에게 대입하였을 때는 정적 바인딩 대입 연산자에 의해 Partial Assignment 현상이 생긴다. man1 man2 People 인스턴스가 아니라 Employee 인스턴스임에도 불구하고 마지막 코드에서 대입 연산은 People operator = 사용하게 된다. 앞에서도 설명 하였듯 동적 바인딩을 고려한 메서드가 없기 때문에 man1,2 클래스인 People operator = 호출 되는 것이다. 이러한 경우 Employee 클래스가 가지고 있는 People 존재하는 멤버에 대해서만 가져오게 되므로 프로그래머가 의도한 이전의 emp2 속성을 emp1으로 옮기지 못하게 된다. 이러한 포인터에 의한 대입연산은 C 프로그래머에서 C++ 프로그래머로 전향하게 경우 빈번하게 발생하게 된다. C++ 메서드 바인딩 시점을 제대로 이해 하지 못하고 단지 메모리 복사만으로 해결 것이라 생각 했기 때문이다. 코드의 문제점을 해결 하기 위해서 우리가 있는 다른 방법은 대입 연산자를 가상함수로 만들어 버리는 것이다. 사실 폴리모피즘을 고려하였다면 상위 클래스 선언에서 당연히 가상 함수가 되었어야 한다. man1 man2 대입할 동적 바인딩에 의해서 Employee 오버로딩된 대입연산자가 호출 되었을 것이기 때문이다. 그런데 예제는 상위코드에서 가상 함수로 대입연산자를 선언하지 않았을까?

.

class People {

public :

              virtual People& operator=(const People& rhsObj);

};

 

//

//            rhs의타입을주의해서살펴보자.

//

class Client : public People {

public :

              virtual Client& operator=(const People& rhsObj);

};

 

class Employee : public People {

public :

              virtual Employee& operator=(const People& rhsObj);

};

<코드 3> Partial assignment 막기 위해 virtual 반영한 코드

 

 

 그렇다. C++ 표준화 정책에 의해 대입 연산자의 반환 값의 타입은 파생 클래스의 타입을 레퍼런스로 하여 돌려 있지만 가상함수의 파라미터가 되는 인스턴스의 타입만은 기본 클래스의 타입을 그대로 쓰도록 되어 있기 때문이다. Partial Assignment 문제를 클래스의 가상 함수의 역할로 떠넘기려 했지만 가상함수의 정의에 의해 다른 문제인 Mixed-type assignment문제를 야기 한다. 파라미터가 기본 클래스의 타입으로만 정의 된다는 이야기는 대입 연산자의 좌측 인스턴스가 무엇이든 관계 없이 Employee Client클래스에 대입 가능해진다는 이야기이다. 가상함수의 파라미터가 되는 인스턴스의 타입이 그대로 사용되는지 이해가 되는 독자는 “STEP #5 C++ Complier 혼자 해내는 일들 참조 하길 바란다.

 본론으로 돌아가서 Mixed-type assignment 문제가 발생하는 실제 예제를 살펴 보자. 우리는 People로부터 상속 받은 개의 다른 클래스의 인스턴스를 People 통하여 복사를 시도 해볼 것이다.

 

Employee emp;

Client                        clt;

 

People                       *man1         = &emp;

People                       *man2         = &clt;

 

//

//            주도가 바뀌어 버리는 현상이 생긴다.

//            한순간에 임직원이 바로 고객으로 변경되어 버리게 된다

//            우리는 언제나 이런 타입변화에 대해서 매우 주의 하여야한다.

//

*man1 = *man2;

<코드 4> 단순히 virtual 문제를 해결하려 했을 문제 예시

 

 

처음 제시되었던 정적 바인딩을 가진 클래스의 코드 또는 일반적인 배정의 경우, 이러한 mixed-type assignment문제가 야기 되지 않는다. C++ C 달리 타입체크(Strong type check) 하기 때문에 컴파일 단계에서 미리 개발자에게 잘못 것을 브리핑 해준다. 하지만 만약 위와 같이 가상함수로 선언하게 되면 파라미터의 제약 사항에 따라 컴파일러의 타입 체크를 무용지물로 만드는 것이다.

 현재 Mixed-type assignment문제와 같은 경우 언어가 처리 해줄 있는 방법은 없다. 하지만 C++ 언어가 지원 해주지 않는다고 해서 그러한 기능을 개발자가 직접 구현 하지 못하도록 만들어진 우매한 언어는 아니다(물론 일부분에 대해서..)우리가 처음 설계할 삽입되었던 ManagedObject 이슈를 제외하고 일단 현재 존재하는 People , Client, Employee 클래스 안에서 어떻게든 해결 해보자. 기본 클래스인 People 포인터로 Client Employee 삽입한다는 것은 컴파일러 입장에서는 타입 체크를 애매하게 밖에 없다. 왜냐면 People 포인터에 대한 타입은 컴파일 타임이 아니라 런타임 시에 결정 되기 때문이다. 우리는 이러한 문제를 커버하기 위해 대입 연산자 안에서 dynamic_cast 통하여 컴파일러에게 타입 체크를 강제로 인식 시킬 있다.

 

//

//            employee 클래스에서대입연산에대한구현

//

Employee& Employee::operator =(const People& rhsObj)

{

              //

              //                다이나믹캐스팅을통하여right hend side의인스턴스가Emplyee클래스타입인지체크한다

              //

              const Employee& verify_Employee                        = dynamic_cast<const Employee&>(rhsObj);

 

              *this = verify_Employee;

}

<코드 5> 다른 타입으로 전이 되는 것을 막기 위한 코드

 

 

  강한 캐스팅 정책을 가지고 있는 dynamic_cast 키워드에 의해 만약 실제로 Employee 타입이 아닌 경우 컴파일러는 std::bad_cast 반환 해줄 것이다. 이러한 경우 우리가 생각 했던 대로 Employee 아닌 다른 클래스의 인스턴스가 Employee 변형되면서 Partial assignment mixed-type assignment 생기지 않을 것으로 기대 있다. 하지만 코드는 퍼포먼스 측면에서 쓸데 없는 오버헤드를 가지게 된다. Employee 인스턴스 간에 빈번하게 대입 연산이 일어나기 때문에 대입 연산자를 구현 해놓은 것인데 실제 Employee간의 대입 연산이라고 하여도 내부에서 매번 다이나믹 캐스팅을 통해 없는 지역 변수를 소비해가며 메모리 카피를 하게 되는 것이다. 우리는 이러한 쓸데 없는 오버헤드를 줄일 필요가 있다.

 

class Employee : public People {

public :

              virtual Employee& operator=(const People& rhsObj);

              //

              //                기본클래스의대입연산자이외에Employee 자신만의대입연산자를따로추가하였다.

              //                우리가원하는대로직관적정상동작을할것으로예상된다.

              //

              Employee& operator= (const Employee& rhsObj);

};

 

//

//            기본클래스의가상함수를오버라이딩을통해서구현

//

Employee& Employee::operator =(const People& rhsObj)

{

              //

              //                추가된대입연산자를통하여오버라이딩함으로써유지보수및재사용성을높인다.

              //

              return operator=(dynamic_cast<const Employee&>(rhsObj));

}

<코드 6> 타입 전이가 언어의 정책 범위를 넘어가지 않도록 변경.

 

이러한 Employee 대입 정책은 이외로 효과적이다. Employee 추가된 연산자 오버로딩 메서드에 의해서 People 대한 대입 연산자 정의도 있고, 다음과 같은 코드들에서 문제 없이 동작 하기 때문이다. (마지막에 operator = 오버로딩 구현이 <코드 5>에서 제시된 것과 어떤 면에서 최적화가 이루어진 것인지 이해가 되는 독자는 2006 12월에 실렸던 C++ 최적화 이슈에 대한 필자의 글을 참조 바란다.)

 

Employee emp1, emp2;

Client                        clt;

//

//            정상적인대입연산을처리함. (const Employee를파라미터로 받는 오버로딩 연산자호출)

//

emp1 = emp2;

 

People     *man1         = &emp1;

People     *man2         = &clt;

 

//

//            Error 발생   (const People 파라미터로 받는 오버라이딩된 연산자호출)

//

*man1     = *man2;

<코드 7> 다이나믹 캐스팅을 통한 배정 연산의 사용

 

 

 오버라이딩 동작에 의하면 파라미터로 넘어오는 rhs 인자에 대하여 먼저 dynamic_cast Employee 타입으로 변환하여 현재 넘어온 인자가 Employee 클래스 타입인지 체크하고서 Employee 가지고 있는 오버로딩된 대입 연산자를 호출하여 처리 하게 된다. 여기까지 코드를 살펴 보면 우리가 원하는 일을 수행하는데 문제가 없을 것으로 보여 진다. 하지만 객체 지향적 언어의 패러다임 속에 핵심 중에 하나인 가상함수를 통하여 메서드를 구할 이렇게 신경 것이 많다는 것은 뭔가 잘못 것은 아닐까? Employee 클래스를 사용하는 개발자는 대입 연산자를 사용 마다 bad_cast 대하여 대비를 해주어야 한다. 매번 대입이 일어 마다 예외 처리를 위해서 catch문을 붙여야 하므로 우리가 원하는 것을 올바르게 수행하지만 직관적이지 않다. 더욱이 dynamic_cast 아직까지 지원하지 않는 컴파일러도 많기 때문에 호환성 문제에서도 떨어지게 된다. Dynamic_cast 매크로로 디파인 해서 플랫폼 마다 호환성을 가지게 없냐는 질문도 나올 있는데 물론 매크로 등을 통해서 링크까지 성공 있을 모르나 그래 봐야 매크로로 define 캐스팅의 실체는 dynamic_cast 아니기 때문에 Partial assign 근본적으로 막을 없다. 구현은 가능하지만 이렇게 직관적인 코드로 Partial assign 막아야 만큼 C++ 까다롭지는 않을 것이다. 객체 지향적 패러다임에서는 하나의 기능을 사용 하기 위해서 여러 가지 방법을 통해 구현이 가능하다. 이러한 virtual 쪽으로의 구현 말고 다른 쪽으로 접근 해보자.

 접근 전에 우리가 하고 싶은 일을 다시 정리 해보자. 우리는 폴리모피즘 사용을 위해 People 타입으로 변환이 일어 나고 나서 Employee Employee끼리, Client Client끼리만 연산을 시키고 싶은 것이다(물론 Partial Assignment 영향은 없어야 한다.). 그렇다면 Emlpoyee Client 대입 연산자는 클래스에서 따로 오버라이드 하고 People 클래스에서는 대입 연산자를 Private으로 만들어 버리면 우리가 원하는 대로 구현 있지 않을까? 이렇게 구현하게 되면 People 타입 캐스팅 되었더라도 이질적인 클래스의 대입 연산은 People operator = 사용할 없기 때문에 각자 자신의 대입 연산자를 호출 하게 것이고 이는 virtual 아니기 때문에 애초에 자신에게 맞는 파라미터만 받을 있게 된다.

 

//

//            이제 People 대입연산자는 Private 되었기 때문에 바깥에서 함부로 호출할 없음.

//

class People {

private :

              People& operator=(const People& rhsObj);

};

 

//

//            자신과 동질적인 클래스에 대한 대입연산만 수행하도록 오버라이딩.

//

class Employee : public People{

              Employee& operator=(const Employee& rhsObj);

};

 

class Client : public People{

              Client& operator=(const Client& rhsObj);

};

<코드 8> 다이나믹 캐스팅을 제거 하고 새로 배정 연산문제에 접근

 

 주석에서 나와 있는 것처럼 상속을 받은 동질적인 클래스의 대입 연산에서 내부 포함관계에 있는 People 멤버들도 같이 처리하기 위해, 어떻게 오버라이딩을 사용할 있을까? 여기서 문제는 People 대입 연산자가 Private이기 때문에 정상적인 오버라이딩을 구현 없다. 물론 정상적인 동작 원리 준수를 위한 오버라이딩을 피해 구현 하면 Private이라도 상관 없겠지만(일일이 멤버들을 복사), 이러한 operator = 구현에서는 기본 클래스의 대입 연산자를 호출 해서 기본 클래스의 대입 연산을 대신 맡기는 것은 기본적 구현 방법이다. 실제 사용 없는 코드 구현을 살펴 보자.

 

Employee& Employee::operator =(const Employee& rhsObj)

{           

              //

              //                People 대입연산자는 private이므로 Employee에서 오버라이드할 없다.

              //                만약 구현을 위해서라면 Protected 선언하면 이러한 문제없이 아래코드를수행할 있다.

              //

              People::operator =(rhsObj);                

 

              //

              //                Employee에게맞는연산을수행.

              //

}

<코드 8> private operator = 가지는 오버로딩 문제.

 

 

 코드의 주석처럼 Protected 선언하여 대입 연산을 오버라이드 있다고 가정하고 문제를 해결 해보자.

 

Employee emp1, emp2;

Client                        clt1, clt2;

 

emp1 = emp2;                              // 동질적인 클래스의 대입연산은 OK.

clt1 = clt2;

 

People     *man1 = &emp1;

People     *man2 = &emp2;

 

man1 = man2;                              // 이질적인 클래스 대입연산은 원천적으로봉쇄.

<코드 9>  Protected 배정문제를 해결한 것처럼 보이는 예제.

 

 

이질적인 클래스 대입 연산에 대하여 Protected 선언되어 있는 대입 연산자를 호출 하기 때문에 바로 컴파일시에 에러가 나게 된다. 이번 것은 virtual 대입 연산자를 단순히 Protected 변경하여 우리가 가지고 있던 이질적인것과 동질적인 것에 대한 대입 연산에서 오는 문제점을 막을 있으므로 꽤나 효율적이다. 그런데 문제는 People간의 대입 연산이다. 상위 코드처럼 문제를 깨끗하게 해결 하고서도 실지 People간의 대입 연산이 일어나면 Protected 선언된 대입 연산 때문에 바로 컴파일 에러가 나게 된다. 그렇다면 Protected 선언된 People 클래스를 아예 인스턴스화 못하게 하면 어떨까?

 

Leaf-node 아니라면 상속관계에서 모두 추상화로 만들자.

 

 문제 해결은 인스턴스화를 못하게 하여 Protected 멤버는 호출 하지 못하게 하는 형태로 구현 되면 것이다. People 경우 구체화 클래스에 들어가기 때문에 이를 추상화 클래스로 만들어야 한다. 하지만 저번 스텝에서도 우리는 이에 대한 People클래스를 코드상에서 사용하기 위해 만든 것이므로 바로 추상화 클래스로 만들 수는 없다. 그래서 ManagedObject 같은 추상화 클래스를 상위에 얹히고 그를 통해서 People Client, Employee 단계별로 상속 관계를 형성 하는 것이다. 여기서 우리의 가정은 People 데이터 멤버가 존재 한다는 것이다. 만약 People에게 데이터 멤버가 존재하지 않는다면 어차피 사용처도 없는 클래스를 추상화 클래스로 변경 하는 것이 올바를 것이다. 추상화 클래스를 만들기 위해서 우리는 순수 가상 함수를 하나 삽입해야 하는데 ManagedObject 특정 처리를 야할 멤버 데이터도 존재 하지 않고 자신의 아래 구체 클래스들과 특별히 겹칠 일은 없다. 이에 따라 일반적으로 많이 사용 하는 수법 중에 하나로 파괴자를 순수 가상함수로 선언하여 삽입한다.

 

class ManagedObject {

protected :

              ManagedObject &operator= (const ManagedObject& rhsObj);

public :

              //

              //                순수가상함수로써의추가

              //

              virtual         ~ManagedObject()           = 0;

};

 

//

//           

//

class People : public ManagedObject {

public :

              People& operator=(const People& People);

};

 

 

//

//            Client, Employee모두People에서상속을받거나

//            또는계층도를완전히변경하여아래와같이ManagedObject로할당받는Peoplesibling

//           클래스로제작될수있다.

//

class Client : public ManagedObject {

public :

              Client& operator=(const Client& rhsObj);

};

 

class Employee : public ManagedObject{

              Employee& operator=(const Employee& rhsObj);

};

<코드 10> 개의 배정연산 문제를 해결 하기 위한 계층도의 변화.

 

 

 이렇게 순수 가상함수에 의해서 추상 클래스가 인터페이스로 잡히고 나면 우리가 기대 했던 같은 연산끼리의 대입 연산끼리의 배정은 가능해지고 Partial Assignment Mixed Assignment 문제를 해결 있어진다. 그리고 내부적으로 Protected 선언된 기본클래스의 오퍼레이터는 상속 관계에 의해서 Client Employee에서 문제 없이 호출하여 오버라이딩 있기 때문에 상위에서 제시된 문제를 해결 있다.

 우리가 오늘 스텝의 처음에서 제시된 문제들을 해결 하는데 있어서 Leaf node 존재 하지 않는 클래스에 대하여 추상 클래스인 ManangedObject 추가 함으로서 계층도를 간단히 변형하고 구체 클래스인 Client, Employee클래스들에 대하여 코드 변형 없이 그대로 사용할 있게 하였다. 물론 ManagedObject 순수 가상 소멸자의 구현에 대한 오버헤드 정도는 존재 하나 실제 ManagedObject 아니 였더라도 포인터를 통한 폴리모피즘을 지원 하기 위해서는 기본 클래스인 People에도 가상 소멸자가 들어가야 했기 때문에 우리는 단지, ManagedObject 소멸자를 바깥에서 구현 하는 정도의 수고만 해주면 되는 것이다.

 이와 같이 일반적으로 개의 관계를 가지는 아래 <그림 1> 같은 상속도의 클래스들은 폴리모피즘의 사용 문제 이외에도 우리가 오늘 다룬 개의 배정문제도 가지고 있기 때문에 수정될 필요가 있다.

 

사용자 삽입 이미지

<그림 1> Two-cass hierarchy 관계

 

Leaf node가 아닌 A의 클래스를 오늘 우리가 생각해본 것을 적용하면 <그림 2>와 같은 형식으로 계층도를 변형 할 수 있다. , 새로운 추상 클래스 I를 새로 끼워 넣고 A B I를 통해 상속받게 하는 것이다.

 

 

사용자 삽입 이미지
 

<그림 2> 구조 변형을 통한 Three-class hierarchy관계

 

 이렇게 상속 관계를 변경 했을 때 또 다른 이점은 무엇일까? UML을 보면 알 수 있듯이 내부적으로 어떤 멤버와 메서드를 가지고 있는지 표기 되어 있지 않은 상태라고 하여도 A B의 공통점을 I를 통해 확실히 추상화(Abstraction)할 수 있다는 것이다. A로부터 B를 상속 받았기 때문에 분명 그 둘 간의 관계에서 공통점이 존재 할 텐데 사용자 입장에서는 코드를 까지 않는 이상 어떤 공통점이 존재하는지 좀 애매한 상태에서 두 클래스를 사용 할 수 밖에 없습니다. 우리나라에서 가장 범용적인 거시기라는 대명사와 똑같이 무엇인지 말할 수는 없지만 분명 둘 간의 공통점이 존재 하긴 하지만 뚜렷하게 그 실체가 들어 나 있지 않습니다. 하지만 추상 클래스 I를 삽입 함으로서 이를 사용자에게 명확히 인지 시킬 수 있다는 장점이 존재 한다.

 

Leaf-node 아니라면 상속관계에서 모두 추상화로 만들자.

 

오늘의 구현 이슈에서 얻은 것이 많을 것이라고 생각 한다. 누구나 중요하게 생각하는 추상 클래스를 어떻게 실제 구현 이슈에 결합시킬 수 있는지에 대한 일부 예가 될 수 있기 때문이다. 그런데 이 추상 클래스의 적용이 모든 해결점을 제시 하지는 않는다. 보는 관점에 따라 조금씩 틀리긴 하지만 일반적으로 클래스로 만들어질 모든 대상들은 모두 일종의 추상 타입이라고 할 수 있는데 그렇다면 모든 클래스들의 계층도를 변경 해야 하는 것은 아닐까? 예를 들어 하나의 클래스를 추상 클래스와 구체 클래스 이렇게 두 개의 하이러키를 가지는 클래스 계층도로 변경해야 하는 가 하는 문제다. 여기에 대해서 Scott mayors는 단호하게 No라고 대답한다. 클래스 개수가 많아 지는 것은 그렇게 현명하지만은 않는 일이다. 이렇게 클래스가 특별한 목적 없이 단지 많아지기만 해서 복잡해 진다면 이러한 계층도는 이해하기도 힘들어지고 유지 보수가 힘들어지게 된다.

 그렇다면 어디까지가 추상 클래스에 의한 계층도를 가져야 하는 것일까. 필자는 개인적으로 Interface를 통한 계층도 구현은 아주 중요한 것이라고 본다. Java의 경우 Interface를 반영하는 직접적인 방법을 가지고 있지만 C++의 경우 Interface가 없어도 구현에 특별한 문제가 없도록 언어가 구성되어 있기 때문이다. 대부분의 C++ 프로그래머들은 추상 클래스를 무시하고서도 코딩 하는데 특별한 문제를 못 느낀다. 그래서 더더욱 추상 클래스의 사용을 습관화 하는 것이 좋다라고 생각 하는 것이다. 물론 적절한 사용이 가장 올바르겠지만 OOP언어를 사용하면서 객체 지향적 패러다임에 근접하도록 언어를 사용하는 것 또한 중요하다.

마지막으로 필자가 하나의 조언을 더 하면 어떠한 이유에서든 추상클래스가 한번 필요하게 된 계층도는 반드시 그 뒤에도 빈번하게 추상클래스가 필요하게 될 확률이 높다는 것이다. 이러한 경우, 즉 한번이라도 추상 클래스의 사용이 필요하다고 느껴지는 계층도를 가진 프로그램에서는 정상적으로 시스템이 동작한 것을 확인 한 뒤에 구체 클래스를 추상 클래스로 변경하는 리팩토링하는 정도의 수고를 해주는 것이 추후 유지 보수에 있어서 또 시스템 운용에 있어서 많은 리스크를 줄여 줄 것이다.

 

 

참조 :

Addison Welsey, Scott Meyers. More Effective C++

배움터, 김태균저, K교수의 객체지향 이야기

 

'Drafts > C++' 카테고리의 다른 글

[STEP 10] C++인터페이스(Interface)  (0) 2008/06/05
[STEP 9] 상속구현 #2, More inheritance  (0) 2008/03/01
[STEP 8] Interitant impelemetation  (0) 2008/02/06
[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06

상속의 자연스러움에 대한 평가.

 확장성에 대한 평가는 저번 스텝에서 어느 정도 이야기가 되었기 때문에 이번 스텝에서는 특별히 다룰 주제가 없다. 생성된 3가지의 모든 다이어그램은 IS-A관계에 있어서 문맥적으로 어색하지 않다. 이는 특별한 부가 설명 없이도 상속 트리 구조가 매우 간단하기 때문에 쉽게 평가 있을 것이다. 자신이 추상화한 시스템의 설계들이 IS-A HAS-A 관계에 있어서 모두 동일한 점수를 획득하여 디테일한 평가가 필요하다면 LSP(Liskov Substitution Principle) 대한 원칙을 적용 해볼 있다. LSP OOP Seminal paper 주장 하나로 오랫동안 상속을 구현하는 자들에게 등대가 되었다. Liskov 그의 논문에서 파생클래스가 특수화 과정을 위하여 기본 클래스로부터 상속을 받지 않는 경우라면 상속을 피해야 한다고 주장했다. LSP 2000년에 들어서 Andy Hunt Dave Thomas 의하여 다시 아래와 같은 내용으로 재해석 되었다.

 

사용자는 서브클래스를 사용함에 있어서 기본 클래스의 인터페이스를 통하여 둘의 차이점을 인지 하지 않고도 사용 있어야 한다

 

이를 굳이 필자가 다시 정리해보자면 트리 내에 클래스 계층도를 형성하고 있는 클래스들의 메서드는 모두 동일한 의미의 일관성을 가져야 한다는 것이다. 만약 상속을 사용하여 작성된 프로그램이 중복성을 굳이 고려 하지 않았다고 가정하더라도 LSP 의해 상속 설계가 투명하게 제작 되었다면 프로그래머들이 메서들에 대한 세부 사항들에 대해서 걱정 하지 않고 객체 자체의 일반적인 특성들을 구현하는데 중점을 두었을 것이기 때문에 상속의 복잡성이 어느 정도 제거 되었다고 있다.

 혼합식 다이어그램을 통해서 LSP 예를 간단하게 살펴 보자. 혼합식 다이어그램으로부터 파생되는 클래스다이어그램을 참조하면(그림 5) 개발자와 관리자의 클래스를 Employee라는 중간의 기본 클래스로부터 상속을 받아 구현하고 있다. 여기에는 GetPay라는 것이 가상함수로써 선언되어 있는데 있다. 이는 현재 인스턴스가 자신의 월급을 얼마나 되는지에 대한 값을 return 한다고 가정해보자. 월급을 리턴 하는 Employee 클래스를 상속 받은 개발자와 관리자는 가상함수에 대하여 오버로딩 하여 구현 있는데 관리자는 개발자와 달리 쓰임새가 달라서 월급을 리턴 하는 것이 아니라 연봉을 리턴 한다고 생각 해보자. 이러한 경우 Employee 통하여 상속을 받아 구현 하였음에도 불구하고 메서드의 의미 자체가 달라지게 되어 LSP 위반 하게 된다.

 

//

//            DeveloperPlannerbase가되는기본클래스

//            임직원에대한메서드를관리한다.

//

class Employee {

private :

              UINT32 nPay;

 

              // ., 기타UML에서정의된속성들

 

public :

              virtual UINT32 GetPay()                    { return nPay; };             // 임직원은모두봉급에관한속성을취할수있다.

 

};

 

class Planner : public Employee  {

public :

              virtual UINT32 GetPay()                    { return nPay * MONTH_PER_PAY; };                 // LSP 위반

};

<코드 1> LSP 위반하는 구현

 

 

 

 

이렇게 Employee 상속을 받은 개발자와 기획자 클래스의 GetPay루틴은 동일한 의미를 가지지 못하기 때문에 Employee로부터 상속을 꾀하지 말라는 것이다. 상속을 사용하고 싶다면 완전히 다름 이름을 가지는 메서드를 오버라이드하여 구현 하는 것이 좀더 바람직하다. GetPay 통한 취득보다는 연봉에 대한 다른 루틴, GetAnnualPay() { return GetPay() * MONTH_PER_YEAR; }; 같은 구현을 통하여 사용 하는 것처럼 말이다.(물론 이러한 방법은 인터페이스의 의미를 변경하고 자손 클래스로의 확장에 있어서 유지보수를 힘들게 하고, 객체 속성에 충실하지 못하는 클래스를 만든 다는 이유 때문에 논쟁 거리를 앉고 있다.)

 눈치가 빠른 독자는 벌써 생각 하였겠지만, 설계 평가에 있어서 이러한 LSP 원칙은 순수 상속 설계보다는 구현에 가까운 생각들을 많이 하게 하므로 우리는 단지 이름을 가진 클래스의 추상화 수준에서 IS-A 관계를 파악하여 평가를 하되, 다이어그램의 상속성의 자연스러움에 대한 가중치를 주기 위한 수단으로 LSP 같은 원칙을 적용하는 하면 것이다.

 

 

중복성에 대한 평가.

 클래스 계층도에 있어서 중복성에 대한 평가를 확인 하는 이유는 코드의 효율성과 유지 보수의 용이성을 고려하려는 것이다. 중복성을 가진 클래스 계층도를 통해 제작된 시스템은, 같은 코드가 여러 곳에 산재해 있다. 만약 유지 보수 기간 동안 시스템의 중복성을 가진 일부 기능을 변경해야 하는 경우, 같은 수정 작업을 코드 중복이 산재 되어 있는 만큼 여러 곳에 걸쳐서 수정해야 하는 어려운 상황에 빠지게 된다. 물론 중복성을 완전히 제거 하는 것이 우리 개발자의 꿈이겠지만 여러 가지 이유에서 우리가 제작하는 시스템은 코드 중복이 절대 존재 하지 않는 바람직한 이상형의 시스템이 절대 없다는 것은 Steve McConnell 같은 대가를 통해 기정 사실화 되어 있다.

비록 코드 중복이 없는 이상적인 시스템을 작성 없다고 하더라도, 시스템의 개발자는 상속 트리를 설계하여 같은 코드가 여러 곳에서 산재하지 않도록 하는 정도는 개발자의 예의가 아니겠는가? 코드 중복 여부를 판단 하는 가장 쉬운 방법은 같은 속성(Attribute, 데이터 멤버) 제작 되는 시스템의 여러 클래스에 존재 하는지를 확인하여 판단 하는 것이다.

이러한 평가를 위해 우선 나열식 다이어그램을 클래스 다이어그램으로 도식화 해보자. 나열 수준의 다이어그램에 대해서 많은 사람들이 나쁘게 평가 하고 수준의 설계라는 것을 직관적으로 알고 있겠지만 사실 우리는 이러한 나열 수준의 디자인을 타의에 의해서이든 자의에 의해서이든 실제 프로젝트에서 하고 있다. 바쁜 프로젝트에 매진하다 보면 간혹 요구사항에 없는 모듈을 구현하여 기존 시스템에 넣어야 하거나 시스템의 기능과는 완전히 동떨어진 것의 일부분을 구현 해야 하는 경우도 있다. 이런 경우 대부분의 프로그래머에게는 시간적 여유가 주어지지 않기 때문에 형상화 도구의 사용은 불구 하고 머릿속 디자인을 생각 시간조차 없이 편집기 앞에서 바로 프로그래밍 해야 하는 경우가 다반사인 환경에서 이러한 설계를 사용한적이 한번도 없다고 주장할 있는 개발자는 소수 것이다. 그렇다고 해서 이러한 나열식 설계가 나쁘기만 것은 아니다.


사용자 삽입 이미지

<그림 1> 나열식 구조의 클래스 다이어그램

 

클래스 다이어그램의 가장 문제는 중복되는 속성 (Attribute) 아주 많이 존재 한다는 것이다. 클래스 다이어그램에서 있듯이 고객(Client) 클래스와 후원자(Supporter) 클래스에서는 등급(m_nClass) 자본금(m_nMoney) 중복되고 개발자와 관리자, 기획자의 클래스에서도 부서(m_nDepartment), 급여(m_nPays), 등급(m_nClass), 경력(m_nCarrier) 속성이 중복되며 ManagedObject아래에 추가 되는 모든 클래스는 (m_bSex) 대한 속성이 매번 겹친다.

속성과 같은 데이터 멤버가 중복 된다는 것은 속성에 대한 행위 연산이 중복 되고 속성을 이용하는 알고리즘이 같은 방식으로 구현 되어 결국 클래스에 대한 코드가 많은 중복성을 가지게 가능성이 높다는 것을 의미한다. 이런 나열식 클래스 다이어그램은 코드 반복성에서 분명히 좋지 못한 점수를 받을 밖에 없다. 이전에 제시 되었던 기계식 클래스 계층도는 이러한 측면에서 가장 효율적인 계층도가 되며 나열식 클래스 계층도의 경우 가장 비효율적인 클래스 계층도가 것이다. 물론 효율적인 측면에서의 평가가 좋다고 해서 클래스 계층도가 뛰어나다고 수는 없다. 중복성 이외에도 상속 간의 관계의 자연스러움과, 확장성을 모두 고려 하여 후보 다이어그램 중에서 가장 적절한 계층도를 선택 해야 것이다.

 

확장성에 대한 평가.

언제나 그러하듯 소프트웨어 요구사항은 시간이 지날수록, 프로젝트가 진행 되는 시간의 흐름에 따라 점차 늘어 나는 경향이 있다. 나선형 개발 프로세스를 가진 객체 지향적 프로세스에서 이러한 경향은 일반적인 현상으로 받아 들여진다. 대동여지도를 제작하면서 경상 남도 덩어리 하나를 제작 해두고 다른 전라 남도 덩어리에 대한 지도를 그리고 붙여 하나의 지도를 만들 , 객체 지향적 프로세스로 개발되는 경우, 전체 시스템에서 일부 기능을 하는 클래스들을 제작하여 먼저 시스템을 구성하고 차후 처음에 생각 하지 못하거나 또는 구현하지 못한 나머지 모든 기능을 추가하여 전체 시스템을 구성하게 되는 경향이 강하기 때문에 확장성에 대한 평가는 설계 처음부터 아주 중요한 역할을 하게 된다.

클래스 확장이란 개념에 대하여 약간 모호함이 존재하는데 일반적으로 이러한 클래스 확장성에 가지로 해석이 가능하다. 첫째는 기존 클래스 자체에 대한 확장을 요구하는 경우이다. 이러한 경우 기존 클래스에 기능이 확장 됨으로써 시스템이 변경되어야 하는 경우가 있다. 둘째는 시스템 요구사항을 만족 하기 위해 새로운 클래스가 제작되거나 새로운 컴포넌트로 인해 확장을 하게 되는 경우다. 전자의 경우에 예를 들자면 고객, 후원자, 기획자, 개발자, 관리자 등에 대한 각각의 클래스에 해고를 위한 Entire()라는 메서드가 추가 되어야 하는 경우를 생각해 있다. 이런 경우 ManagedObject 삽입하게 되면 모든 클래스에서 Entire 메서드를 바로 받아 사용 있기 때문에 적절히 대응 있으나 개의 특징으로 구분되는 그룹 단위로 각기 다른 메서드가 삽입되는 경우 일괄적으로 ManagedObject 삽입 하여 시스템을 유지보수 수는 없다. 관리자, 기획자, 개발자의 클래스에 새로운 프로젝트를 설정하는 NewProject()라는 메서드가 삽입되어야 한다고 하면 메서드와 연관성이 없는 고객과 후원자와 같은 클래스를 모두 아우르는 ManagedObject NewProject라는 메서드를 삽입 수는 없을 것이다. 이러한 확정성은 클래스 계층도의 유연함보다 코드 중복에 밀접한 관련이 있다. 왜냐면 결국 메서드라는 것도 특정 속성을 가지는 데이터 멤버에 따라 결정되기 때문이다. 우리는 앞서 코드 중복성에 대하여 생각 해보면서 이에 대한 적절성을 짚어 보았으므로 전자의 의미를 통해 확장성을 평가 하고자 하는 것은 아닐 것이다.

 후자의 차후에 새로운 클래스가 추가될 여지가 있는 가에 대한 평가가 우리가 원했던 확장성에 대한 평가에 가깝다. 이전 스텝에서 보안견(Dog) 관련된 클래스를 다시 한번 돌이켜 보자. 유지 보수 기간에 일종의 회사 내부의 자산인 보안견이라는 클래스에 대해서도 추가적으로 관리하도록 요구사항이 변경 되었을 경우 클래스를 어디에 배치하여 시스템을 구성할 것인가 하는 문제가 발생한다. 보안견의 클래스의 경우 기본적인 나이, 성별, 개인 관리 아이디를 가지고 있고 보안견을 판매했던 상점들에 대한 속성도 함께 관리 해야 한다.

나열식 클래스 계층도에 가장 상위에 존재하는 기반 클래스인 ManagedObject로부터 보안견 클래스는 파생될 있다. 이는 IS_A관계를 통해 평가 해볼 상속에 대한 자연스러움을 만족하며 다른 클래스에 영향을 주지 않고 시스템의 분류를 새로 차지 있다. 물론 ManagedObject 너무나 평범한 기반 클래스이다 보니 보안견 클래스에 보안견에 대한 특성이 많이 반영되도록 코딩의 수고가 필요 하겠지만 이전 스텝에 효율성 면에서 아주 높은 평가를 받았던 기계식 클래스 계층도에 비하여 훨씬 유연한 상속 확장성을 제공한다.

그럼 이전에 효율성 면에서 높은 평가를 받았던 기계식 클래스 계층도의 문제를 살펴 보자.


사용자 삽입 이미지
 

<그림 2> 기계식 구조에 새로운 요구사항이 추가 되는 경우

 

새로운 클래스가 요구사항에 의해서 계층도에 삽입되어야 이상적으로 설계된 계층도라면 어느 클래스의 자식 클래스로 배치 것인가를 판단 하기 쉬워야 하는데, 기계식 클래스 계층도에서는 보안견 클래스의 기반 클래스가 있는 클래스는 어느 곳에서도 존재를 찾기 힘들다. 정확히 이야기 하면 기계식 계층도에 의해 만들어진 시스템에 보안견이라고 하는 클래스는 어디서도 상속을 받아서는 된다. 시스템의 입장에서 보았을 고객이나 임직원처럼 관리 대상임이 틀림 없기 때문에 클래스가 현재 구현되어 있는 계층도 어디엔가 배치 되어야 기존 시스템과 어울리기 좋은데 기계식 계층도에서는 보안견의 상속을 위한 설계가 없기 때문에 상속을 금지해야 하는 대상이 되는 것이다..

 

대부분의 개발자가 기반 클래스에서 상속을 받는 파생 클래스를 만들면서 클래스 상속 관계가 자연스러운지에 대하여 생각 해보지 않는다. 상속을 받아 새로 구현될 파생 클래스가 구현 요구사항의 기능성(Functionality) 만족하면 구현 시간의 단축과 재사용성이라는 이유를 방패 삼아 대부분 그대로 상속 구현을 하게 된다. 이러한 상속은 C 프로그래머가 단지 시그니쳐가 같은 함수를 그대로 Copy & Paste하는 경우와 같다. 한번이라도 이러한 상속을 구현해본 프로그래머라면 설사 시스템이 동작하더라도 뭔가 찝찝한 마음이 가시지 않는 경험을 보았을 것이다. 상속관계가 단지 기능적 구현이나 중복 코드를 막기 위해서 사용 되면 어떠한 문제가 존재 하는 것일까?

  시스템을 새로 담당한 없는 프로그래머가 보안견을 추상화하여 Dog라는 클래스를 생성 하여 기존 시스템에 삽입 하려고 보니 상속을 받아야 기존 시스템에 리스트, 관련 자료구조 그리고 자료 구조에 바인딩 알고리즘들을 사용 있다. 그렇다고 해서 새로운 클래스를 상속 없이 기존 시스템에 끼워 넣는 것이 간단한 공사처럼 보이지도 않는다. 보안견(Dog) 클래스의 속성은 이름이랑 성별, 그리고 개마다 가지고 있는 고유한 아이디 정도가 필요한데 People이라는 클래스가 마침 그러한 속성을 그대로 가지고 있다. People이라는 클래스로부터 보안견을 상속 받으면 기존 시스템의 자료 구조와 알고리즘들을 거의 보지 않고 그대로 끼워 넣을 있고 그럼으로써 새로운 클래스를 추가하고 현재 시스템의 클래스 계층도를 변경하여 들어가는 시간, 비용, 노력을 절감할 있다. 마침 회사에서 시스템이 해야 하는 요구사항을 생각해보건 , 보안견과 임직원은 시스템으로부터 관리되어야 대상이라는 점에서 같다. IS-A관계를 만족하지 않음을 개발자는 익히 알고 있음에도 불구하고 막노동에 지친 개발자에게 이러한 조건은 상당히 유혹적일 밖에 없다. 여러분은 어떻게 생각 하는가? 내일 일은 내일 생각하자, 이렇게 해서 유혹을 뿌리치지 못하고 상속을 받을 경우, 현재 변경된 시스템에서는 속성이 일치 하고 기존 시스템의 대부분의 모듈들을 그대로 있기 때문에 추가가 간단해지고 특별한 문제가 없이 돌아 있긴 하지만 이러한 상속은 앞서 말한 것과 같이 결국 시스템 전체를 망가트리는 결과를 가지고 온다.


사용자 삽입 이미지
<그림 3> 잘못된 상속 구조

 

개와 사람과 같이 우리가 직관적으로 판단 있는 대상을 비유하여 상속 관계를 만들어 두었기 때문에 당연히 아니 개의 아버지가 사람이라니요?” 라고 되묻겠지만 People이라는 클래스가 만약에 MFC CObject CListCtrl 같은 것이었다면 개를 관리하는 컨트롤을 만들기 위해서 Dog 클래스를 CListCtrl으로부터 상속 받아 가능성도 있다. 명명 관계에서 Dog is a XXX 성립하지 않으면 무조건 상속 하지 말라. 나중에 시간이 지나서 만약에 시스템이 보안견을 판매 하는 상점(Store)이라는 클래스와 상점 주인(Owner)이라는 클래스를 다루는 회사내의 외부 모듈과 연결 되어야 한다면 앞서 추가된 보안견 클래스 하나 때문에 전체 시스템이 말이 되는 클래스 계층도를 가지게 된다.

사용자 삽입 이미지

<그림 4> 다른 모듈과 함께 잘못된 상속구조가 시스템에 결함을 주는 경우

 

 

 다른 모듈과 시스템이 조합되어 버린 시스템의 클래스 다이어그램에서 우리는 문제를 쉽게 확인 있다. 상점 주인인 Owner 클래스는 People로부터 구현 받아 외부 모듈에서 관리 되고 있고, 주인은 자신이 판매할 보안견들을 배열이나 다른 컨테이너를 통해서 멤버로 보유 하고 있었을 것이다. 또한 투자자는 관심사에 이러한 상점 주인들을 멤버로 가지고 있어서 InterestThing()이라는 메서드를 통해 이를 쿼리 있다. 그런데 이렇게 되면 보안견을 판매 하는 판매자와 투자자, 그리고 판매 대상이 되는 주체인 보안견은 같은 조상을 가짐으로써 시스템 내에서 동등한 관계를 유지 하게 된다. 이러한 구조는 개와 상점 주인, 그리고 후원자를 모두 동일하게 만들고 세계와 전혀 관계 없는 추상화 수준에서 프로젝트를 운용하게 된다.

 독립된 시스템에서 또는 확장되기 전의 시스템에서는 문제 없이 돌아가던 것이 자신의 의사와 관계 없이 요구사항의 변화에 따라서 또는 외부 모듈과의 연결에 의해 시스템 전체가 의도하지 않은 바대로 동작 하게 가능성이 크다. 아니 그냥 동작하지 않는다 라고 하는 것이 올바르겠다. 혹시 개발자가 꼼수를 통해서 이를 동작 하도록 변경 했다고 생각 해보자. 수많은 버그를 클래스에서 잡아 내고, 조건에 따라 보안견을 구분하고 수십에서 수백 개의 플래그를 코드상에 박아 넣어 동작 시키게 했다라고 하더라도 개발자는 자신이 만들어낸 말도 되는 조건 분기와 플래그들 속에서 프로젝트를 다음 번에 다시 이해 있을까?

 차후 보안견을 추가한 개발자가 문제점을 발견하고 이를 수정 하려 한다고 해서 초기 잘못된 상속의 문제는 간단하게 원래 위치로 돌아오지 않는다. 보안견이라는 클래스를 시스템가 추가한 시점부터 벌써 클래스들 간에 관계는 복잡해 지게 되고 이에 대한 문제점이 굵어져 상속관계를 투명하게 유지 하려고 즈음이면 잘못 클래스의 메서드 곳곳에 박혀 있는 의미 모를 플래그들을 제거 하는 것만 해도 엄청난 공사가 것이다. 한번 상속에 대한 잘못된 구현은 당시 상속에 대한 클래스 추가가 이루어 때와는 비교도 안될 만큼 비용이 들어가게 된다는 것을 명심 해야 한다.

 

마지막으로 혼합식 계층도를 평가 해보자. 우리가 언급하고 있는 기계식, 나열식, 혼합식 계층도는 모두 어느 정도 자연스러운 상속관계를 유지하고 있다고 이야기 있다.

 

사용자 삽입 이미지

<그림 5> 혼합식 구조의 클래스 다이어그램

 

 

이 혼합식 계층도는 사실 기계식 계층도의 사람 클래스 위에 추상 기초 클래스로 인터페이스가 되는 ManagedObject의 인터페이스를 하나 더 얹은 것에 불과 하다. Scot Mayer에 의해서도 언급 된 적이 있지만 이와 같이 상속에 단말 노드로 있지 않는 클래스들은 추상 클래스로 만드는 것이 설계상 많은 이점을 준다(이는 다음 스텝에서 좀 더 상세하게 알아 볼 것이다). 물론 필요에 따라 구현 시 고객과 임직원의 경우도 추상 클래스로서 제작이 가능하다. 혼합식 계층도의 경우 시스템이 관리해야 하는 다른 개체 즉 보안견과 같은 클래스는 ManagedObject아래에 다른 분류로써 배치가 가능하며, 만약 외주 개발자와 같이 임직원과 고객 사이에 애매하게 존재하는 클래스에 대해서도 사람이라는 클래스를 통해 새로운 분류로의 상속이 가능하도록 여운을 남겨 주고 있다. 추가가 용이 하면서도 코드의 중복성을 최대한 줄이는 편으로 계층도가 형성되는 것이 가장 올바르다. 혼합식 계층도가 다소 복잡해 보일 수도 있지만, 일반적으로 공통적으로 사용되는 API, 데이터, 메서드(또는 behavior)들은 가능한 한 가장 높은 곳으로 이동 시키는 것이 좋다. 이렇게 높은 곳으로 이동 시킬수록 파생 클래스가 이들을 쉽게 사용 할 수 있으며 이로 인해 여러 개의 단말 노드에서의 코드 중복성을 피할 수 있다. 물론 무한정 높이는 것은 깊은 상속 트리를 만들게 할 수 있다. 객체 지향적 프로그래밍은 복잡성을 관리 하기 위해 수많은 기법들을 제공하고 있지만 이처럼 깊은 상속으로 상속의 기법이 전환되게 되면 복잡성을 줄이기 보다 증가 시키는 경향이 있다. Basili, Brand, Melo에 의해 깊은 상속 트리는 오류율 증가와 상당한 연관성을 가지는 것이 입증된 바 있다. 그럼 어디까지 올리는 것이 좋을까? 그것은 해당 객체의 추상화 수준을 깨어 버리는 위치까지만 배치 하는 것이다. 예를 들어 혼합식 계층도에서 임직원(Employee)의 속성인 GetPay()가 사람의 클래스까지 올라 가게 된다면 그것은 추상화 수준을 깨버리는 것이 될 수 있으므로 임직원 클래스상에서 배치가 마감되어야 한다.

 

종합평가

이제 우리는 상위 3가지의 평가 항목에 대하여(하나는 저번 스텝에서 제시되었다) 아래와 같은 상속 설계의 후보들을 평가 할 수 있을 것이다.

 

평가 항목/ 계층도

기계식

나열식

혼합식

상속의 자연스러움

매우 좋음

보통

좋음

코드의 중복성

매우 좋음

매우 나쁨

좋음

확장성

나쁨

보통

매우 좋음

 

< 1> 종합 평가 테이블

 

 

평가 결과로부터 우리는 종합적으로 계층도를 판단해 볼 때 상속 설계에 있어 혼합식 계층도가 가장 유용하게 쓰일 수 있음을 알 수 있다. 우리가 예로 든 한 IT업체의 시스템은 상속 설계에 있어 평가되어야 항목들을 살피고 각 항목의 장단점을 고찰 하는데 그 목적이 있었기 때문에 아주 간단한 예제 이다. 실제 시스템을 구현하는 경우 요구사항의 분석이 어렵고 쉽게 평가하기가 쉽지 않다. 특히 특정 프레임 워크나 솔루션을 제공하는 경우가 아니라 실체를 알 수 없는 다수의 개발자를 고객으로 하는 라이브러리, 코어, 엔진등을 제작 할 때 있어서 상속 설계에 대한 평가는 아주 난해한 면이 있으며, 이러한 분야의 경우 많은 시행착오를 겪으며 만들어질 가능성이 높다.

 상속 설계가 조금은 난해하고 또 개발 프로세스에 있어서 가시적이지 못하기는 하지만, 실제 어떤 상속 계층도를 설계하고 평가하여 선택 하는가에 따라 전체 시스템의 작업 난이도는 물론 유지 보수에 대한 비용이 결정 된다는 점에 있어서 상속 설계는 단순한 코딩 작업보다 더 중요하다.

 

HAS-A 관계.

상속 설계에 있어 우리는 public으로 표현된 모든 상속은 IS-A 관계로 보고 계층도를 형성 하였다. 만약 클래스가 원시적인 데이터나 객체를 포함해야 한다면 HAS-A관계로 구현되어야 한다. 저번 스텝과 이번 스텝에 상속 설계에 있어서 IS-A관계에 대해서 많이 살펴 본 이유는 IS-A 관계가 HAS-A 관계보다 뛰어나서가 아니다. 상속 관계(IS-A)는 포함 관계보다 오류가 더욱 많고, 시스템을 해칠 가능성이 더 크고 까다롭기 때문이다. 다시 말해 그만큼 HAS-A관계는 좀 더 문제가 적기 때문에 편하게 사용 할 수 있다. 다만 HAS-A관계에서 주의 해야 할 점은 private을 상속을 통하여 포함 관계를 구현 하는 경우다. Meyers Sutter에 의해 제시된 private 상속을 통한 포함 관계가 사용되는 이유는 포함 되는 클래스의 protected 멤버에 접근 할 수 있기 때문엔데 이러한 접근 방법은 부모 클래스에 지나치게 밀접한 관계를 만들고 객체 지향적 언어에서 정보 은닉의 주제를 위반하는 결과를 초래 한다. 물론 대가들의 제시한 방법들이 큰 도움이 되기도 하지만 필자와 같은 소인배라면 가급적 private을 통한 HAS-A관계는 포함에 있어서 마지노선에 남겨 두는 것이 올바르다.

 

기타 주제

설계를 함에 있어서 간혹 우리는 많은 양의 데이터 멤버, 즉 속성들을 포함하는 클래스를 설계하기도 한다. 포함, 상속등의 레벨에 있어서 김태균 교수는 가급적 7이라는 숫자를 넘어 가지 안도록 권유 하고 있는데 이는 실지로 1995 Miller에 의해서도 똑같이 강조 되기도 했다. Miller는 개인이 여러 작업을 수행 하면서도 기억 할 수 있는 개별적인 항목의 수가 7+-2라고 이야기 했다. 이 뿐만 아니라 Arthur Riel Object-Oriented Design Heuristic이라는 그의 저서에서 7이라는 숫자를 넘기지 않도록 권유 하고 있다.(이는 arm과 같은 load / store 아키텍쳐에서 효율성을 위해 레지스터 개수 만큼 지역 변수와 파라미터 변수의 개수를 제한 하는 것과는 다르게 어떠한 지표에 의한 권유라기 보다는 일반적인 개발 습성에 관계가 깊다) 요즘은 여러 가지 코딩 보조 어플리케이션(Visual assist와 같은)이 보편화 되어 있는 시점에서 숫자에 기인하여 클래스를 분리 한다는 것은 좀 어색할 수 있으나, 멤버와 메서드가 한눈에 들어오지 못하도록 한 종합 선물 세트와 같은 클래스는 분리되어야 하는지를 고려 해야 한다.

 

 

 

참조 :

배움터, 김태균저, K교수의 객체지향 이야기,

Code Complete, Steve McConnell

'Drafts > C++' 카테고리의 다른 글

[STEP 9] 상속구현 #2, More inheritance  (0) 2008/03/01
[STEP 8] Interitant impelemetation  (0) 2008/02/06
[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06
[STEP 6] Inheritance design 1 #3  (0) 2007/12/23

무엇이든 다룰 수 있어야 하는 한국인 프로그래머와 객체지향

 

Korean Programmer

Inheritance design #2

 

C.A.R Hoare소프트웨어의 설계와 구현에 대하여 두 가지 방법이 있다고 이야기 하였다. 한 가지의 방법은 누가 봐도 문제가 없을 정도로 간단하게 만드는 것이고, 또 다른 하나의 방법은 어느 누구도 문제를 찾을 수 없을 정도로 복잡하게 만드는 것이다. 이 두 방법에는 반드시 필요한 하나의 묵시적 가정이 존재 하는데, 그것은 두 가지 방법 모두 소프트웨어로서 기능적으로 문제가 없이 잘 동작 해야 한다는 것이다. 이 전제는 프로그램의 정의 중 하나이기도 하기 때문에 시간의 흐름에 따라 그 전제가 변하지 않는다. 결국 시간의 흐름에 따라 요구사항은 변경되고 이에 따라 Hoare의 이야기에 나오는 전자와 같이 너무나 간단하게 만들어진 소프트웨어는 추가적 기능을 수용 하기 위해서 다시 복잡 해질 수 밖에 없고, 반대 경우인 극도로 복잡하게 만들어진 소프트웨어도 새로운 추가 기능을 수용할 때 변경되는 여러 가지 코드 복잡도 때문에 간결화 과정을 거칠 수 밖에 없다. 복잡성이 소프트웨어 개발에서 가장 기술적인 주제임은 틀림이 없다면 확장성은 소프트웨어 생명에 있어서 심장과도 같다. 우리는 오늘 상속 설계를 살펴 보면서 이 두 가지 이슈에 대하여 중점적으로 알아 보고 평가 해보도록 할 것이다.

 

명수 | byeguns 앳 sw블로그.net/ http://www.swblog.net

필자는 현 삼성전자 연구원으로 재직 중이다. 컴퓨터 공학을 전공하고 Haskell Scheme등의 Functional language를 좋아하며, 학부 시절, C++과 관련하여 3개의 논문과 대회 수상을 한 경험이 있다. 올해 들어와서 Class와는 전혀 다른 작업을 통해 조금은 OOP와 거리가 멀어진 길을 걷고 있지만, 항상 언어에 대한 갈등은 지우지 못하는 이제 막 걸음마를 시작한 부족한 그릇의 개발자이다.

 

 

무엇이 설계의 핵심인가?

어떤 소프트웨어의 설계자이든 프로젝트의 시작을 요구사항 문서로부터 합리적 이고 오류를 하나도 가지고 있지 않은 방법으로 제작 해내는 것은 비현실적이다. David parnas paul clements 의하면 이제까지 이러한 방법으로 개발 시스템은 하나도 없었고, 앞으로도 그럴 것이라고 이야기 한다. 심지어 그들은 책이나 논문에 나오는 소규모의 프로그램 또한 그러한 방법으로 만들어진 프로그램은 없다라고 단언한다. 하지만 원래 설계라는 것은 엉성한 프로세스 이다. 설계를 하면서 몰랐던 것을 구현하면서 깨닫기도 하고 설계를 하면서 요구사항과는 달리 완전히 잘못된 길로 빠져 들기도 한다. 평소 보다 정성 들여 만들어 놓은 UML 구현으로 옮기면서 알게 되는 구조적으로 결함은 어떻게 보면 보편적 일수 있다. 일부 개발자는 재치 있는 언어적 기교를 사용하여 그러한 설계 결함을 감추기도 한다. 우리가 생각하는 설계는 이러한 재치 있는 언어적 기교를 피하고 가급적 누가 봐도 쉽게 작성 있도록 하는 것이 목적일 것이다. 간혹 어떤 개발자는 재치 있는 기교를 프로그래머의 기본 소양으로 생각 하는 경향이 있는데 이러한 기교를 통해 설계의 결함을 감추는 것보다는 정의된 설계를 바탕으로 누구나 이해 있는 심플한 코드를 작성 하는 것이 개발자에 있어서의 미덕이자 기본 소양이다.

그렇다면 결함을 가지는 설계를 하는 것이 잘못 것인가? 설계에 있어서 핵심은 무엇 인가. 누구나 설계를 하면서 실수를 있다. Parnas Clements 주장 하는 바대로 세상 어떤 뛰어난 설계자도 요구사항의 모든 것을 한번에 꾀어 차고 한방에 깨끗한 설계를 제시 수는 없다. 오히려 실제 설계상에서 또는 구현 도중에 설계의 결함을 발견하는 것이 시스템에 있어서 올바른 방향이며 설계의 핵심이라고 있다. 프로그래머의 기교에 의해서 시스템이 구현되고 나서 되돌릴 없는 버그를 발견하는 보다는 그전에 설계상에서 잘못 것들을 발견하고 변경하는 것이 프로그래머의 삶의 질을 올려 있다. 코드는 시간이 지남에 따라 얼어버리는 냉장고 얼음과 같아서 문제점을 설계상 발견하여 수정하는 것이 시스템이 개발에 착수 뒤에 문제점을 발견하고 수정하는 것에 비하여 소프트웨어 비용 측면에서 훨씬 이익이다는 것은 누구나 아는 기정 사실이다. 다시 한번 말하건대 이러한 실수나 결함을 찾고 조기에 시스템에서 제거하는 것이 소프트웨어 설계의 핵심이다.

 

설계에 정석은 존재 하는가?

 훌륭한 해결책을 가진 설계와 그렇지 못한 설계의 차이는 아주 미묘하다. 그렇기 때문에 우리는 특정 설계에 대하여 설계가 엉성하다고 단언 없다. Brooks 설계에 있어 본질적인 어려움과 비본질적인 어려움, 가지의 이질적인 문제 때문에 소프트웨어가 어렵게 만들어 진다고 주장한다. 본질적인 어려움이란 한마디로 말해 소프트웨어가 하는 일을 정의 있는 실제 속성을 뜻하는 것이고 비본질적인 어려움은 그러한 프로그램이 하는 이외에 지켜져야 하는 요구사항 언어적인 제약사항들을 이야기 한다. Brooks 이러한 비본질적인 문제가 3세대 언어를 거치면서 실제로 해결 되었다고 정의 하고 있다.

 하지만 Brooks 정의와는 다르게 비본질적 문제가 단지 언어적인 제약사항에서만 오는 것은 아니다. 필자는 본질적인 문제를 해결하기 위해 모든 제약사항은 비본질적인 문제에 귀결 있다고 생각한다. 소프트웨어 개발에 있어서 아주 특별하면서도 도저히 빠져 나갈 없는 제약사항을 가지는 문제덩어리는 예나 지금이나 디바이스이다. 요즘은 디바이스의 속도가 하루하루가 다르게 달라지고 지능적인 디바이스들이 늘어가는 시점에서 이러한 디바이스는 성능이 향상된 만큼이나 그들이 가지고 있는 제약사항의 종류가 다양해지고 복잡해 졌다. 또한 소프트웨어가 휴대성과 결합하면서 디바이스와 소프트웨어가 하나의 패키지로 보급되는 빈도가 높아짐에 따라 디바이스를 제어하는 미들웨어나 소프트웨어들의 비중이 높아졌기 때문에 디바이스에 대한 제약사항 또는 자원의 제약사항과 같은 비본질적인 문제가 결국 설계에 영향을 미치게 밖에 없다. 여러분은 WDM에서 제공하는 리스트와 DMA 조작을 위한 MDL(Memory descriptor list)등이 잘못된 설계의 부산물이라고 생각 하는가?

이러한 제약사항을 가진 설계들은 이상적인 세계에서 제작된 설계와는 분명히 다르다. 비약하자면 같은 상황에 대하여 한쪽 다리가 없는 장애인을 운용하여 어떠한 문제를 해결 해야 하는 것과 같다. 이러한 소프트웨어는 비본질적인 문제가 소프트웨어의 설계에 상당한 영향력을 가지기 때문에 풍부한 자원과 제약사항이 적은 곳에서 만들어진 소프트웨어 설계에 비하여 빈약하고 모습이 기형적인 요소를 가질 밖에 없다. 만약 특정 하나의 설계가 이상적인 설계에 비하여 복잡도 관리에서 실패하고 확장성을 가지지 못하는 설계로 제작되었다 하더라도 우리는 설계를 비웃거나 무시 없다.

이처럼 설계는 플랫폼과 디바이스에 따라 변경되고 비본질적인 요구사항에 따라 여러 가지 모습을 가질 밖에 없다.개발자는 하나의 요구사항에 대하여 디바이스에 따라 설계 방식을 완전히 다르게 있으며 반대로 같은 디바이스를 가지고 하나의 문제를 해결 하는데 있어서 설계의 방향을 완전히 다르게 잡을 있다. Steve McConnell 하나의 솔루션에 대하여 프로그램을 설계하는 방법은 일반적으로도 보통 10가지가 넘게 존재한다고 이야기 한다. 하지만 이러한 이질적 설계는 모두 솔루션에 대하여 타당한 해결책을 제시한다. 그렇다면 설계의 정석이란 어떤 것인가?

설계의 정석이란 충분히 정의된 디자인을 만들어 내는 것이다. 하지만 충분히 정의된 설계라는 요구사항에서 있듯이 문장의 한정사인 충분히 정의된이라는 단어는 정말 애매모호 있다. 우리는 하나의 솔루션에 대하여 가지의 설계를 생각 해야 할까? 시간을 다투는 프로젝트 내에서 얼마나 형식적이고 시간 소비적인 설계 표기법을 사용 것인가? 정확히 정의되지 않는 설계라는 이슈에 대하여 충분히 설계한다는 정석이 정말 비현실 적이기 까지 하다.

하지만 우리는 쉬지 않고 똑딱대는 프로젝트 일정 시계의 초침에도 굴하지 않고도 매일매일 작은 기적을 만드는 프로그래머이고, 기예가이기 때문에 계속 기적을 만드는 일을 행하여야 한다. 이러한 설계와 구현의 반복적인 작업을 통해 충분히 정의된 설계를 하는 것은 Steve McConnell 말처럼   이상 시간이 없을 때까지반복 되어야 한다. 우리는 개의 클래스 계층도의 후보들을 작성하고 이를 평가하여 제작하는 방법을 취할 것이다.

 앞서 스텝에서 우리는 간단하게 하나의 추상적인 시스템에 대하여 필요한 클래스와 서비스를 클러스터링하고 3가지의 다이어그램을 생성 하였다. 다이어그램의 평가를 들어가기 전에 스텝에서 정의된 다이어그램을 이번 스텝에서 반복하여 소개 수는 없으므로 다이어그램의 섬네일과 명칭을 정하여 평가에 활용 하도록 하자.

 

사용자 삽입 이미지

 <기계식>

                                   

사용자 삽입 이미지

<나열식> 

                                                           
 

사용자 삽입 이미지


<혼합식>

 

다이어그램에 대한 명칭은 읽는 사람의 혼돈을 막기 위해 기술 것이기 때문에 특별한 의미는 없다. 이전에도 이야기 했듯이 3가지 모델들은 모두 시스템을 문제 없이 동작하는데 쓰일 있다.

 

상속의 자연스러움에 대한 평가.

 확장성에 대한 평가는 저번 스텝에서 어느 정도 이야기가 되었기 때문에 이번 스텝에서는 특별히 다룰 주제가 없다. 생성된 3가지의 모든 다이어그램은 IS-A관계에 있어서 문맥적으로 어색하지 않다. 이는 특별한 부가 설명 없이도 상속 트리 구조가 매우 간단하기 때문에 쉽게 평가 있을 것이다. 자신이 추상화한 시스템의 설계들이 IS-A HAS-A 관계에 있어서 모두 동일한 점수를 획득하여 디테일한 평가가 필요하다면 LSP(Liskov Substitution Principle) 대한 원칙을 적용 해볼 있다. LSP OOP Seminal paper 주장 하나로 오랫동안 상속을 구현하는 자들에게 등대가 되었다. Liskov 그의 논문에서 파생클래스가 특수화 과정을 위하여 기본 클래스로부터 상속을 받지 않는 경우라면 상속을 피해야 한다고 주장했다. LSP 2000년에 들어서 Andy Hunt Dave Thomas 의하여 다시 아래와 같은 내용으로 재해석 되었다.

 

사용자는 서브클래스를 사용함에 있어서 기본 클래스의 인터페이스를 통하여 둘의 차이점을 인지 하지 않고도 사용 있어야 한다

 

이를 굳이 필자가 다시 정리해보자면 트리 내에 클래스 계층도를 형성하고 있는 클래스들의 메서드는 모두 동일한 의미의 일관성을 가져야 한다는 것이다. 만약 상속을 사용하여 작성된 프로그램이 중복성을 굳이 고려 하지 않았다고 가정하더라도 LSP 의해 상속 설계가 투명하게 제작 되었다면 프로그래머들이 메서들에 대한 세부 사항들에 대해서 걱정 하지 않고 객체 자체의 일반적인 특성들을 구현하는데 중점을 두었을 것이기 때문에 상속의 복잡성이 어느 정도 제거 되었다고 있다.

 혼합식 다이어그램을 통해서 LSP 예를 간단하게 살펴 보자. 혼합식 다이어그램으로부터 파생되는 클래스다이어그램을 참조하면(그림 5) 개발자와 관리자의 클래스를 Employee라는 중간의 기본 클래스로부터 상속을 받아 구현하고 있다. 여기에는 GetPay라는 것이 가상함수로써 선언되어 있는데 있다. 이는 현재 인스턴스가 자신의 월급을 얼마나 되는지에 대한 값을 return 한다고 가정해보자. 월급을 리턴 하는 Employee 클래스를 상속 받은 개발자와 관리자는 가상함수에 대하여 오버로딩 하여 구현 있는데 관리자는 개발자와 달리 쓰임새가 달라서 월급을 리턴 하는 것이 아니라 연봉을 리턴 한다고 생각 해보자. 이러한 경우 Employee 통하여 상속을 받아 구현 하였음에도 불구하고 메서드의 의미 자체가 달라지게 되어 LSP 위반 하게 된다.

 

//

//            DeveloperPlannerbase가되는기본클래스

//            임직원에대한메서드를관리한다.

//

class Employee {

private :

              UINT32 nPay;

 

              // ., 기타UML에서정의된속성들

 

public :

              virtual UINT32 GetPay()                    { return nPay; };             // 임직원은모두봉급에관한속성을취할수있다.

 

};

 

class Planner : public Employee  {

public :

              virtual UINT32 GetPay()                    { return nPay * MONTH_PER_PAY; };                 // LSP 위반

};

<코드 1> LSP 위반하는 구현

 

 

 

 

이렇게 Employee 상속을 받은 개발자와 기획자 클래스의 GetPay루틴은 동일한 의미를 가지지 못하기 때문에 Employee로부터 상속을 꾀하지 말라는 것이다. 상속을 사용하고 싶다면 완전히 다름 이름을 가지는 메서드를 오버라이드하여 구현 하는 것이 좀더 바람직하다. GetPay 통한 취득보다는 연봉에 대한 다른 루틴, GetAnnualPay() { return GetPay() * MONTH_PER_YEAR; }; 같은 구현을 통하여 사용 하는 것처럼 말이다.(물론 이러한 방법은 인터페이스의 의미를 변경하고 자손 클래스로의 확장에 있어서 유지보수를 힘들게 하고, 객체 속성에 충실하지 못하는 클래스를 만든 다는 이유 때문에 논쟁 거리를 앉고 있다.)

 눈치가 빠른 독자는 벌써 생각 하였겠지만, 설계 평가에 있어서 이러한 LSP 원칙은 순수 상속 설계보다는 구현에 가까운 생각들을 많이 하게 하므로 우리는 단지 이름을 가진 클래스의 추상화 수준에서 IS-A 관계를 파악하여 평가를 하되, 다이어그램의 상속성의 자연스러움에 대한 가중치를 주기 위한 수단으로 LSP 같은 원칙을 적용하는 하면 것이다.

'Drafts > C++' 카테고리의 다른 글

[STEP 8] Interitant impelemetation  (0) 2008/02/06
[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06
[STEP 6] Inheritance design 1 #3  (0) 2007/12/23
[STEP 6] Inheritance design 1 #2  (0) 2007/12/23

각 클래스의 속성과 유사점 인출

우리는 상위에서 서비스들을 정의 하고 객체들을 정의 했다. 당연히 다음으로 일은 클래스의 속성들을 정의 하는 것이다. 속성들은 서비스를 구현 하면서 또는 코드를 작성하면서 우리가 모르고 있던 많은 데이터 멤버들을 정의 있게 된다. 필자는 속성(Attribute)라는 이름과 데이터 멤버라는 것을 구분하는데 속성은 실제 명세로부터 생성되고 클래스의 고유한 특성을 대변하는 데이터 멤버를 말한다. Client 경우 우리와 비즈니스를 하기 위해 필요한 자본을 가지고 있어야 것이고 고객의 경험과 마인드 등이 속성으로 분류 있다. 이러한 데이터 멤버들은 실제 클래스의 본질적인 것으로 속성(Attribute)으로 부르고 이에 반해, 검색 또는 특정 정책이 반영된 알고리즘을 통해서 Client VIP인지를 찾는 것에 필요한 멤버 변수는 앞에서 말한 속성과는 본질적으로 다르기 때문에 데이터 멤버로 호칭한다. 이러한 데이터 멤버들을 알고리즘 표현뿐만 아니라 메서들간의 정보전달, 또는 메서드의 중간 결과를 잠시 보관 하기 위해서도 사용 있다. 속성(Attribute)들은 관례적으로 Get이나 Set이라는 이름의 메서드로 외부 클래스나 서비스로 Expose시키게 되는데 이것은 번째 스텝을 기고할 이야기 하였으니 참조 바란다. 요즘의 추세로는 Get이나 Set이라는 접두사를 사용하지 않고 실제 멤버들의 이름을 그대로 따서 좀더 보기 편리 하게 하고 있는데 예를 들면 GetPay 경우 Get 제외 하고 int Pay() void Pay(int money) 같이 있다. 이런 명명 법칙이 편리한 것은 속성의 정의 이름과 일치 하기 때문에 직관적인 메서드들로 구성이 가능하고 이에 따라 가독성이 늘어난다는 것이다.  Get 리턴 타입이 반드시 필요 것이고 Set 경우 반드시 속성에 대한 정보를 인자로 받을 것이니 이를 제외하고 같은 이름을 두고 인자를 통해서 오버로딩이 가능하다.(TOMATO사의 최근 버전의 Visual assist refactoring 기능에서 후자의 생략 방식대로 속성들을 expose하고 있다)

다시 본론으로 돌아가서 이러한 속성들을 정의 하고 나면 속성 중에서 공통적인 점을 찾아야 한다. 공통적인 특징으로 멤버들을 묶음으로서 우리는 상속 관계의 가장 기본적인 틀을 만들 있다. 눈에 보일 말듯한 존재의 공통점을 인출하는 것은 생각보다 쉽지 않은 작업이다. 직관적으로 각각 공통되는 속성을 묶고 유사한 클래스들을 묶어 보는데 다이어그램이 유용 있다. 이러한 다이어그램에는 클래스 내의 멤버를 먼저 생각하여 멤버들간의 포함관계를 고려하여 분류하여야 하나 그림에는 너무 복잡해지는 것을 방지 하기 위해 클래스 이름들만 나열 하였다. 아래의 그림은 정말 유사점을 가지는 것들을 하나하나 묶어서 기계적으로 분류 것이다.


사용자 삽입 이미지

<그림 4>  포함관계를 기계적으로 분류한 다이어그램

 

 

다이어그램에서 Planner, Manager, Developer, Client, Supporter들은 공통점으로 주민등록 번호와 이름, 성별이라는 속성을 가지고 있고 속성은 People 클래스에 포함되어 있기 때문에 People 다른 클래스의 Super set으로 그렸다. 그리고 단말 노드가 서로 중복 되지 않는 부분이 있는데 이는 고객인가 또는 임직원인가에 따른 속성이 차이로, 차이는 Client Employee 같은 중간 단계의 클래스를 삽입하여 분류를 명확히 하였다. Supporter 부분 속성은 Client 모두 있으므로 Client Supporter Super set으로 정의 하였다. Planner, Manager 그리고 Developer클래스들은 Employee라는 중간 클래스를 하나 두어 임직원과 고객의 속성 차이를 확실히 하고 이러한 클러스터링은 차후 클래스를 통하여 진화된 클래스들을 정의 있도록 여운을 남겨 놓는 것이다. 예를 들어 Client 다른 고객군이 생성될 확장 있을 것이고, 마찬가지로 Employee 클래스는 외부 직원 새로 생길 직군 까지 커버 있을 것이다. 확장성도 어느 정도 고려 되었기 때문에 기계적인 분류에 의해 생성된 클래스 다이어그램은 어느 정도 디자인 이슈를 만족 한다고 있다. 이러한 기계적 분류는 시스템 설계에서 좋은 것으로 분류 수도 있고 최악의 계층도가 수도 있다. 위에서 제시된 다이어그램 외에도 아래와 같은 다이어그램을 생각 있다.

 


사용자 삽입 이미지
사용자 삽입 이미지

<그림 5 > 같은 시스템에 대한 다른 다이어그램

 

 

 이러한 다이어그램은 필자에 의해서 3가지로 그려진 것이지 실제로는 더욱 많은 다이어그램이 있을 있다. 다시 말해 같은 시스템을 구현 함에 있어서 수많은 클래스 계층 트리를 구현 있는데 우리가 간과하지 말아야 것은 이중 어느 다이어그램을 사용하더라도 정상적으로 동작하는 시스템을 구현 있다는 것이다. 그래서 우리는 각기 제작이 가능한 다이어그램을 가지고 시스템에서 미칠 영향력을 평가해야 한다. 아울러 평가에 있어 이러한 분류들의 문제점과 장점에 대해서도 도출 하여 가장 적절한 클래스 계층도를 유도해내고 구현 해야 것이다.

우리는 세가지 모델을 가지고 각기 시스템에서 어떤 것이 좋을 것인지 평가 해보도록 하자.

 

클래스의 설계 때 고려 되어야 하는 이슈

우리는 3가지 케이스를 가지고 이 클래스 계층 도를 평가 했는데 이는 우리가 클래스 계층 도를 설계할 때 지켜야 할 3가지 이다. 앞서 말한 3가지 케이스는 아래와 같이 요약 할 수 있다.

 

1.        상속 관계는 자연스러워야 한다.

2.        속성과 메서드는 중복 되지 않는 것이 좋다.

3.        확장성을 고려 해야 한다.

 

1 상속 관계가 자연스러운지에 대한 평가는 어떠한 평가보다 먼저 이루어져야 한다. 이는 시스템이 어떤 확장성을 가질 있는 가에 대해서도 어느 정도 영향력을 과시한다. 번째 다이어그램으로부터 우리는 아래와 같은 클래스 다이어그램을 생성 있다. 번째 다이어그램은 클래스간의 상속관계가 명확하다. 레벨에서 임직원과 고객은 사람이다 라는 조건을 만족 하고 각기 개발자와 관리자는 임직원이며, 후원자는 고객이다라는 조건을 만족 하기 때문에 클래스 다이어그램만큼 상속 관계가 명확한 것은 없다. 여기서 Poeple이라는 클래스는 추상 기본 클래스(ABC : Abstraction Base Class) 다형성의 사용시 모든 클래스의 인터페이스가 있기 때문에 자료 구조 운영에서도 적당한 계층도를 가지고 있다.

 



사용자 삽입 이미지

<그림 5>  그림 4 대한 클래스 다이어그램

 

 

대부분의 개발자가 생각 하게 되는 클래스 다이어그램은 위와 같은 형태를 띄게 것이다. 하지만 클래스 다이어그램은 차후 코드가 반복적으로 사용되는 중복된 클래스 계층도(2번째 다이어그램에서 파생된) 보다 못한 평가를 받게 것이다. 이것에 대한 이유는 다음 스텝에서 확인 하도록 하자. 아울러 다음 스텝에서는 코드 반복성에 대한 평가와 확장성에 대하여 다른 다이어그램을 모두 같이 평가해보고 어떠한 클래스 계층도가 이중에서 좋은 것인지 생각 해보자.

 

 오늘은 이제까지의 스텝과는 조금 다르게 코드에 대한 언급이 없이 단지 상속 설계에 대한 이야기만을 다루었다. 상속에 대한 이슈는 개발자 마다 중요시 하는 관점이 다르고 또 자신의 가치관에 따라 다른 계층도를 생성 할 수 있다. 또한 코드나 언어에 대한 테크니컬 스킬보다는 시스템 전체를 디자인하는 작업이기 때문에 상속에 대하여 이야기 할 때, 잘못하면 너무 높은 추상화 수준에서 기술 되기 싶고, 그렇게 되면 오랫동안 관련 정보를 읽고 또 생각 해도 이것이 시스템 속에서 녹아 들게 될지에 대한 불안 감이 생기기 마련이다. 이러한 이유에서 상속 관계를 정의 하고 시스템을 형성 할 때는 가급적 UML을 통하여 코딩이 이루어지기 전에 정확히 설계되는 것이 올바르다. 그렇다고 UML로만 설계 한다고 해서 UML이 올바른 클래스 계층도를 형성 해주는 것은 아니다. UML은 클래스간의 관계를 가시적으로 변환 하여 주기 때문에 개발자가 클래스 설계에 있어 실수 할 수 있는 많은 것들을 사전에 막아주긴 하지만 가장 중요한 것은 개발자의 소프트웨어적인 마인드다. UML을 사용 하던, 그냥 종이에 끄적거려 디자인을 하던 우리가 반드시 기억 해야 할 것은 단지 클래스간의 재사용성만을 가지고 계층도를 형성 하여서도 안되고, 그렇다고 해서 확장성만을 가지고 계층도를 형성해도 된다는 사실이다. 또한 설계자는 어떤 언어적 테크닉이나 기교를 통해서 이쁜 코드를 만드는 것보다 오늘부터 다음 스텝까지 언급될 내용을 하나씩 평가해보고 프로젝트의 생명 주기를 길게 해주는 클래스 설계를 하는 것이 올바를 것이다.

 

 

참조 :

배움터, 김태균저, K교수의 객체지향 이야기

'Drafts > C++' 카테고리의 다른 글

[STEP 7] Inheritance design 2 #2  (0) 2008/02/06
[STEP 7] Inheriance design 2 #1  (0) 2008/02/06
[STEP 6] Inheritance design 1 #4  (0) 2008/02/06
[STEP 6] Inheritance design 1 #3  (0) 2007/12/23
[STEP 6] Inheritance design 1 #2  (0) 2007/12/23
[STEP 6] Inheritance design 1 #1  (0) 2007/12/23