C++ 다이아몬드 상속 문제: virtual 상속의 동작과 초기화 순서, 컴포지션 대안

이 글의 핵심

다중 상속에서 같은 기본 클래스가 여러 경로로 상속되어 발생하는 다이아몬드 문제의 원인, virtual 상속이 이를 해결하는 정확한 객체 레이아웃 원리, 생성자 초기화 순서, 그리고 컴포지션·인터페이스 분리로 애초에 문제를 피하는 실전 대안을 다룹니다.

Diamond Problem이란?

다이아몬드 문제는 하나의 기본 클래스(base class)를 두 개 이상의 중간 클래스가 각각 상속하고, 그 중간 클래스들을 다시 하나의 최하위 클래스가 다중 상속할 때 발생합니다. 상속 관계를 그림으로 그리면 마름모(다이아몬드) 모양이 되기 때문에 이런 이름이 붙었습니다. 문제의 핵심은 “같은 이름의 멤버”가 모호해지는 것이 아니라, 기본 클래스의 서브오브젝트(subobject) 자체가 메모리 상에 두 벌 생긴다는 점입니다. D는 B를 통해 A 하나, C를 통해 A 하나, 총 두 개의 독립된 A 서브오브젝트를 갖게 됩니다. 이 둘은 완전히 별개의 메모리 영역이기 때문에, B 경로로 A::x를 수정해도 C 경로에서 보는 A::x는 그대로입니다.

    A
   / \
  B   C
   \ /
    D
class A { int x; };
class B : public A {};
class C : public A {};
class D : public B, public C {};  // A가 두 번

컴파일러 입장에서는 D 객체 안에 A가 두 벌 존재하는 상황을 그대로 받아들입니다. 문제는 사람이 d.x처럼 A의 멤버에 접근하려 할 때 어느 A의 x를 말하는 것인지 컴파일러가 판단할 수 없다는 것입니다. 이것이 이어지는 절에서 다룰 “모호한 호출” 컴파일 에러의 근본 원인입니다. sizeof(D)를 직접 찍어보면 A가 정말 두 벌 들어있다는 것을 눈으로 확인할 수 있는데, 이 사실을 모르고 다중 상속을 설계하면 나중에 디버깅하다가 당황하게 됩니다.

문제 상황

class Animal {
public:
    void eat() {
        std::cout << "Eating" << std::endl;
    }
};

class Mammal : public Animal {};
class Bird : public Animal {};

class Bat : public Mammal, public Bird {
    // Animal이 두 번 상속됨
};

int main() {
    Bat b;
    // b.eat();  // 에러: 모호함
    b.Mammal::eat();  // 명시적 지정 필요
}

Bat은 Mammal을 통해 얻은 Animal과 Bird를 통해 얻은 Animal, 이렇게 서로 다른 두 개의 Animal 서브오브젝트를 갖습니다. b.eat()이라고 쓰면 컴파일러는 “Mammal::Animal::eat()을 말하는 건지 Bird::Animal::eat()을 말하는 건지” 판단할 근거가 없어서 컴파일 에러를 냅니다. b.Mammal::eat()처럼 스코프를 명시하면 컴파일은 되지만, 이는 문제를 회피한 것이지 해결한 것이 아닙니다. Bat이 “포유류이면서 동시에 조류인 동물”이라는 개념 자체는 유지되지만, 그 동물이 정확히 어떤 Animal 상태를 갖는지가 호출부마다 갈릴 수 있다는 근본적인 설계 결함은 그대로 남습니다.

가상 상속 해결

class Animal {
public:
    void eat() {
        std::cout << "Eating" << std::endl;
    }
};

class Mammal : virtual public Animal {};
class Bird : virtual public Animal {};

class Bat : public Mammal, public Bird {};

int main() {
    Bat b;
    b.eat();  // OK: Animal이 한 번만
}

