C++ 객체 슬라이싱: 파생 부분이 잘려 나가는 네 가지 경우와 다형성 지키는 법

이 글의 핵심

파생 클래스 객체를 기반 클래스 값으로 전달·반환·저장할 때 파생 부분이 잘려 나가는 객체 슬라이싱이 왜 생기는지 메모리 레이아웃으로 설명합니다. 컨테이너에 값으로 담을 때 다형성이 사라지는 사례와 참조·포인터·스마트 포인터로 막는 법, 탐지 방법을 정리합니다.

Object Slicing이란?

파생 클래스 객체를 값(value)으로 기본 클래스에 넣을 때, 객체 메모리 상에서 “기본 클래스 부분만” 복사되고 Derived에만 있던 멤버·다형성 정보가 잘려 나가는 현상입니다. 이름 그대로 빵 한 덩어리를 도마로 “슬라이스”한 것과 비슷합니다.

메모리 관점에서 왜 일어나는가

Base b = d;에서 대입의 왼쪽 타입이 Base이므로, 컴파일러는 Base 크기만큼의 저장 공간만 준비합니다. 오른쪽 d는 Derived 전체를 담고 있어도, 대입 연산은 Base 서브객체만 복사합니다. 그 결과 Derived에만 있던 필드(아래 예에서는 y)는 대상 객체 b 안에 자리가 없어서 사라집니다.

struct Base {
    int x;   // Base 레이아웃의 일부
};

struct Derived : Base {
    int y;   // Derived가 Base 뒤에 “붙어 있는” 추가 멤버
};

Derived d;
d.x = 1;
d.y = 2;

Base b = d;  // b에는 x≈1만 의미 있게 복사되고, y는 잘림(슬라이싱)
// b의 타입이 Base이므로 b.y 같은 접근 자체가 불가능

가상 함수가 기대대로 안 도는 이유도 같은 계열입니다. 값으로 넘기면 실제 객체는 Base 크기의 복사본이 되어, Dog였던 정보가 잘리면 speak()도 기본 클래스 쪽 동작으로 고정되는 식입니다(아래 예시 1 참고).

여기서 흔히 오해하는 부분이 하나 있습니다. 슬라이싱은 “vptr이 잘려서 사라지는” 현상이 아닙니다. Base b = d;는 Base의 복사 생성자를 호출해 새로운 Base 객체를 만드는 일이고, 이 새 객체의 vptr은 생성 과정에서 Base의 가상 함수 테이블을 가리키도록 초기화됩니다. 복사 생성자는 멤버 값만 옮길 뿐 vptr을 원본에서 가져오지 않습니다. 즉 b는 처음부터 끝까지 온전한 Base 객체이고, 그래서 b.speak()가 Base::speak를 부르는 것은 언어 규칙상 완전히 정상적인 동작입니다. 슬라이싱이 버그인 이유는 컴파일러가 잘못해서가 아니라, 프로그래머가 “타입이 보존된 사본”을 기대했는데 언어는 “타입이 바뀐 새 객체”를 만들었기 때문입니다.

auto와 범위 기반 for문에서도 같은 일이 일어납니다. Base& ref = derived; auto copy = ref;에서 auto는 참조를 벗겨 Base로 추론되므로 copy는 슬라이스된 사본입니다. 참조의 정적 타입이 Base&이기 때문에, auto는 동적 타입이 무엇이든 Base를 고릅니다.

발생 원인

객체 슬라이싱은 겉으로 다르게 보이는 여러 상황에서 사실 동일한 근본 원인, 즉 “파생 클래스 객체가 값으로 기본 클래스 타입 변수에 담긴다”는 조건에서 발생합니다. 함수 매개변수를 값으로 받으면 호출 시점에 인자가 매개변수 타입(Base)으로 복사되며 슬라이싱되고, 함수가 파생 클래스 객체를 기본 클래스 타입으로 반환해도 반환값을 만드는 과정에서 똑같이 잘려나갑니다. std::vector<Base>처럼 기본 클래스를 값으로 저장하는 컨테이너에 파생 클래스 객체를 넣는 경우도 컨테이너 내부적으로 Base 타입의 저장 공간에 값을 복사하는 것이므로 동일한 문제가 발생합니다. 이 세 가지 패턴을 기억해 두면, 코드를 보다가 슬라이싱이 의심되는 지점을 훨씬 빠르게 짚어낼 수 있습니다.

