대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 C++ 프로그래밍 : 가상 상속의 득과 실
등록일 조회수 7087
첨부파일  

C++ 프로그래밍

가상 상속의 득과 실



C++와 자바(Java)를 많이 비교하는데 그 중에서 제일 빈번하게 비교되는 개념이 바로 ‘다중 상속’이다. C++에서는 존재하는 다중 상속이 명목상으로는 자바에서 지원되지 않기 때문이다. 여기서 명목상이라고 한 것은 비록 자바가 다중 상속을 지원하지는 않지만 인터페이스 상속을 통해서 다중 상속의 이점을 그대로 이용하고 있기 때문이다.



자바가 인터페이스 상속을 통해서 피할 수 있었던 다중 상속의 단점 혹은 문제점은 무엇일까? 여러 가지가 있겠지만, 대표적으로 중복된 멤버 문제가 가장 크다고 할 수 있다. 보통 수많은 서적을 통해서 접할 수 있는 ‘다이아몬드’를 떠올리면 된다.



1. 다중 상속의 문제점(Diamond 구조)

<리스트 1> Diamond 예제 class CTop { public: int m_Top; }; class CMiddleA : public CTop { public: int m_MiddleA; }; class CMiddleB : public CTop { public: int m_MiddleB; }; class CBottom : public CMiddleA, public CMiddleB { public: CBottom() { m_Top = 10; // ① Error m_MiddleA = 21; m_MiddleB = 22; m_Bottom = 30; } int m_Bottom; };



<리스트 1>을 컴파일하면 ①에서 액세스가 모호하다는 에러가 발생한다. 왜 이런 모호성 에러가 나는지 충분히 이해하고 있는 독자도 있겠지만, 잘 모를 수도 있는 독자를 위해 메모리 구조를 살펴보면서 왜 에러가 발생하게 되었는지 살펴보자.



그림에서 보았듯이 상속 구조가 다이아몬드 모양임을 알 수 있다. 다이아몬드 구조에서 발생할 수 있는 문제는 최상위 부모인 CTop이 중간 부모 CMiddleA와 CMiddleB에게 상속되고, 중간 부모 CMiddleA와 CMiddleB는 모두 CBottom에 의해서 상속되면서 CTop의 모든 멤버가 CBottom에 두 번씩 중복으로 상속되는 데 있다. 그림처럼 CTop의 멤버 m_Top은 CBottom에서 두 개가 존재하게 된다. 따라서 컴파일러는 예제의 ①과 같이 m_Top을 사용하고자 할 경우 둘 중에 어떤 것을 사용해야 할 지 모호하다는 에러를 발생시키게 된다. 그러나 이런 모호성 문제가 프로그램을 제작하는데 있어서 치명적인 문제라고는 할 수 없다. 컴파일 당시 모호성이 발견되면 모호성을 해결해주기만 하면 되기 때문이다. 제일 간단하게 해결하는 방법은 무엇일까? 기준을 하나 정하면 되는 것이다. 가령 m_Top를 사용할 경우 첫 번째 것만 사용하겠다고 지정해준다고 해보자. 즉, ①부분을 아래처럼 고치기만 하면 된다.

CMiddleA::m_Top = 10; // ①

달라진 점은 변수 앞에 범위연산자가 붙었다는 것뿐이다. 즉, CMiddleA:: 를 앞에 사용함으로써 컴파일러에게 명확하게 해당 변수가 CMiddleA에서 온 것임을 밝히는 것이다. 이렇게 범위연산자를 사용하게 되면 마찬가지로 CMiddleB에서 온 두 번째 m_Top도 사용할 수 있다. 결국 CBottom에서 범위연산자만 사용하면 두 개의 변수를 개별적으로 사용할 수 있는 이점까지 얻을 수 있다.

그러나 현실에서 다이아몬드 구조는 비효율의 극치를 보여주는 예일 뿐이다. 대부분의 개발자는 오직 하나의 m_Top만을 필요로 하기 때문에 사실상 두 번째 m_Top은 메모리 낭비만 일으키게 된다. 그래서 다중 상속의 다이아몬드 구조에 의한 메모리 낭비를 줄이기 위한 특별한 구조가 제시되는데 그것이 바로 지금부터 소개할 가상 상속이다.



2. 가상 상속(Virtual Inheritance)

다이아몬드 구조에서 보았듯이 다중 상속은 멤버의 중복에 의한 메모리 낭비를 가져올 수 있다. 이런 문제는 꼭 다이아몬드 구조에서만 발생하는 것은 아니다. 다이아몬드 구조가 아니더라도 다중 상속이라면 충분히 발생할 수 있는 문제이며, 다만 다이아몬드 구조에서는 100% 멤버 중복으로 인한 모호성 문제가 발생한다는 것을 의미한다. 그리하여 모호성 문제와 메모리 낭비를 일거양득으로 해결할 수 있는 방법이 고안되었는데, 그것이 바로 가상 상속인 것이다. 가상 상속은 실제 프로젝트에서 그리 많이 사용되지 않기 때문에 낯선 독자도 많아서 문법 설명을 충실히 할 것이다. 여기에 덧붙여 가상 상속이 어떤 구조와 원리를 가지고 있는지도 충분히 추가로 설명할 것이다.



3. 가상 기저 클래스(Virtaul Base Class)

가상 상속을 하는 방법은 사실 그리 어렵지 않은데, 중복을 제거하고 싶은 클래스에 대해 virtual 키워드를 붙여서 상속한다.



<리스트 2> 가상 기저 클래스 class CTop { public: CTop() {} // 명시적 생성자 int m_Top; }; class CMiddleA : virtual public CTop // ⓐ { public: int m_MiddleA; }; class CMiddleB : virtual public CTop // ⓑ { public: int m_MiddleB; }; class CBottom : public CMiddleA, public CMiddleB { public: CBottom() { m_Top = 10; // ① m_MiddleA = 21; m_MiddleB = 22; m_Bottom = 30; } int m_Bottom; };



<리스트 2>의 ⓐ, ⓑ를 살펴보면 virtual 키워드가 추가되어 있음을 확인할 수 있다. CTop이 중복 상속되는 문제를 해결하기 위한 것이므로 CTop을 상속받을 때 virtual을 추가하는 것이다. virtual로 상속을 할 경우 상속되는 CTop 클래스를 가상 기저 클래스(Virtual Base Class)라고 한다. 가상 기저 클래스는 여러 번 상속되더라도 메모리 구조상 하나만 존재하게 된다. 따라서 ①과 같이 사용해도 더 이상 모호성 문제로 인한 컴파일 에러가 발생하지 않게 된다. 그러나 ⓑ에서 실수로 virtual이 생략될 경우는 가상 기저 클래스인 CTop 하나와 CMiddleB의 부모인 CTop이 메모리에 각각 생성된다. 따라서 중복 제거의 효과가 사라지게 되는 것이다.

CTop은 가상 기저 클래스로서 CMiddleA와 CMiddleB의 부모 클래스다. 따라서 생성자의 호출 순서를 생각하지 않을 수 없을 것이다. CBottom의 생성자가 호출되면 당연히 부모 클래스인 CMiddleA와 CMiddleB의 생성자를 호출하게 된다. 그렇다면 CTop이 CMiddleA와 CMiddleB의 부모 클래스이기 때문에 CTop의 생성자는 두 번 호출되는 것일까? 메모리 구조상 하나만 존재하는 데 생성자만 두 번 호출된다는 것은 이치에 맞지 않는다. 실제로 가상 상속된 가상 기저 클래스의 생성자는 절대로 여러 번 호출되지 않는다. 오직 단 한번만 호출될 뿐이다. 그렇다면 가상 기저 클래스의 생성자는 누가 호출하는 것인 CBottom에서 호출하게 되어있다. 가상 기저 클래스의 생성자가 최종 클래스의 생성자에 의해서 호출될 경우, 중간 부모 클래스인 CMiddleA와 CMiddleB의 생성자에서 가상 기저 클래스의 생성자는 호출되지 않도록 처리된다. 그러므로 생성자의 중복 호출 문제가 깨끗하게 해결될 수 있는 것이다.

갑자기 궁금함을 느끼는 독자도 있을 것이다. 가상 상속의 경우 메모리 구조는 어떻게 그려지는 것일까?

<그림 2>는 클래스 CBottom의 메모리 구조이다. 그림에서 확인할 수 있듯이 가상 기저 클래스인 CTop은 메모리에 단 하나만 존재한다. 또한 CTop이 메모리 구조의 맨 아래에 위치하는 것을 볼 수 있는데, 이런 구조가 반드시 정해져 있는 것은 아니다. 가상 상속 구조를 정하는 것은 컴파일러 제작사마다 다를 수 있다. C++ 명세는 단지 가상 기저 클래스는 메모리 구조에 단 한 벌만 존재하면 되는 것이기에 실제 메모리 배치와 같은 구현 방법은 컴파일러에 따라서 달라질 수 있다. 참고로 위의 그림은 Visual C++ 컴파일러를 기준으로 한 구조다. 눈여겨보아야 할 것이 있는데 바로 vbptr이라는 포인터 메모리 영역이다. 눈을 크게 뜨고 보자! 가상 함수 테이블 포인터를 나타내는 vfptr이 아니다(f가 아니라 b가 사용된다). 사실 vbptr이라는 것은 필자가 붙여 넣은 이름일 뿐이다. 어떤 클래스 C가 있고, C의 조상 클래스(부모 클래스, 부모의 부모 클래스, 부모의 부모의 부모 클래스, ...) 중에 가상 기저 클래스가 존재할 경우 컴파일러는 클래스 C의 메모리 시작 부분에 가상 기저 클래스 관리를 위해 기본 포인터 크기만큼(x86 4바이트, x64 8바이트) 메모리 영역을 확보한다. 이 영역은 내부에서만 사용되기 때문에 특별한 이름이 정해져 있지 않다. 그러나 Virtual Base Table이라는 것이 있고, 그것을 가리키는 포인터를 나타내므로 필자가 vbptr이라고 부르기로 했다.

<그림 1>과 <그림 2>를 한번 비교해보자. 가상 상속을 통해서 메모리를 절약하려고 했으나 실제로는 vbptr이 두 개나 추가되면서 오히려 클래스의 크기가 더 커진 것을 알 수 있다. 이런 경우를 주로 배보다 배꼽이 더 크다고 말하는데, 실제로 가상 상속에서는 충분히 그런 일이 벌어질 수 있다. 따라서 정말 작은 크기의 간단한 클래스를 가상 기저 클래스로 만들 경우 메모리 절약 효과가 그리 크지 않을 수도 있다. 따라서 가상 기저 클래스로 만들 클래스는 중복을 제거할 때 메모리 절약 효과가 있을 정도로 크기가 커야 의미가 있다.

메모리 절약 효과가 크다고 해서 무턱대고 가상 상속을 사용해서도 안 된다. 균형 효과(Trade Off)에 의해서 메모리 절약 효과가 있는 대신에 성능은 떨어질 수밖에 없다. 왜 vbptr이 있겠는가? 바로 가상 기저 클래스에 접근하기 위해서다. 다중 상속에서 클래스의 멤버 함수를 호출하거나 멤버에 접근할 경우 클래스 포인터의 타입 변환이 일어날 수밖에 없는데, 가상 상속을 사용할 경우 타입 변환이 일어날 때 vbptr을 한번 거쳐야만 하고, 이것은 필연적으로 어느 정도의 성능저하를 가져올 수밖에 없다. 따라서 가상 상속을 사용할 경우 신중하게 선택해야만 한다. 메모리 절약이 먼저인지, 성능이 우선인지를 잘 따져보아야만 하다. 어쩌면 둘 중 하나를 선택하는 것보다 애초에 클래스 설계를 제대로 해서 다이아몬드 구조 자체를 사용하지 않도록 만드는 것이 더 효과적일 수도 있다.



4. 가상 기저 테이블 포인터(Virtual Base Table Pointer)

앞에서 필자가 직접 이름을 붙여준 vbptr이 기억날 것이다. vbptr은 virtual base table pointer를 나타내는 말이다. 용어의 뜻에서 알 수 있듯이 vbptr은 특정 테이블을 가리키는 포인터이다. 마치 가상 함수 테이블을 가리키는 vfptr과 비슷하다고 보면 될 것이다. vbptr이 가리키는 virtual base table에는 가상 기저 클래스의 오프셋 정보가 포함되어 있다. 오프셋이 하나만 있으면 될 것 같은데 왜 테이블일까 궁금할 수도 있을 것이다. 그 이유는 가상 기저 클래스가 항상 한 개만 있는 것은 아니기 때문이다. 또한 virtual base table의 맨 처음 항목은 자기 자신의 클래스 오프셋 정보가 들어가기 때문에 가상 기저 클래스가 단 하나만 존재하더라도 virtual base table은 최소한 두 개의 항목을 가지게 된다. 오프셋이란 말은 특정 기준 위치로부터 얼마나 떨어져있는가를 나타내는 지표다. 즉, 특정 기준 위치가 중요하다. 당연히 클래스의 메모리 시작 주소라고 생각할 수 있는데, vbptr영역 자체의 메모리 위치가 바로 기준 위치가 된다. 설명만 들으면 전혀 이해가 가지 않을 것이다. 그래서 <그림 2>를 좀 더 확장해 설명을 이어나가도록 하겠다.



먼저 <리스트 2>를 다시 한 번 살펴보자! CMiddleA, CMiddleB가 virtual 키워드를 사용해CTop을 상속했다. 따라서 CTop은 가상 기저 클래스가 되며 전체 클래스 구조에서 가장 밑부분에 위치하게 된다.

virtual 키워드를 사용해 상속받은 자식 클래스는 반드시 vbptr이 생성되며 클래스 구조의 가능하면 위쪽(메모리 주소가 작은 쪽)에 위치하게 된다. 가능하면 위쪽에 위치한다는 의미는 더 위쪽에 다른 정보를 나타내는 영역이 존재할 수 있다는 의미이다. 사실 클래스에 가상 함수가 하나라도 존재해서 가상 함수 테이블 포인터인 vfptr이 생성될 경우 vfptr은 vbptr보다 우선 순위가 높아서 클래스 메모리의 최상위 시작 위치에 생성된다. 즉, 가상 함수가 없을 경우에는 일반적으로 vbptr이 최상위 즉, 클래스 오프셋으로 따지면 0 위치에 놓이게 되며, 가상 함수가 있을 때는 vfptr 바로 다음에 vbptr이 위치하게 된다. 따라서 그림에서 CMiddleA의 vbptr A와 CMiddleB의 vbptr B가 각각 자기 자신 클래스의 시작 위치에 생성돼 있음을 확인할 수 있다.

vbptr A, vbptr B는 각각 자신만 테이블을 가리키고 있다. 테이블에는 기본적인 오프셋 정보가 기록돼 있는데, vbptr A가 가리키는 테이블은 두 개의 오프셋 항목을 가지고 있으며, 첫 번째가 0, 두 번째가 20을 나타내고 있다. 첫 번째 0은 vbptr A의 위치를 기준으로 자기 자신 클래스의 오프셋을 나타낸다. CMiddleA의 시작위치는 vbptr A와 일치하므로 오프셋은 0이 된다. 두 번째 오프셋이 20을 가리키는데, 이것은 바로 가상 기저 클래스인 CTop의 오프셋을 나타낸다. vbptr A를 기준으로 CTop이 20바이트 만큼 떨어져있기에 오프셋은 20이 되는 것이다. 마찬가지로 따져보자. CMiddleB의 vbptr B가 가리키는 테이블에도 역시 두 개의 오프셋 항목이 있다. 첫 번째 0은 vbptr B를 기준으로 CMiddleB의 오프셋을 나타내는데 두 위치가 같으므로 오프셋은 역시 0이 된다. 두 번째 오프셋은 12를 나타낸다. vbptr B를 기준으로 가상 기저 클래스인 CTop이 12바이트 만큼 떨어져있기에 오프셋은 12가 되는 것이다.

한가지 의문이 들 수도 있을 것이다. 오프셋 테이블의 첫 번째 오프셋은 항상 0이 되기 때문에 불필요하지 않을까 생각할 수도 있다. 그러나 첫 번째 오프셋이 항상 0이 되는 것은 아니다. 앞에서도 설명했듯이 vbptr을 기준으로 자기 자신 클래스의 오프셋을 구하는 것이기 때문에 0이 아니라 음수가 나올 수도 있다. 실제로 예제를 통해서 확인해보자!



<리스트 3> 가상 함수와}; class CD2 : virtual public CD1 { public: virtual ~CD2() {} // ① Virtual Function int m_D2; };



<리스트 3>에서 클래스 CD2는 ①에서 소멸자를 가상 함수로 선언했으며, 클래스 CD1을 가상 상속한다. <그림 4>를 통해서 클래스 CD2의 메모리 구조를 살펴보자!



클래스 CD1은 가상 기저 클래스이므로 메모리 구조에서 가장 밑에 위치하게 된다. 클래스 CD2는 소멸자가 가상 함수이므로 vfptr(가상 함수 테이블 포인터)를 가지게 되며, 앞에서 설명했듯이 vfptr은 메모리 가장 위쪽에 위치하게 된다. 따라서 가상 기저 클래스의 오프셋을 나타내는 vbptr은 vfptr 바로 밑에 위치하게 되는 것이다.

vbptr이 가리키는 오프셋 테이블의 항목들의 값을 살펴보자! 첫 번째 값은 자기 자신 클래스의 오프셋을 나타낸다고 하였다. 자기 자신이라는 것은 곧 CD2 클래스를 의미한다. 오프셋의 기준은 vbptr이므로 CD2의 시작 위치는 vfptr을 기준으로 오프셋이 -4가 된다. 두 번째 값은 가상 기저 클래스인 CD1의 오프셋을 나타내며 vbptr 기준으로 8바이트만큼 떨어져있으므로 8이 된다.

또 하나의 예를 살펴보자! 이번에는 가상 함수를 사용하지 않지만 역시 vbptr이 클래스 메모리 시작 주소에 위치하지 않는 경우다.



<리스트 4> 가상 함수가 없는 가상 기저 클래스 class CD1A { public: int m_D1A; }; class CD1B { public: int m_D1B; }; class CD2 : public CD1A, virtual public CD1B // ① { public: int m_D2; };



<리스트 4>의 ①을 살펴보자! 클래스 CD2는 부모 클래스 CD1A, CD1B를 상속하는데, 그 중에서 CD1B에 대해서만 가상 상속을 한다. 그로 인해 <그림 5>와 같은 메모리 구조가 생겨난다.



클래스 CD2를 컴파일러가 구성할 때, 부모 클래스의 순서대로 메모리에 구조를 잡는다. 그래서 CD1A가 먼저 위치하고, 그 뒤에 CD1B가 위치하게 된다. 이때 CD1B는 가상 기저 클래스가 되므로 메모리 구조의 가장 밑으로 이동하게 되고, 대신 vbptr을 생성해 CD1A 바로 밑에 위치시키게 되는 것이다. vbptr이 가리키는 오프셋 테이블의 값은 그림과 같다. 자기 자신 클래스인 CD2를 가리키기 위해 첫 번째 오프셋은 -4가 되며, 가상 기저 클래스 CD1B를 가리키기 위해 두 번째 오프셋은 8이 된다.

여기서 감이 올지 모르겠으나 가상 상속의 메모리 구조에 대한 일반적인 법칙을 이해할 수 있다. 일단 가상 상속이라는 개념을 무시하고 메모리 상속 구조를 만든다. 가상 기저 클래스에 대해서는 각각 vbptr로 대체하고 가상 기저 클래스는 맨 밑으로 이동시킨다. 이때 가상 기저 클래스가 중복되더라도 오직 하나만 존재하도록 한다. vbptr이 가리키는 테이블의 항목에 가상 기저 클래스의 오프셋을 계산해 기록한다. 바로 이것이 컴파일러가 가상 기저 클래스의 메모리 구조를 작성하는 기본적인 방식이다. 물론 기본적인 원칙 이외에도 여러 가지 규칙들이 적용되기 때문에 훨씬 복잡해질 수 있다. 가상 상속 구조에 대한 일반적인 이해를 바탕으로 마지막 예제를 살펴보자!



<리스트 5> 가상 상속 실습 class CD1A { public: int m_D1A; }; class CD2A : virtual public CD1A // ① { public: int m_D2A; }; class CD1B { public: int m_D1B; }; class CD2B : virtual public CD1B // ② { public: int m_D2B; }; class CD3 : virtual public CD2A, virtual public CD2B // ③ { public: int m_D3; };





<리스트 5>와 <그림 6>을 함께 살펴보자! 소스 코드는 그리 복잡하지 않은 것 같지만, 그림을 보면 현기증이 날만큼 복잡함을 알 수 있다. 먼저 중간에 있는 vbptr of CD2A를 살펴보자! CD2A는 CD1A를 가상 상속하였다. 따라서 CD2A의 메모리 시작 위치에는 vbptr이 존재한다. 이 vbptr을 다른 vbptr과 구분하기 위하여 vbptr of CD2A라고 이름 지었다. vbptr of CD2A가 가리키는 오프셋 테이블을 살펴보자! 첫 번째 오프셋은 자기 자신인 CD2A의 오프셋을 나타내므로 0이 된다. 두 번째 오프셋은 -4가 되는데, CD2A의 가상 기저 클래스인 CD1A의 오프셋을 나타내기 때문이다. 그림에서 살펴보면 CD1A는 CD2A의 바로 위에 위치하고 있다. 가상 기저 클래스가 여러 개 존재하게 될 경우 반드시 메모리 구조상 맨 밑에 위치하는 것은 아니다. 이런 방식으로 아래쪽에 그려진 vbptr of CD2B도 쉽게 해석할 수 있을 것이다.

이제 가장 복잡해 보이는 vbptr of CD3를 살펴보자! 오프셋 테이블에 오프셋이 무려 다섯 개나 담겨있다. 왜 이렇게 많은 것일까? 오프셋 테이블은 가상 기저 클래스의 오프셋을 담는 테이블이다. 즉, 클래스 CD3가 상속하는 조상 클래스 중 가상 기저 클래스의 오프셋은 모두 모아놓는다고 볼 수 있다. 테이블에 기록되는 오프셋의 순서는 최상위 가상 기저 클래스부터 그것을 상속하여 따라오는 자식 가상 기저 클래스로 이어진다. 자식 가상 기저 클래스가 끝나면 최상위 가상 기저 클래스의 이웃 가상 기저 클래스로 이어지게 된다. 글로만 설명하니 어려운 면이 있는데 자료구조의 트리를 탐색한다고 생각하면 된다. 특정 노드를 검색할 때 자식 노드들이 모두 검색 완료되면 특정 노드의 형제 노드로 검색순서가 이어지는 것과 같다.

vbptr of CD3가 가리키는 테이블의 첫 번째 항목은 CD3 자기 자신을 나타내므로 0이 된다. CD3가 처음으로 가상 상속하는 기저 클래스는 ③에서 볼 수 있듯이 CD2A이다. 그런데 CD2A는 ①에서 보듯이 CD1A를 가상 상속한다. 즉, CD3 입장에서는 조상 클래스 중 가상 기저 클래스의 서열을 따질 때, CD1A, CD2A 순서가 된다. 따라서 테이블의 두 번째, 세 번째 항목은 각각 CD1A, CD2A의 오프셋을 나타내는 것이다. 역시 같은 식으로 따질 경우 네 번째와 다섯 번째는 각각 CD1B와 CD2B의 오프셋을 나타낸다. 지금까지 살펴보았듯이 가상 상속은 개념은 무척 쉬울지 모르지만 설계나 구현 부분은 상당히 복잡하다. 그로 인해 메모리 절약 및 성능 향상을 위해 쉽게 가상 상속을 사용하려 하지만 뜻대로 효과를 보지 못하는 경우도 발생할 수 있다.



정리

가상 상속에 대해서 자세히 살펴보았다. 일반적으로 현업에서 가상 상속이 사용되는 경우는 극히 드물다. 또한 많은 서적에서 가상 상속은 단지 C++ 다중 상속에서 발생할 수 있는 중복 메모리 문제를 해결하기 위한 팁 정도로 간단히 소개되는 경우도 많다. 그러나 충분히 살펴보았듯이 가상 상속은 꽤 복잡한 구조를 지니며 그로 인해서 오히려 메모리를 더 소비하고, 성능은 더욱 떨어트릴 수도 있다. 즉, 제대로 알고 사용해야지 무턱대고 사용하면 얻기는커녕 잃기만 할 수 있다. C++ 주제이지만, 왜 Java가 다중 상속을 피하게 되었는지(다중 상속을 사용하지 않을 경우 자연스레 가상 상속도 있을 이유가 없다.) 충분히 이해할 수 있는 주제였다고 생각한다.



출처 : 마이크로소프트웨어 4월호

제공 : 데이터전문가 지식포털 DBguide.net