객체지향 이야기 – 3.상속 구현(1)

대한민국 개발자와 객체지향 이야기 – 3.상속 구현(1)

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

정명수 ㅣmsguns.jung@samsung.com ㅣ 삼성전자 메모리사업부 플래시 소프트웨어 개발 관련 연구원

폴리모피즘의 배정 문제

Partial Assignment 및 Mixed-type Assignment 문제

지난 호까지 제시했던 최상위 부모 클래스인 People 클래스로부터 상속받은 Client와 Employee 클래스 핸들링에 있어서 재미나는 주제를 짚어보자. Client와 Employee 클래스는 People과 다른 역할을 하므로 유독 두 클래스에 대해 특별한 처리가 요구된다. People은 사람이라는 특징을 실체화한 것이고 Client와 Employee는 각각 고객이 갖는 특징과 임직원이 갖는 특징을 세분화했다.

<리스트 1>의 클래스 선언에서는 전반적인 내용은 제외하고 대입 연산자에 대한 오버로딩 부분만 살펴보겠다. 상위 코드의 바디는 각각 자신만의 대입 연산자를 갖고서 특정한 동작을 수행한다. Employee와 Client는 동일한 계층도 안에 존재하므로 <리스트 2>와 같은 코드의 작성 및 사용이 가능하다.

<리스트 2>에 나온 코드의 문제점은 기초 클래스 타입을 가진 포인터를 사용해 폴리모피즘 입장에서 각 인스턴스를 다룸에도 불구하고 각 대입 연산자에 대한 메소드는 정적 바인딩이 되도록 제작되어 있다. 즉 Employee를 People에게 대입했을 때는 정적 바인딩 된 대입 연산자에 의해 Partial Assignment 현상이 생긴다. man1과 man2는 People의 인스턴스가 아니라 Employee의 인스턴스임에도 불구하고 마지막 코드에서 대입연산은 People의 operator =를 사용한다. 앞에서 설명했듯이 동적 바인딩을 고려한 메소드가 없기 때문에 man1, 2의 클래스인 People의 op erator =가 호출된다. 이런 경우 Employee의 클래스가 갖고 있는 값 중 People에 존재하는 멤버만 가져오므로 개발자가 의도한 이전의 emp2의 속성을 emp1으로 옮기지는 못한다.

이러한 포인터에 의한 대입연산은 C 개발자에서 C++ 개발자로 전향할 경우 빈번히 발생한다. C++의 메소드 바인딩 시점을 제대로 이해하지 못하고 단지 메모리 복사만으로 해결된다고 생각하기 때문이다. 이런 문제점을 해결하려면 각 대입 연산자를 가상함수로 만들어야 한다. 사실 폴리모피즘을 고려했다면 상위 클래스 선언에서 당연히 가상함수가 됐어야 했다. man1과 man2를 대입할 때 동적 바인딩에 의해 Employee의 오버로딩 된 대입 연산자가 호출된다. 하지만 왜 예제는 상위코드에서 가상함수로 대입 연산자를 선언하지 않았을까?

C++ 표준화 정책에 의해 대입 연산자 반환 값의 타입은 파생 클래스의 타입을 레퍼런스로 돌려줄 수 있지만 가상함수의 파라미터가 되는 인스턴스 타입만은 기본 클래스의 타입을 그대로 써야한다. Partial Assignment의 문제를 각 클래스의 가상함수의 역할로 떠넘기려 했지만 가상함수 정의에 의해 또 다른 문제인 Mixed-type assignment 문제가 발생한다. 파라미터가 기본 클래스의 타입으로만 정의 된다는 말은 대입 연산자의 좌측 인스턴스가 무엇이든 상관없이 Employee나 Client클래스에 대입 가능해진다는 의미이다. 왜 가상함수의 파라미터가 되는 인스턴스의 타입이 그대로 사용되는지 이해가 안 되는 독자는 마소 7월호(C++ 컴파일러의 은닉처리)를 참조하길 바란다. 본론으로 돌아가서 Mixed-type assignment 문제가 발생하는 실제 예제를 살펴보자. People로부터 상속받은 서로 다른 클래스의 인스턴스를 People을 이용해 복사를 시도해 보자.