// 1. 값으로 전달
void func(Base b) {  // 슬라이싱
    // ...
}
Derived d;
func(d);

// 2. 값으로 반환
Base func() {
    Derived d;
    return d;  // 슬라이싱
}

// 3. 컨테이너
std::vector<Base> vec;
Derived d;
vec.push_back(d);  // 슬라이싱

실전 예시

이론적인 설명만으로는 슬라이싱이 실제로 프로그램 동작을 어떻게 망가뜨리는지 체감하기 어렵습니다. 아래 예시들은 슬라이싱이 실제로 다형성을 무력화하는 문제 상황과, 이를 레퍼런스·포인터·스마트 포인터로 해결하는 방법을 순서대로 비교합니다.

예시 1: 문제 상황

아래 코드는 Animal을 상속한 Dog가 speak()를 오버라이드하고 있지만, makeSpeak 함수가 매개변수를 Animal a처럼 값으로 받고 있어서 다형성이 완전히 무력화되는 상황을 보여줍니다. makeSpeak(d)를 호출하는 순간 Animal의 복사 생성자가 d의 Animal 부분만 가지고 매개변수 a를 새로 만들고, a는 순수하게 Animal 타입의 객체가 됩니다(vptr도 Animal의 테이블을 가리키도록 새로 설정됩니다). 그 결과 a.speak()는 항상 Animal::speak로 가서 “Animal”만 출력하게 되며, 컴파일러는 경고 없이 이 코드를 받아들입니다. 이는 이는 다형성을 기대하고 작성한 코드가 조용히 잘못된 결과를 내는 전형적인 사례입니다.

class Animal {
public:
    virtual void speak() {
        std::cout << "Animal" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Woof!" << std::endl;
    }
};

void makeSpeak(Animal a) {  // 값으로 전달
    a.speak();  // 항상 "Animal" 출력
}

int main() {
    Dog d;
    makeSpeak(d);  // "Animal" (슬라이싱)
}

예시 2: 올바른 해결

슬라이싱을 막는 핵심 원리는 아주 단순한데, 객체를 복사하지 않고 원본을 가리키기만 하면 됩니다. Animal& a처럼 참조로 받거나 Animal* a처럼 포인터로 받으면 함수 안의 a는 여전히 원래의 Dog 객체를 가리키고 있으므로 가상 함수 테이블도 그대로 유지되어, a.speak()를 호출하면 런타임에 실제 객체 타입인 Dog::speak가 정상적으로 호출됩니다. 이 두 방식 모두 객체를 전혀 복사하지 않으므로 앞서 본 슬라이싱 문제가 원천적으로 발생할 수 없으며, 다형성이 필요한 인터페이스를 설계할 때는 매개변수를 값으로 받지 않고 참조나 포인터로 받는 것이 원칙이 되어야 합니다.

// ✅ 레퍼런스 사용
void makeSpeak(Animal& a) {
    a.speak();  // 다형성 작동
}

// ✅ 포인터 사용
void makeSpeak(Animal* a) {
    a->speak();  // 다형성 작동
}

int main() {
    Dog d;
    makeSpeak(d);  // "Woof!"
    makeSpeak(&d); // "Woof!"
}

예시 3: 컨테이너

