C++ 객체 슬라이싱 에러: vector<Base>에 담을 때 잘리는 이유와 clone 패턴

이 글의 핵심

슬라이싱은 컴파일 에러도 크래시도 없이 객체가 기반 클래스 부분만 남은 채 동작해서, 다형성이 갑자기 작동하지 않는 버그로 나타납니다. 함수 인자와 컨테이너에서 복사가 일어나는 지점을 짚고, 다형적 기반 클래스는 복사를 금지하거나 protected로 두고 필요한 복사는 가상 clone()으로 하는 설계를 예제로 보여줍니다. 예외를 값으로 catch할 때의 슬라이싱도 함께 다룹니다.

들어가며: “파생 클래스를 복사했더니 데이터가 사라졌어요”

C++에서 파생 클래스 객체를 베이스 클래스 타입으로 복사하면, 파생 클래스의 멤버가 잘려나가는 슬라이싱(Slicing) 문제가 발생합니다.

// ❌ 슬라이싱 문제
class Animal {
public:
    virtual void speak() {
        std::cout << "Animal sound\n";
    }
};

class Dog : public Animal {
    std::string name_;
public:
    Dog(const std::string& name) : name_(name) {}
    
    void speak() override {
        std::cout << name_ << " barks\n";
    }
};

void makeSound(Animal animal) {  // ❌ 값 전달
    animal.speak();
}

int main() {
    Dog dog("Buddy");
    makeSound(dog);  // ❌ 슬라이싱 발생
    // 출력: Animal sound (Dog가 잘림!)
}

이 코드는 경고 하나 없이 컴파일됩니다. Dog는 Animal을 public 상속했으므로 “Dog는 Animal이다”라는 관계가 성립하고, Animal의 복사 생성자 Animal(const Animal&)는 Dog 객체를 const Animal&로 받을 수 있기 때문입니다. 컴파일러 입장에서는 정상적인 복사일 뿐이고, 그 결과가 우리가 원한 것이 아닐 뿐입니다. 이 점이 슬라이싱을 찾기 어렵게 만듭니다. 크래시도 없고 에러 메시지도 없이, “왜 오버라이드한 함수가 안 불리지?”라는 증상으로만 나타납니다.

이 글에서 다루는 것:

  • 슬라이싱 문제란?
  • 다형성 손실
  • 참조·포인터로 해결
  • 복사 방지 패턴

슬라이싱 문제란?

슬라이싱 발생

class Base {
public:
    int x = 1;
    virtual void foo() {
        std::cout << "Base::foo\n";
    }
};

class Derived : public Base {
public:
    int y = 2;  // 파생 클래스 멤버
    void foo() override {
        std::cout << "Derived::foo\n";
    }
};

int main() {
    Derived d;
    Base b = d;  // ❌ 슬라이싱 발생
    
    std::cout << b.x << '\n';  // 1
    // std::cout << b.y << '\n';  // 컴파일 에러: y가 없음
    
    b.foo();  // Base::foo (다형성 손실)
}

문제:

  • y 멤버 손실
  • 다형성 손실 (virtual 무시)
  • vtable 포인터 복사 안 됨

왜 이런 일이 일어나는지는 메모리 크기로 보면 명확합니다. Base b는 Base 크기만큼의 공간(vptr + x)만 가진 새 객체입니다. Derived의 y를 담을 자리 자체가 없으므로, 복사 생성자는 d의 Base 부분만 골라 복사합니다. 이름 그대로 파생 부분이 잘려 나가는 것입니다.

“vtable 포인터가 복사되지 않는다”는 부분도 같은 원리입니다. vptr은 멤버 변수처럼 복사되는 값이 아니라 생성자가 자기 클래스의 vtable 주소로 설정하는 값입니다. b를 만든 것은 Base의 복사 생성자이므로 b의 vptr은 Base의 vtable을 가리키고, 그래서 b.foo()는 Base::foo를 호출합니다. 만약 vptr까지 그대로 복사되었다면 Derived::foo가 호출되면서 존재하지도 않는 y에 접근해 메모리를 망가뜨렸을 것입니다. 즉 슬라이싱된 객체는 “잘린 채로나마 일관된 Base 객체”이고, 이것이 C++이 이 복사를 허용하는 이유이기도 합니다.

값으로 복사하지 않고 참조나 포인터를 쓰면 다형성이 유지되는 이유도 여기서 나옵니다. Base& r = d;는 새 객체를 만들지 않고 기존 d를 가리키므로, vptr도 d가 가진 Derived의 vtable 그대로입니다.


다형성 손실

예시 1: 함수 인자

// ❌ 값 전달
void process(Animal animal) {  // 슬라이싱
    animal.speak();  // Animal::speak 호출
}

Dog dog("Buddy");
process(dog);  // Animal sound (다형성 손실)

// ✅ 참조 전달
void process(Animal& animal) {  // 슬라이싱 없음
    animal.speak();  // Dog::speak 호출
}

process(dog);  // Buddy barks (다형성 유지)