virtual 키워드를 상속 목록에 붙이면 컴파일러는 Animal 서브오브젝트를 최종 객체 안에 단 하나만 배치하고(주요 구현에서는 보통 객체의 뒷부분에 둡니다), Mammal과 Bird는 그 하나뿐인 Animal을 간접적으로 찾아가게 됩니다. 이유는 Mammal 서브오브젝트 입장에서 Animal까지의 거리가 최종 타입에 따라 달라지기 때문입니다. Mammal 단독 객체와 Bat 안의 Mammal은 Animal과의 오프셋이 다르므로, 컴파일 타임에 고정 오프셋을 쓸 수 없습니다. 구현 방식은 ABI마다 다릅니다. GCC·Clang이 따르는 Itanium C++ ABI는 vtable 안에 가상 기본 클래스 오프셋(vbase offset)을 저장하고 기존 vptr로 찾아가며, MSVC는 가상 기본 클래스 테이블을 가리키는 별도의 vbptr을 객체에 넣습니다. 어느 쪽이든 가상 기본 클래스 멤버에 접근할 때 오프셋을 한 번 읽어 오는 간접 단계가 추가되고, 객체에는 포인터 크기의 숨은 필드가 생길 수 있습니다. 대부분의 코드에서는 이 비용이 문제가 되지 않지만, 아주 작은 객체를 대량으로 만들거나 짧은 루프에서 가상 기본 클래스 멤버를 반복 접근하는 코드라면 프로파일러로 확인해 볼 만합니다.

여기서 반드시 기억해야 할 규칙이 하나 있습니다. 가상 기본 클래스는 항상 가장 파생된 클래스(most-derived class)가 직접 초기화한다는 것입니다. Mammal이나 Bird의 생성자에 Animal(...) 초기화 목록을 적어도, Bat을 직접 생성할 때는 그 초기화 목록이 무시되고 Bat의 생성자에 적힌 Animal(...) 호출(또는 기본 생성자)이 사용됩니다. 이 규칙을 모르면 “분명 중간 클래스에서 인자를 넘겼는데 왜 기본 생성자로 초기화됐지”라는 의문에 빠지기 쉽습니다.

flowchart TD
    A["Animal (가상 기본 클래스, 단일 인스턴스)"]
    M["Mammal<br/>오프셋으로 Animal 참조"]
    B["Bird<br/>오프셋으로 Animal 참조"]
    D["Bat (최하위 클래스)<br/>Animal을 직접 초기화"]
    A --> M
    A --> B
    M --> D
    B --> D
    D -.->|"생성자에서 직접 호출"| A

자주 빠지는 함정 두 가지

첫 번째 함정은 공통 기본 클래스가 있다는 사실을 놓치는 경우입니다. 예를 들어 id 멤버를 가진 Component를 Loggable과 Serializable이 각각 virtual 없이 상속하고, 실제 컴포넌트 클래스가 이 둘을 다중 상속한다고 해 봅시다. 이때 객체 안에는 Component가 두 벌 있으므로, Loggable 쪽 경로로 id를 설정해도 Serializable 쪽 경로로 읽은 id는 초기값 그대로입니다. 코드상으로는 같은 id처럼 보이기 때문에 원인을 찾기 어렵고, sizeof가 예상보다 크거나 두 경로에서 얻은 Component* 주소가 서로 다르다는 점을 확인해야 비로소 드러납니다. 그래서 여러 인터페이스가 공통 기본 클래스를 공유하는지는 다중 상속을 설계하는 초기에 확인해야 합니다.

두 번째 함정은 초기화 순서입니다. 가상 상속 계층에서 중간 클래스 생성자에 Base(config)처럼 실제 설정값을 넘기도록 작성해 두고, 최하위 클래스의 생성자 초기화 목록에는 Base를 적지 않으면, 컴파일러는 Base의 기본 생성자를 호출하고 중간 클래스에 적힌 Base(config)는 무시합니다. Base에 기본 생성자가 있으면 컴파일 에러도 나지 않고, 주요 컴파일러는 이 상황에 기본적으로 경고를 내지 않으므로 설정값이 전부 기본값으로 초기화된 것을 런타임에 발견하게 됩니다. 가상 상속을 쓴다면 최하위 클래스 생성자 초기화 목록에 가상 기본 클래스를 항상 명시적으로 적는 편이 안전합니다.