컨테이너에서 슬라이싱은 함수 매개변수보다 더 알아채기 어려운데, push_back을 호출하는 시점에는 코드가 겉보기에 전혀 문제없어 보이기 때문입니다. 아래 첫 번째 예시처럼 std::vector<Animal>에 Dog 객체를 push_back하면, 벡터 내부적으로 Animal 크기만큼의 공간에 객체를 복사해 저장하므로 이 시점에 슬라이싱이 발생하고, 이후 animals[0].speak()는 항상 Animal의 동작만 호출합니다. 이를 해결하려면 컨테이너 자체가 다형 객체를 가리키는 포인터나 스마트 포인터를 저장하도록 바꿔야 하며, 특히 std::vector<std::unique_ptr<Animal>>처럼 스마트 포인터 컨테이너를 사용하면 다형성을 지키면서도 각 객체의 소유권과 자동 해제까지 안전하게 관리할 수 있습니다.

// ❌ 값 컨테이너
std::vector<Animal> animals;
Dog d;
animals.push_back(d);  // 슬라이싱
animals[0].speak();    // "Animal"

// ✅ 포인터 컨테이너 (단, delete를 직접 해야 하므로 누수 위험)
std::vector<Animal*> animals;
Dog* d = new Dog();
animals.push_back(d);
animals[0]->speak();   // "Woof!"

// ✅ 스마트 포인터
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>());
animals[0]->speak();   // "Woof!"

원시 포인터 컨테이너는 다형성은 지키지만 누가 delete를 할지가 코드 어디에도 드러나지 않습니다. 벡터가 소멸될 때 원소(포인터)만 사라지고 가리키던 Dog는 남기 때문에, 루프를 돌며 직접 지우는 코드를 빠뜨리면 그대로 누수가 됩니다. unique_ptr 컨테이너는 이 책임을 타입에 새겨 넣는다는 점이 핵심 차이입니다. 대신 unique_ptr는 복사가 안 되므로 std::vector<std::unique_ptr<Animal>> 자체를 복사하려 하면 use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)' 같은 에러가 납니다. 컨테이너를 깊은 복사해야 한다면 아래에서 다룰 clone() 패턴이 필요합니다. 그리고 Animal에 가상 소멸자가 없으면 unique_ptr<Animal>이 Dog를 지울 때 Dog의 소멸자가 불리지 않으므로, 다형 기본 클래스에는 virtual ~Animal() = default;를 반드시 넣어야 합니다.

예시 4: 복사 방지

가장 근본적인 대책은 슬라이싱이 아예 컴파일조차 되지 않도록 언어 차원에서 막아버리는 것입니다. 아래 Base 클래스는 복사 생성자와 복사 대입 연산자를 = delete로 명시적으로 삭제해, 값으로 복사하려는 모든 시도(함수 인자 전달, 반환, 컨테이너 저장 포함)를 컴파일 에러로 만들어 버립니다. 대신 이동 연산은 = default로 허용해 자원 소유권을 넘기는 것은 그대로 가능하게 열어두었으며, 생성자를 protected로 두어 Base 자체를 직접 인스턴스화하지 못하게 해 오직 파생 클래스를 통해서만 사용하도록 강제하고 있습니다. 이렇게 클래스 설계 단계에서 복사를 막아두면, 나중에 실수로 슬라이싱을 유발하는 코드를 작성하더라도 런타임 버그가 아니라 즉시 컴파일 에러로 드러나 훨씬 안전합니다.

class Base {
public:
    virtual ~Base() = default;
    
    // 복사 방지
    Base(const Base&) = delete;
    Base& operator=(const Base&) = delete;
    
    // 이동은 허용
    Base(Base&&) = default;
    Base& operator=(Base&&) = default;
    
protected:
    Base() = default;
};

그런데 위 코드에는 함정이 하나 남아 있습니다. 이동 생성자를 public으로 열어 두었기 때문에 Base b = std::move(derived);나 vec.push_back(Derived());(임시 객체라 이동 생성자가 선택됨)는 여전히 컴파일되고 여전히 슬라이싱됩니다. 이동도 결국 “Base 타입의 새 객체를 만든다”는 점에서 복사와 다를 게 없기 때문입니다. 제가 이 패턴을 처음 적용했을 때도 복사만 막으면 끝이라고 생각했다가, 임시 객체를 넘기는 코드가 조용히 통과하는 것을 보고서야 이동도 같은 문제라는 걸 깨달았습니다.