참조 전달은 슬라이싱을 막을 뿐 아니라 성능도 좋습니다. 값 전달은 호출할 때마다 Animal 복사본을 만드는데, Dog처럼 std::string 같은 멤버가 있는 타입이었다면 파생 부분은 버리면서 기반 부분의 복사 비용만 치르는 셈입니다. 그래서 “다형적 타입은 참조나 포인터로 다룬다”는 규칙은 C++ Core Guidelines에도 C.145(“다형 클래스는 포인터와 참조로 접근하라”)로 명시되어 있습니다.

예시 2: 컨테이너

// ❌ vector<Base>
std::vector<Animal> animals;
animals.push_back(Dog("Buddy"));  // 슬라이싱
animals[0].speak();  // Animal sound

// ✅ vector<unique_ptr<Base>>
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>("Buddy"));
animals[0]->speak();  // Buddy barks

컨테이너 슬라이싱은 함수 인자보다 더 자주, 더 늦게 발견됩니다. std::vector<Animal>은 Animal 크기의 칸을 연속으로 가진 배열이라 Dog를 통째로 담을 방법이 없고, push_back은 Animal 부분만 복사해 넣습니다. 처음 다형성을 배운 뒤 “도형 목록”이나 “적 캐릭터 목록”을 만들 때 가장 흔히 겪는 실수가 이것으로, 모든 요소가 기반 클래스처럼 동작하는데 코드상으로는 이상한 곳이 보이지 않아 한참을 헤매게 됩니다.

std::vector<std::unique_ptr<Animal>>는 요소가 포인터이므로 실제 객체는 힙에 각자의 크기로 존재합니다. 대신 요소마다 힙 할당이 일어나고, 객체들이 메모리에 흩어져 순회할 때 캐시 효율이 떨어진다는 비용이 있습니다. 파생 타입의 종류가 미리 정해져 있고 성능이 중요하다면 std::vector<std::variant<Dog, Cat>>처럼 값으로 담고 std::visit으로 분기하는 방법도 있습니다. unique_ptr을 담은 vector는 복사가 불가능하다는 점도 기억해 두십시오. vector 자체를 복사해야 한다면 아래의 clone() 패턴이 필요합니다.


해결책: 참조·포인터

해결책 1: 참조 사용

// ✅ 참조 (const 참조로 받으려면 speak()가 const 멤버 함수여야 함:
//          virtual void speak() const)
void process(const Animal& animal) {
    animal.speak();
}

Dog dog("Buddy");
process(dog);  // Buddy barks

해결책 2: 포인터 사용

// ✅ 포인터
void process(Animal* animal) {
    if (animal) {
        animal->speak();
    }
}

Dog dog("Buddy");
process(&dog);  // Buddy barks

해결책 3: 스마트 포인터

// ✅ unique_ptr
void process(std::unique_ptr<Animal> animal) {
    animal->speak();
}

process(std::make_unique<Dog>("Buddy"));  // Buddy barks

// ✅ shared_ptr
void process(std::shared_ptr<Animal> animal) {
    animal->speak();
}

process(std::make_shared<Dog>("Buddy"));  // Buddy barks

해결책 1은 const 참조로 받고 있으므로, 앞의 Animal 정의처럼 speak()가 const가 아닌 멤버 함수라면 passing 'const Animal' as 'this' argument discards qualifiers 에러가 납니다. 객체를 수정하지 않는 가상 함수는 처음부터 virtual void speak() const로 선언해 두는 것이 좋습니다. 파생 클래스의 오버라이드도 const까지 같아야 하며, 한쪽에만 const가 있으면 오버라이드가 아니라 새 함수가 되는데, override 키워드를 붙여 두면 이 실수를 컴파일 에러로 잡을 수 있습니다.

세 방법은 소유권으로 고르면 됩니다. 함수가 객체를 잠시 쓰기만 한다면 참조(또는 null일 수 있으면 포인터)를 받습니다. unique_ptr을 값으로 받는 것은 “이 함수가 객체의 소유권을 가져간다”는 뜻이라, 호출한 쪽은 std::move로 넘긴 뒤 더 이상 그 객체를 쓸 수 없습니다. 단지 호출하기 위해 스마트 포인터를 인자로 받는 것은 불필요한 제약이므로, 소유권을 넘기거나 공유할 때만 스마트 포인터를 매개변수로 씁니다.


복사 방지 패턴

패턴 1: 복사 생성자 delete

class Animal {
public:
    Animal() = default;
    Animal(const Animal&) = delete;  // 복사 금지
    Animal& operator=(const Animal&) = delete;
    
    virtual ~Animal() = default;
    virtual void speak() = 0;
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Bark\n";
    }
};

int main() {
    Dog dog;
    // Animal animal = dog;  // 컴파일 에러
    Animal& ref = dog;  // OK
}