실전 예시

예시 1: 기본 다이아몬드

class Base {
protected:
    int value;
public:
    Base(int v) : value(v) {}
    int getValue() const { return value; }
};

class Left : virtual public Base {
public:
    Left(int v) : Base(v) {}
};

class Right : virtual public Base {
public:
    Right(int v) : Base(v) {}
};

class Bottom : public Left, public Right {
public:
    Bottom(int v) : Base(v), Left(v), Right(v) {}
};

int main() {
    Bottom b(10);
    std::cout << b.getValue() << std::endl;  // 10
}

Bottom의 초기화 목록에 Left(v), Right(v)가 있지만, 이 안에 적힌 Base(v) 호출은 실제로는 사용되지 않습니다. Base를 초기화하는 건 오직 Bottom(int v) : Base(v), ...에 명시된 Base(v)뿐입니다. Left(v)와 Right(v)는 Base가 아닌 부분(만약 있다면 Left, Right 자체의 멤버)만 초기화하는 역할로 축소됩니다. 이 규칙을 모르고 Left와 Right에 서로 다른 값을 넘기면, “둘 중 어느 값이 최종 Base::value가 될까” 하는 혼란이 생기는데, 정답은 “둘 다 아니고 Bottom이 넘긴 값”입니다.

예시 2: 인터페이스 다중 상속

class IDrawable {
public:
    virtual void draw() = 0;
    virtual ~IDrawable() = default;
};

class ISerializable {
public:
    virtual void serialize() = 0;
    virtual ~ISerializable() = default;
};

class Shape : public IDrawable, public ISerializable {
public:
    void draw() override {
        std::cout << "Drawing" << std::endl;
    }
    
    void serialize() override {
        std::cout << "Serializing" << std::endl;
    }
};

이 예시는 다중 상속 자체가 문제는 아니라는 점을 보여줍니다. IDrawable과 ISerializable은 서로 다른 별개의 클래스이므로, Shape가 이 둘을 다중 상속해도 같은 기본 클래스가 중복되는 상황 자체가 생기지 않습니다. 순수 가상 함수만 있는 인터페이스를 여러 개 다중 상속하는 패턴은 가장 문제가 적은 다중 상속 형태이며, Google C++ Style Guide도 다중 상속은 허용하되 구현을 가진 기본 클래스를 여러 개 상속하는 것(multiple implementation inheritance)은 강하게 지양하라고 적고 있습니다. 데이터 멤버나 구현 로직을 갖는 “구현 상속”을 여러 경로로 섞을 때만 다이아몬드 문제의 위험이 커집니다.

예시 3: 생성자 호출 순서

class A {
public:
    A() { std::cout << "A" << std::endl; }
};

class B : virtual public A {
public:
    B() { std::cout << "B" << std::endl; }
};

class C : virtual public A {
public:
    C() { std::cout << "C" << std::endl; }
};

class D : public B, public C {
public:
    D() { std::cout << "D" << std::endl; }
};

int main() {
    D d;
    // 출력: A B C D
}

가상 상속 계층의 생성자 호출 순서에서는 가상 기본 클래스가 항상 가장 먼저 생성됩니다. 그다음은 선언 순서(여기서는 B, C)를 따르고, 마지막으로 D 자신이 초기화됩니다. non-virtual 다중 상속이었다면 A가 B와 C 각각의 일부로 두 번 생성되어 “A B A C D”가 출력됩니다. 가상 상속에서는 A가 정확히 한 번만 생성되기 때문에 “A B C D”라는 출력이 직관적으로 이해가 됩니다. 만약 D가 A의 생성자 인자를 지정하지 않았다면 A의 기본 생성자가 호출되고, A에 기본 생성자가 없다면 컴파일 에러가 발생합니다 — 이 에러 메시지가 처음에는 왜 B나 C가 아니라 D를 가리키는지 헷갈릴 수 있는데, 바로 “가장 파생된 클래스가 가상 기본 클래스 초기화 책임을 진다”는 규칙 때문입니다.