처음 제시됐던 정적 바인딩을 가진 클래스의 코드나 일반적인 배정의 경우, mixed-type assignment 문제가 발생하지 않는다. C++는 C와 다르게 강 타입체크(Strong type check)를 하기 때문에 컴파일 단계에서 미리 개발자에게 잘못 된 부분을 알려준다. 만약 <리스트 4>와 같이 가상함수로 선언하면 파라미터의 제약 사항에 따라 컴파일러의 강 타입 체크가 무용지물이 된다.

현재 Mixed-type assignment 문제의 경우 언어가 처리할 수 있는 방법은 없다. 하지만 C++는 언어가 지원하지 않는다고 해서 개발자가 직접 구현하지 못하게 만들어진 우매한 언어는 아니다(물론 일부분에 대해). 처음 설계 시에 삽입됐던 Managed Object의 이슈를 제외하고 일단 현재 존재하는 People , Client, Employee 클래스 안에서 해결해 보자. 기본 클래스인 People의 포인터로 Client와 Employee를 삽입한다는 것은 컴파일러 입장에서는 타입체크를 애매하게 만든다. People의 포인터에 대한 타입은 컴파일 타입이 아니라 런타임 시에 결정되기 때문이다. 이러한 문제를 극복하기 위해 각 대입 연산자 안에서 dynamic_ cast를 이용해 컴파일러에게 타입체크를 강제로 인식 시킨다.

좀 더 강한 캐스팅 정책을 갖고 있는 dynamic_cast의 키워드에 의해 실제로 Employee의 타입이 아닌 경우라면 컴파일러는 std::bad_cast를 반환한다. 이런 경우 예상했던 대로 Employee가 아닌 다른 클래스의 인스턴스가 Employee로 변형되면서 Partial assignment나 mixed-type assignment가 발생하지 않는다. 하지만 퍼포먼스 측면에서 쓸모없는 오버헤드가 발생한다. 즉 Employee의 인스턴스 간에 빈번히 대입연산이 일어나므로 대입 연산자를 구현했다. 실제 Employee간의 대입연산이라도 내부에서 매번 동적 캐스팅을 통해 쓸모없는 지역 변수를 소비하며 메모리 복사한다. 이런 쓸모없는 오버헤드는 줄여야 한다.

이러한 Employee의 대입 정책은 의외로 효과적이다. Empl oyee에 추가된 연산자 오버로딩 메소드에 의해 People에 대한 대입 연산자 정의가 가능하다. <리스트 6>에서 문제없이 동작하기 때문이다(마지막에 operator =의 오버로딩 구현이 <리스트 5>에서 제시된 것과 어떤 측면에서 최적화가 이뤄진 것인지 이해가 안 된다면 2006년 12월에 실린 ‘C++ 최적화(특집 2부)’를 참조하길 바란다).

오버라이딩 된 동작에 의하면 파라미터로 넘어오는 rhs 인자에 대해 먼저 dynamic_cast로 Employee 타입으로 변환시켜 현재 넘어온 인자가 Employee의 클래스 타입인지를 체크하고 Employee가 갖고 있는 오버로딩 된 대입 연산자를 호출한다. 여기까지 코드를 살펴보면 원하는 일을 수행하는데 큰 문제가 없다. 하지만 객체지향 패러다임의 핵심 중 하나인 가상함수를 통해 메소드를 구하는 일이 이렇게 복잡하다는 것은 ‘뭔가 잘못 된 것은 아닐까?’라는 의구심이 든다.

Employee 클래스를 사용하는 개발자는 대입 연산자를 사용할 때 마다 bad_cast를 대비해야 한다. 대입이 일어날 때 마다 예외 처리를 위해 catch 문을 붙여야 하므로 원하는 것을 올바르게 수행하지만 직관적이지 못하다. 더욱이 아직까지 dynamic_cast를 지원하지 않는 컴파일러도 많기 때문에 호환성 문제도 뒤쳐진다. Dynamic_cast를 매크로로 정의(define)해서 각 플랫폼 마다 호환성을 갖게 할 수 없냐는 질문도 나올 수 있다. 매크로를 써서 링크까지 성공할 수도 있지만, 매크로로 define된 캐스팅의 실체는 dynamic_cast가 아니므로 Partial assign을 근본적으로 막을 수는 없다. 구현은 가능하지만 이렇게 비직관적인 코드로 Partial assign을 막아야 할 만큼 C++는 까다롭지 않다. 객체지향 패러다임에서는 하나의 기능을 사용하기 위해 여러 가지 방법을 통해 구현이 가능하다. 이제 virtual 쪽으로의 구현하지 말고 다른 쪽으로 접근해 보자.

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

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

