본문으로 건너뛰기 C++ Object Slicing | '객체 슬라이싱' 가이드

C++ Object Slicing | '객체 슬라이싱' 가이드

C++ Object Slicing | '객체 슬라이싱' 가이드

이 글의 핵심

C++ Object Slicing - "객체 슬라이싱" 가이드. C++ Object Slicing의 Object Slicing이란?, 발생 원인, 실전 예시를 실전 코드와 함께 설명합니다.

Object Slicing이란?

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

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

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

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

class Derived : public 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 참고).

발생 원인

객체 슬라이싱은 겉으로 다르게 보이는 여러 상황에서 사실 동일한 근본 원인, 즉 “파생 클래스 객체가 값으로 기본 클래스 타입 변수에 담긴다”는 조건에서 발생합니다. 함수 매개변수를 값으로 받으면 호출 시점에 인자가 매개변수 타입(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을 상속한 Dogspeak()를 오버라이드하고 있지만, makeSpeak 함수가 매개변수를 Animal a처럼 값으로 받고 있어서 다형성이 완전히 무력화되는 상황을 보여줍니다. makeSpeak(d)를 호출하는 순간 Dog 객체 dAnimal 타입의 매개변수 a로 복사되면서 Dog만의 정보(가상 함수 테이블 포인터 포함)가 잘려나가고, a는 순수하게 Animal 타입의 객체가 되어 버립니다. 그 결과 a.speak()는 컴파일 시점에 Dog::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"

// ✅ 포인터 컨테이너
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!"

예시 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;
};

자주 발생하는 문제

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

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

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

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

문제 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>());

탐지 방법

슬라이싱은 컴파일 에러도 런타임 크래시도 아니고 그저 조용히 잘못된 결과를 내는 경우가 많아서, 사전에 탐지할 수 있는 도구와 습관을 갖춰두는 것이 중요합니다. GCC의 -Weffc++ 플래그는 Scott Meyers의 Effective C++ 권장 사항을 기준으로 슬라이싱을 유발할 수 있는 패턴에 경고를 내주므로, 빌드 파이프라인에 추가해 두면 코드 리뷰 전에 잠재적 문제를 미리 걸러낼 수 있습니다. 더 근본적인 예방책은 앞서 예시 4에서 본 것처럼 다형 기본 클래스의 복사 생성자를 아예 = delete로 선언해 두는 것으로, 이렇게 하면 슬라이싱을 유발하는 모든 코드가 런타임이 아니라 컴파일 타임에 즉시 드러나 문제를 훨씬 빨리, 훨씬 저렴하게 고칠 수 있습니다.

// 컴파일러 경고
g++ -Weffc++ program.cpp

// 복사 생성자 삭제
class Base {
public:
    Base() = default;
    Base(const Base&) = delete;  // 슬라이싱 방지
};

FAQ

Q1: Object Slicing은 언제?

A: 파생 클래스를 기본 클래스 값으로 복사 시.

Q2: 해결 방법은?

A:

  • 레퍼런스 사용
  • 포인터 사용
  • 스마트 포인터

Q3: 탐지는?

A:

  • 컴파일러 경고
  • 복사 생성자 삭제

Q4: 성능 영향?

A: 레퍼런스/포인터는 영향 없음.

Q5: 컨테이너는?

A: 스마트 포인터 컨테이너 사용.

Q6: Object Slicing 학습 리소스는?

A:

  • “Effective C++”
  • “C++ Primer”
  • “More Effective C++“

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, object-slicing, polymorphism, inheritance, 다형성 등으로 검색하시면 이 글이 도움이 됩니다.