예시 4: 대안 - 컴포지션

// ❌ 다중 상속
class Engine {};
class Wheels {};
class Car : public Engine, public Wheels {};

// ✅ 컴포지션
class Car {
private:
    Engine engine;
    Wheels wheels;
};

Car는 Engine도 아니고 Wheels도 아닙니다. “Car는 Engine을 가진다(has-a)“이지 “Car는 Engine이다(is-a)“가 아니기 때문에, 애초에 상속을 쓸 이유가 없습니다. 이런 관계를 상속으로 표현하면 Car의 퍼블릭 인터페이스에 Engine과 Wheels의 모든 public 멤버가 그대로 노출되어 캡슐화가 깨지고, 나중에 Engine과 Wheels가 우연히 같은 이름의 멤버를 가지면 다이아몬드 문제와 비슷한 모호성 문제까지 떠안게 됩니다. 컴포지션을 쓰면 Car가 engine.start()처럼 명시적으로 위임할 대상을 고를 수 있고, 다중 상속 관련 문제 자체가 원천적으로 발생하지 않습니다. 실무에서 다중 상속을 검토할 때 가장 먼저 던져야 할 질문은 “이게 정말 is-a 관계인가, 아니면 has-a를 상속으로 흉내 내고 있는가”입니다.

자주 발생하는 문제

문제 1: 모호한 호출

// ❌ 가상 상속 없음
class Base {
public:
    void func() {}
};

class D1 : public Base {};
class D2 : public Base {};
class Final : public D1, public D2 {};

Final f;
// f.func();  // 에러: 모호함

// ✅ 가상 상속
class D1 : virtual public Base {};
class D2 : virtual public Base {};

f.D1::func()처럼 중간 클래스를 명시해서 컴파일 에러를 피할 수는 있지만(f.Base::func()는 Final에서 Base로의 변환 자체가 모호하므로 여전히 에러입니다), 이는 “어느 Base를 쓸지 호출부가 매번 결정해야 한다”는 근본 문제를 감춘 임시방편일 뿐입니다. virtual 상속으로 바꾸면 애초에 Base가 하나만 존재하므로 이런 명시적 스코프 지정 자체가 필요 없어집니다. 기존 코드베이스에 이미 non-virtual 다중 상속이 퍼져 있는 상태에서 뒤늦게 virtual을 추가하면, ABI(바이너리 호환성)가 깨질 수 있다는 점도 알아둬야 합니다. 가상 상속 여부에 따라 객체 레이아웃이 완전히 달라지기 때문에, 이미 배포된 라이브러리의 클래스 계층에 virtual을 추가하는 것은 마이너 버전 업데이트로 취급해서는 안 됩니다.

문제 2: 생성자 초기화

class Base {
public:
    Base(int x) {}
};

class D1 : virtual public Base {
public:
    D1(int x) : Base(x) {}
};

class D2 : virtual public Base {
public:
    D2(int x) : Base(x) {}
};

class Final : public D1, public D2 {
public:
    // ✅ 가장 파생된 클래스가 Base 초기화
    Final(int x) : Base(x), D1(x), D2(x) {}
};