C++ Core Guidelines(C.67)가 권하는 형태는 다형 기본 클래스의 복사·이동을 public에서 제거하고, 필요하면 protected로만 두는 것입니다. 파생 클래스는 자기 복사 생성자 안에서 Base의 복사를 쓸 수 있지만, 외부 코드는 Base 값을 만들 수 없게 됩니다. 사본이 필요하다면 가상 clone()으로 “동적 타입을 보존하는 복사”를 명시적으로 제공합니다.

class Shape {
public:
    virtual ~Shape() = default;
    virtual std::unique_ptr<Shape> clone() const = 0;
    virtual double area() const = 0;
protected:
    Shape() = default;
    Shape(const Shape&) = default;             // 파생 클래스만 사용 가능
    Shape& operator=(const Shape&) = default;
};

class Circle : public Shape {
public:
    explicit Circle(double r) : r_(r) {}
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Circle>(*this); // Circle 전체를 복사
    }
    double area() const override { return 3.14159 * r_ * r_; }
private:
    double r_;
};

// Shape s = circle;  // 에러: 'Shape::Shape(const Shape&)' is protected

이렇게 하면 void f(Shape s) 같은 시그니처 자체가 호출 지점에서 컴파일 에러가 되므로, 슬라이싱이 발생할 수 있는 모든 경로가 빌드 단계에서 막힙니다.

자주 발생하는 문제

앞서 살펴본 원리를 알고 있어도, 실제 코드베이스에서는 아래와 같은 네 가지 패턴으로 슬라이싱이 반복적으로 등장합니다. 코드 리뷰에서 이 패턴들을 의식적으로 찾아보는 것만으로도 상당수의 슬라이싱 버그를 사전에 예방할 수 있습니다.

문제 1: 값 전달

다형 타입을 다루는 함수의 매개변수를 값으로 선언하는 것은 슬라이싱의 가장 흔한 원인입니다. 아래 process(Base obj)처럼 값으로 받으면 어떤 파생 클래스 객체를 넘기더라도 함수 내부의 obj는 항상 순수한 Base 타입이 되어 virtualFunc()는 결코 파생 클래스의 오버라이드된 버전을 호출하지 못합니다. 해결책은 매개변수를 const Base& obj처럼 참조로 바꾸는 것이며, 이렇게 하면 불필요한 복사 비용도 함께 없앨 수 있어 성능과 정확성 두 마리 토끼를 잡을 수 있습니다.

// ❌ 값 전달
void process(Base obj) {
    obj.virtualFunc();  // 슬라이싱
}

// ✅ 레퍼런스 전달
void process(const Base& obj) {
    obj.virtualFunc();  // 다형성
}

문제 2: 값 반환

값 전달만큼이나 흔한 것이 함수가 기본 클래스 타입으로 값을 반환하는 경우입니다. 아래 create() 함수는 반환 타입이 Base로 선언되어 있어서, 함수 내부에서 Derived()를 만들어 반환하더라도 반환값이 Base 타입으로 슬라이싱되어 호출자는 절대 Derived의 정보를 얻을 수 없습니다. 이 문제는 팩토리 함수를 작성할 때 특히 자주 나타나는데, 반환 타입을 std::unique_ptr<Base>처럼 다형 포인터로 바꾸고 내부에서 std::make_unique<Derived>()로 실제 파생 객체를 힙에 생성해 반환하면 다형성을 온전히 유지한 채로 객체를 넘겨줄 수 있습니다.

// ❌ 값 반환
Base create() {
    return Derived();  // 슬라이싱
}

// ✅ 포인터 반환
std::unique_ptr<Base> create() {
    return std::make_unique<Derived>();
}

문제 3: 대입 연산자