이 예제의 Animal은 speak()가 순수 가상 함수라 추상 클래스이므로, 사실 복사 생성자를 지우지 않아도 Animal animal = dog;는 “추상 클래스의 객체를 만들 수 없다”는 에러로 막힙니다. 함수 인자 void f(Animal a)나 std::vector<Animal>도 마찬가지입니다. 기반 클래스를 추상 클래스로 만드는 것만으로도 값 슬라이싱은 대부분 막힙니다. 복사 생성자 삭제가 추가로 의미 있는 경우는 기반 클래스가 구체 클래스일 때와, 아래에서 볼 부분 대입을 막고 싶을 때입니다.

다만 복사 생성자를 delete하면 부작용이 있습니다. 파생 클래스의 암시적 복사 생성자는 기반 클래스의 복사 생성자를 호출해야 하므로, Dog까지 복사할 수 없게 됩니다. Dog d2 = dog;는 use of deleted function 'Dog::Dog(const Dog&)' 에러가 납니다. 파생 클래스끼리의 복사는 허용하고 싶다면 패턴 2가 맞습니다.

패턴 2: protected 복사 생성자

class Animal {
protected:
    Animal(const Animal&) = default;  // 파생 클래스만 복사 가능
    
public:
    Animal() = default;
    virtual ~Animal() = default;
    virtual void speak() = 0;
};

int main() {
    Dog dog1;
    // Animal animal = dog1;  // 컴파일 에러
    Dog dog2 = dog1;  // OK (파생 클래스 복사)
}

슬라이싱에는 복사 생성뿐 아니라 부분 대입이라는 형태도 있습니다.

Dog a("Buddy"), b("Max");
Animal& ref = a;
ref = b;  // Animal::operator=가 호출되어 Animal 부분만 대입됨
// a의 name_은 여전히 "Buddy" -> 절반만 바뀐 객체

이 경우 a는 Animal 부분은 b의 값, Dog 부분은 원래 값인 혼합 상태가 됩니다. 복사 생성 슬라이싱은 적어도 일관된 Animal 객체를 만들지만, 부분 대입은 불변 조건이 깨진 객체를 만들 수 있어 더 위험합니다. 그래서 패턴 2를 쓸 때는 복사 생성자와 함께 복사 대입 연산자도 protected로 두는 것이 완전한 형태입니다(Animal& operator=(const Animal&) = default;를 protected 영역에). 이동 생성자와 이동 대입도 같은 이유로 함께 고려해야 하며, 복사 연산을 직접 선언하면 이동 연산이 암시적으로 생성되지 않는다는 점도 알아 두면 좋습니다.

패턴 3: clone 메서드

class Animal {
public:
    virtual ~Animal() = default;
    virtual std::unique_ptr<Animal> clone() const = 0;
    virtual void speak() = 0;
};

class Dog : public Animal {
    std::string name_;
public:
    Dog(const std::string& name) : name_(name) {}
    
    std::unique_ptr<Animal> clone() const override {
        return std::make_unique<Dog>(*this);
    }
    
    void speak() override {
        std::cout << name_ << " barks\n";
    }
};

int main() {
    Dog dog("Buddy");
    std::unique_ptr<Animal> cloned = dog.clone();
    cloned->speak();  // Buddy barks
}

clone()은 “가상 복사 생성자” 역할을 합니다. C++에서 생성자는 가상일 수 없으므로, 기반 클래스 포인터만 가진 상태에서 실제 타입 그대로 복사하려면 각 클래스가 자기 타입을 아는 가상 함수를 제공해야 합니다. Dog::clone()은 *this를 Dog의 복사 생성자로 복사하므로 name_까지 온전히 복사됩니다.

이 패턴의 약점은 파생 클래스마다 clone()을 빠짐없이 오버라이드해야 한다는 것입니다. Dog를 상속한 Puppy가 clone()을 오버라이드하지 않으면, Puppy 객체의 clone()은 Dog::clone()이 호출되어 Dog로 잘린 복사본을 만듭니다. 슬라이싱을 막으려고 도입한 패턴 안에서 다시 슬라이싱이 일어나는 셈입니다. 이 실수는 컴파일러가 잡아 주지 못하므로, CRTP로 clone() 구현을 자동으로 붙이는 헬퍼 템플릿을 두거나 디버그 빌드에서 assert(typeid(*copy) == typeid(*this))로 검사하는 방법을 씁니다. 패턴 1과 함께 쓰면 std::make_unique<Dog>(*this)가 삭제된 복사 생성자를 호출하게 되므로, clone()과 조합할 때는 패턴 2의 protected 복사 생성자를 써야 합니다.


같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. catch (Base e)처럼 예외를 값으로 받으면 어떤 문제가 생기나요?

A. 예외를 값으로 받으면 던져진 파생 예외 객체가 Base 타입으로 복사되면서 슬라이싱이 일어나, 파생 클래스의 멤버와 오버라이드된 what() 같은 동작을 잃습니다. 이 상태에서 throw e;로 다시 던지면 원래 타입이 아닌 Base 예외가 전파되어 상위의 catch (Derived&)가 잡지 못합니다. 예외는 항상 catch (const Base& e)처럼 참조로 받고, 다시 던질 때는 원본을 그대로 전파하는 throw;를 쓰는 것이 원칙입니다.