Final의 초기화 목록에 Base(x)를 빼먹으면 컴파일러가 자동으로 Base의 기본 생성자를 호출하려 시도하는데, 위 예시처럼 Base에 기본 생성자가 없다면 컴파일 에러가 납니다. 문제는 에러 메시지가 D1이나 D2가 아니라 Final을 가리킨다는 점인데, 처음 이 상황을 마주치면 “나는 D1(x), D2(x)에서 이미 Base(x)를 호출하고 있는데 왜 에러가 나지”라는 의문을 갖기 쉽습니다. 답은 앞서 설명한 대로 중간 클래스의 가상 기본 클래스 초기화 지정은 무시되기 때문입니다. 이런 클래스 계층을 여러 명이 나눠서 유지보수하는 팀이라면, Base를 초기화하는 코드가 실제로는 Final 쪽에만 있다는 사실을 문서나 주석으로 남겨두는 편이 좋습니다.

문제 3: 메모리 오버헤드

// 가상 상속은 숨은 포인터(vptr 또는 vbptr)를 추가
class Base {};
class D : virtual public Base {};

// sizeof(D) > sizeof(Base)

가상 상속 클래스는 가상 기본 클래스를 찾아가기 위한 포인터(또는 오프셋 정보)를 추가로 저장하기 때문에, 일반 상속보다 객체 크기가 커집니다. 이 오버헤드는 보통 포인터 하나 정도(64비트 환경에서 8바이트)이고 정렬에 따라 패딩이 더 붙을 수 있습니다. 크지 않지만, 이런 객체를 수백만 개 생성하는 자료구조라면 무시할 수 없는 크기가 됩니다. 또한 dynamic_cast를 가상 상속 계층에서 사용할 때는 RTTI(Run-Time Type Information)를 통해 실제 객체의 최종 타입을 확인한 뒤 올바른 오프셋을 계산해야 하므로, non-virtual 상속의 dynamic_cast보다 약간 더 무겁습니다. 성능이 민감한 코드 경로에서 가상 상속 객체를 자주 캐스팅한다면, 캐스팅 결과를 캐싱하거나 애초에 가상 상속을 쓰지 않는 설계를 고려하는 게 낫습니다.

문제 4: 복잡성

// ❌ 복잡한 다중 상속
class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
class E : public D {};

// ✅ 인터페이스 분리
class IInterface1 {};
class IInterface2 {};
class Implementation : public IInterface1, public IInterface2 {};

계층이 한 단계씩 깊어질 때마다 가상 기본 클래스를 초기화할 책임이 계속 아래로 위임되기 때문에, E처럼 계층이 깊어지면 “결국 누가 A를 초기화하는가”를 추적하기가 점점 어려워집니다. 실무에서 다중 상속 계층이 3단계 이상으로 깊어진다면, 이는 대개 설계가 잘못되고 있다는 신호입니다. 인터페이스 분리 원칙(Interface Segregation Principle)을 따라 각 인터페이스가 순수 가상 함수만 갖도록 하면 데이터 멤버가 아예 없으므로 가상 상속이 필요한 상황 자체가 사라집니다.

대안

// 1. 컴포지션
class Car {
    Engine engine;
    Wheels wheels;
};

// 2. 인터페이스 상속만
class IDrawable { virtual void draw() = 0; };
class IClickable { virtual void click() = 0; };

// 3. 템플릿
template<typename Base1, typename Base2>
class Combined : public Base1, public Base2 {};

세 대안 모두 “다중 구현 상속을 피한다”는 공통된 방향을 가리킵니다. 컴포지션은 has-a 관계에, 인터페이스 상속은 is-a 관계 중에서도 상태 없는 계약(contract)에 적합합니다. 템플릿 기반의 CRTP(Curiously Recurring Template Pattern)나 믹스인(mixin) 패턴은 컴파일 타임에 조합을 결정할 수 있어 유연하지만, 템플릿 인스턴스화마다 별개의 타입이 생기므로 다형성이 필요한 경우에는 맞지 않습니다. 실무에서는 대부분 컴포지션과 순수 인터페이스 상속의 조합만으로 충분하고, 데이터를 가진 클래스 두 개를 동시에 상속해야 하는 상황 자체를 설계 단계에서 걸러내는 것이 가장 확실한 예방책입니다.


같이 보면 좋은 글