<리스트 9>의 주석처럼 Protected로 선언해 각 대입연산을 오버라이드 한다고 가정하고 문제를 해결해 보자.

이질적인 클래스 대입연산에 대해 Protected로 선언된 대입 연산자를 호출하기 때문에 컴파일 즉시 에러가 발생한다. 이는 virtual의 대입 연산자를 단순히 Protected로 변경해 이질적인 것과 동질적인 것에 대한 대입연산에서 오는 문제점을 막을 수 있다. 문제는 People 간의 대입연산이다. 상위 코드처럼 문제를 깨끗하게 해결하고도 실제로 People 간의 대입연산이 일어나면 Protected로 선언된 대입연산 때문에 즉시 컴파일 에러가 발생한다. 그렇다면 Protected로 선언된 People 클래스를 아예 인스턴스화 못하게 하면 어떻게 될까?

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

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

이렇게 순수 가상함수에 의해 추상 클래스가 인터페이스로 잡히면 예측한 것 같이 연산끼리의 대입과 배정이 가능해지고 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-classs hierarchy 관계

<그림 2>와 같이 상속관계를 변경했을 때 얻는 또 다른 이점은 무엇일까? UML을 보면 알 수 있듯이 내부적으로 특정 멤버와 메소드를 포함하는지 표기되지 않은 상태여도 A와 B의 공통점을 I를 통해 확실히 추상화 한다. A로부터 B를 상속 받았기 때문에 분명 둘의 관계에는 공통점이 존재하지만, 사용자는 어떤 공통점(코드를 확인하지 않는 이상 알 수 없다)이 존재하는지 모르는 상황에서 두 클래스를 사용한다. 한글에서 쓰이는 ‘거시기’라는 대명사처럼 무엇인지 말할 수는 없지만 분명 둘 사이에는 공통점이 존재하지만 뚜렷하게 그 실체가 드러나지 않는다. 하지만 추상 클래스 I를 삽입함으로써 이를 사용자에게 명확히 인지시킨다.

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

지금까지 누구나 중요하게 생각하는 추상 클래스를 어떻게 실제 구현에 결합시킬 수 있는지 알아봤다. 하지만 추상 클래스의 적용이 모든 해결점을 제시하지는 못한다. 바라보는 관점에 따라 조금씩 다르긴 해도 일반적으로 클래스로 만들어질 모든 대상은 일종의 추상 타입이라고 볼 수 있다. 그렇다면 모든 클래스의 계층도를 변경해야 할까? 예를 들어 하나의 클래스를 추상 클래스와 구체 클래스로, 두 개의 계층(hierarchy)을 가지는 클래스 계층도로 변경하는가에 대한 문제이다. 이에 대해 Scott mayors는 단호하게 ‘No’라고 답한다. 클래스 개수가 늘어나는 것은 그리 좋은 방법은 아니다. 클래스가 특별한 목적 없이 개수만 늘어나면 계층도를 이해하기 어렵고 유지보수도 힘들어진다.

그렇다면 어디까지 추상 클래스에 의한 계층도를 가져야 할까. 필자는 개인적으로 인터페이스를 통한 계층도 구현이 매우 중요하다고 생각한다. 자바의 경우 인터페이스를 반영하는 구체적인 방법을 갖고 있지만 C++의 경우 인터페이스 없이도 구현상에 특별한 문제가 없도록 언어가 구성되었다. 대부분의 C++ 개발자는 추상 클래스를 무시하고 코딩하지만 특별한 문제를 느끼지 못한다. 그래서 더욱 더 추상 클래스의 사용을 습관화하는 것이 좋다고 생각한다. 적절한 사용이 가장 이상적이지만 OOP언어를 사용하면서 객체지향 패러다임에 근접하도록 언어를 사용하는 것도 그 이상으로 중요하다.

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

제공 : DB포탈사이트 DBguide.net
출처 : 마이크로소프트웨어 2007년 10월

답글 남기기