b = d;처럼 이미 존재하는 두 객체 사이에서 대입 연산자를 통해 슬라이싱이 발생하는 경우는 함수 호출이 전혀 없어서 특히 놓치기 쉽습니다. b가 Base 타입이고 d가 Derived 타입이라면, 이 대입은 Base의 대입 연산자를 호출해 d의 Base 부분만 b로 복사하고 Derived만의 멤버는 조용히 버려집니다. 이런 대입 자체를 막고 싶다면 Base의 포인터(Base* b = &d;)를 사용해 원본 객체를 가리키기만 하도록 바꾸거나, 앞서 예시 4에서 본 것처럼 Base의 복사 대입 연산자 자체를 = delete로 막아 이런 대입이 컴파일조차 되지 않도록 원천 차단하는 것이 안전합니다.

Derived d;
Base b;
b = d;  // 슬라이싱

// ✅ 포인터 사용
Base* b = &d;  // 다형성 유지

대입에는 더 교묘한 변형도 있습니다. 참조를 통한 부분 대입(partial assignment)입니다. Derived d1, d2; Base& r = d1; r = d2;는 r이 d1을 가리키므로 괜찮아 보이지만, operator=는 가상이 아니어서 Base::operator=만 호출됩니다. 결과적으로 d1의 Base 부분은 d2의 값으로 바뀌고 Derived 부분은 예전 값 그대로 남아, 어느 쪽과도 같지 않은 “반쯤 섞인” 객체가 됩니다. 이 경우는 새 객체가 만들어지지 않으니 엄밀히는 슬라이싱과 다르지만 원인과 해법(기본 클래스의 복사 대입을 protected로 숨기기)은 같습니다.

문제 4: 컨테이너 저장

컨테이너에 파생 클래스 객체를 직접 값으로 저장하는 것은 앞서 다룬 예시 3의 패턴이 실제 프로덕션 코드에서 등장할 때 가장 흔한 형태입니다. 아래 std::vector<Base>에 Derived()를 push_back하면 벡터 내부의 저장 공간이 처음부터 Base 크기로 고정되어 있어서 슬라이싱을 피할 방법이 없으며, 이는 벡터를 선언하는 순간부터 이미 결정되어 있는 문제입니다. 반드시 다형성을 유지하며 컨테이너에 저장해야 한다면 std::vector<std::unique_ptr<Base>>처럼 스마트 포인터를 담는 컨테이너로 설계를 바꿔야 하며, 이렇게 하면 각 원소가 실제로는 서로 다른 파생 클래스일 수 있으면서도 소유권과 메모리 해제까지 안전하게 관리됩니다.

// ❌ 값 저장
std::vector<Base> vec;
vec.push_back(Derived());  // 슬라이싱

// ✅ 스마트 포인터
std::vector<std::unique_ptr<Base>> vec;
vec.push_back(std::make_unique<Derived>());

문제 5: 예외를 값으로 잡기

실무에서 가장 자주 보는 슬라이싱은 사실 예외 처리 코드에 있습니다. catch (std::exception e)처럼 값으로 잡으면 던져진 std::runtime_error나 직접 만든 예외 타입이 std::exception으로 잘려, e.what()이 원래 메시지 대신 구현이 정한 일반 문자열(libstdc++에서는 "std::exception")을 돌려줍니다. 로그에 에러 원인이 하나도 안 남는데 코드상으로는 문제가 안 보여서 한참 헤매게 되는 전형적인 경우입니다. GCC 8 이상은 -Wall에 포함된 -Wcatch-value로 catching polymorphic type 'class std::exception' by value 경고를 내주므로, 이 경고가 보이면 catch (const std::exception& e)로 바꾸면 됩니다.

try {
    throw std::runtime_error("config file not found");
} catch (std::exception e) {          // ❌ 슬라이싱: what()이 원래 메시지를 잃음
    std::cerr << e.what() << '\n';
}

try {
    throw std::runtime_error("config file not found");
} catch (const std::exception& e) {   // ✅ "config file not found"
    std::cerr << e.what() << '\n';
}

탐지 방법

슬라이싱은 컴파일 에러도 런타임 크래시도 아니고 그저 조용히 잘못된 결과를 내는 경우가 많아서, 사전에 탐지할 수 있는 도구와 습관을 갖춰두는 것이 중요합니다. 흔히 GCC의 -Weffc++를 추천하는 글이 있지만, 이 옵션은 “다형 기본 클래스에 가상 소멸자가 없음”, “operator=가 *this를 반환하지 않음” 같은 Effective C++ 초판 항목을 검사할 뿐 슬라이싱 자체를 잡아 주지는 않고, 오탐이 많아 실무에서는 거의 쓰지 않습니다. 실제로 도움이 되는 도구는 다음과 같습니다.

  • clang-tidy cppcoreguidelines-slicing: 가상 함수가 있는 타입을 값으로 복사하거나, 파생 클래스에서 추가된 멤버가 잘려 나가는 대입·초기화를 찾아 경고합니다. CI에서 돌리기 좋습니다.
  • GCC/Clang -Wall: 앞서 본 예외의 값 잡기(-Wcatch-value)를 잡아 줍니다.
  • 설계로 막기: 앞서 본 것처럼 다형 기본 클래스의 복사·이동을 protected로 두면, 도구 없이도 컴파일러가 모든 슬라이싱 경로를 에러로 만듭니다.
# clang-tidy로 슬라이싱 검사
clang-tidy program.cpp -checks='-*,cppcoreguidelines-slicing' -- -std=c++17

# 예외 값 잡기 경고 (GCC 8+, -Wall에 포함)
g++ -std=c++17 -Wall program.cpp

슬라이싱이 의도적인 경우

모든 슬라이싱이 버그는 아닙니다. 가상 함수가 없는 단순 데이터 구조에서 공통 부분만 뽑아 내는 경우, 예를 들어 struct Point3D : Point2D에서 2D 좌표만 필요한 함수에 Point2D로 넘기는 경우는 의도대로 동작합니다. 판단 기준은 간단합니다. 기본 클래스에 가상 함수가 하나라도 있다면(=다형 타입이라면) 값 복사는 거의 항상 실수이고, 가상 함수가 없는 값 타입이라면 슬라이싱은 단순한 변환일 수 있습니다. clang-tidy 검사가 가상 함수 유무를 기준으로 경고하는 것도 같은 이유입니다.

FAQ

Q1: 복사 생성자만 = delete하면 슬라이싱이 완전히 막히나요?

A: 아닙니다. 이동 생성자가 public으로 남아 있으면 std::move나 임시 객체를 통한 슬라이싱은 그대로 컴파일됩니다. 복사와 이동을 모두 protected로 두고, 사본이 필요하면 가상 clone()을 제공하는 것이 확실합니다.

Q2: 참조로 받으면 성능 비용이 있나요?

A: 참조 전달은 주소만 넘기므로 값 복사보다 오히려 저렴합니다. 가상 호출 한 번의 간접 비용은 있지만, 다형성이 필요한 코드라면 어차피 피할 수 없는 비용입니다.

Q3: std::variant로 슬라이싱을 피할 수도 있나요?

A: 가능한 타입 목록이 닫혀 있다면(예: Circle, Rect, Triangle 셋뿐) std::vector<std::variant<Circle, Rect, Triangle>>로 값 의미를 유지하면서 힙 할당과 슬라이싱을 모두 피할 수 있습니다. 새 파생 타입이 계속 추가되는 플러그인 구조라면 상속과 스마트 포인터가 더 맞습니다.

Q4: 예외는 왜 참조로 잡아야 하나요?

A: 값으로 잡으면 파생 예외가 catch 절의 타입으로 슬라이싱되어 what() 메시지와 추가 정보가 사라집니다. catch (const std::exception& e)가 표준 관례입니다.


같이 보면 좋은 글