C++ 상속과 다형성: virtual 함수, 추상 클래스, 가상 소멸자 누락과 슬라이싱
이 글의 핵심
C++ 상속의 기본과 virtual 함수로 다형성을 얻는 방법, 추상 클래스 설계, 가상 소멸자 누락이나 객체 슬라이싱처럼 실무에서 자주 겪는 문제를 게임 캐릭터·결제 시스템 예제로 정리합니다.
상속과 다형성이란?
상속(Inheritance)은 기존 클래스의 멤버와 동작을 물려받아 재사용하는 기법이고, 다형성(Polymorphism)은 같은 인터페이스로 서로 다른 타입을 다루는 능력입니다. 공통 기능을 기본 클래스에 한 번만 두면 중복이 사라지고, 호출하는 코드가 기본 클래스 인터페이스만 알면 새 파생 타입을 추가해도 그 코드를 고칠 필요가 없습니다. 어떤 구현이 실행될지는 런타임에 실제 객체 타입에 따라 정해집니다.
// ❌ 상속 없이: 중복 코드
class Dog {
string name;
public:
void eat() { cout << name << " 먹습니다\n"; }
void bark() { cout << "멍멍\n"; }
};
class Cat {
string name;
public:
void eat() { cout << name << " 먹습니다\n"; } // 중복
void meow() { cout << "야옹\n"; }
};
// ✅ 상속 사용: 재사용
class Animal {
protected:
string name;
public:
Animal(string n) : name(n) {}
void eat() { cout << name << " 먹습니다\n"; }
};
class Dog : public Animal {
public:
Dog(string n) : Animal(n) {}
void bark() { cout << "멍멍\n"; }
};
상속 구조:
flowchart TD
Animal["Animal (기본 클래스)"]
Dog["Dog (파생 클래스)"]
Cat["Cat (파생 클래스)"]
Animal --> Dog
Animal --> Cat
Animal -.-> |eat| A1["eat()"]
Dog -.-> |bark| D1["bark()"]
Cat -.-> |meow| C1["meow()"]
기본 상속
// 타입 정의
class Animal {
protected:
string name;
public:
Animal(string n) : name(n) {}
void eat() {
cout << name << "이(가) 먹습니다" << endl;
}
};
class Dog : public Animal {
public:
Dog(string n) : Animal(n) {}
void bark() {
cout << name << "이(가) 짖습니다: 멍멍!" << endl;
}
};
int main() {
Dog dog("바둑이");
dog.eat(); // 상속받은 메서드
dog.bark(); // Dog만의 메서드
}
여기서 눈여겨볼 부분은 Dog(string n) : Animal(n) {}입니다. 파생 클래스 생성자는 멤버 초기화 리스트에서 기본 클래스 생성자를 명시적으로 호출해야 하고, 생략하면 컴파일러가 Animal() 기본 생성자를 찾습니다. Animal에는 기본 생성자가 없으므로 이때 “no matching function for call to ‘Animal::Animal()’” 같은 에러가 납니다. 생성 순서는 항상 기본 클래스 → 파생 클래스 멤버 → 파생 클래스 생성자 본문이고, 소멸은 정확히 그 역순입니다. 그래서 기본 클래스 생성자 안에서는 파생 클래스 멤버가 아직 만들어지지 않은 상태라는 점을 기억해 두어야 합니다. 같은 이유로 기본 클래스 생성자·소멸자 안에서 가상 함수를 호출하면 파생 클래스의 재정의가 아니라 기본 클래스 버전이 실행됩니다.
name을 protected로 둔 것은 편의를 위한 선택입니다. 파생 클래스가 직접 멤버에 접근할 수 있어 코드가 짧아지지만, 그만큼 기본 클래스의 내부 표현이 모든 파생 클래스에 노출되어 나중에 name의 타입이나 의미를 바꾸기 어려워집니다. 파생 클래스가 많아질 것 같다면 멤버는 private로 두고 protected 접근자 함수만 열어 두는 편이 유지보수에 유리합니다.
접근 지정자:
| 상속 방식 | public 멤버 | protected 멤버 | private 멤버 |
|---|---|---|---|
public | public | protected | 접근 불가 |
protected | protected | protected | 접근 불가 |
private | private | private | 접근 불가 |
class Base {
public:
int pub;
protected:
int prot;
private:
int priv;
};
class Derived : public Base {
void func() {
pub = 1; // OK: public
prot = 2; // OK: protected
// priv = 3; // 에러: private
}
};
virtual 함수와 다형성
class Animal {
public:
virtual ~Animal() = default; // Animal*로 delete하므로 필요
virtual void speak() {
cout << "동물 소리" << endl;
}
};
class Dog : public Animal {
public:
void speak() override {
cout << "멍멍!" << endl;
}
};
class Cat : public Animal {
public:
void speak() override {
cout << "야옹!" << endl;
}
};
int main() {
Animal* animals[3];
animals[0] = new Animal();
animals[1] = new Dog();
animals[2] = new Cat();
for (int i = 0; i < 3; i++) {
animals[i]->speak(); // 각자의 speak 호출
}
// 메모리 해제
for (int i = 0; i < 3; i++) {
delete animals[i];
}
}
animals[i]->speak()가 각 객체의 실제 타입에 맞는 함수를 부르는 원리는 가상 함수 테이블(vtable)입니다. 대부분의 컴파일러는 가상 함수가 하나라도 있는 클래스의 객체에 숨은 포인터(vptr)를 하나 넣고, 그 포인터가 해당 클래스의 함수 주소 표를 가리키게 합니다. 호출 시점에는 “vptr을 읽고 → 표에서 speak 슬롯을 찾아 → 그 주소로 점프”하므로, 정적 타입이 Animal*이어도 실제 객체가 Dog이면 Dog::speak가 실행됩니다. 표준이 vtable을 강제하지는 않지만 GCC, Clang, MSVC 모두 이 방식을 씁니다.
이 예제의 Animal에 가상 소멸자를 넣은 것도 같은 이유입니다. delete animals[i]는 Animal*을 통해 Dog와 Cat을 지우므로, 소멸자가 virtual이 아니면 표준상 정의되지 않은 동작입니다. 실무 코드라면 Animal* animals[3]과 수동 delete 대신 std::vector<std::unique_ptr<Animal>>을 쓰는 것이 좋습니다. 예외가 중간에 던져져도 누수가 없고, 해제 루프를 빠뜨릴 일도 없습니다.
추상 클래스 (순수 가상 함수)
class Shape {
public:
virtual double area() = 0; // 순수 가상 함수
virtual double perimeter() = 0;
virtual ~Shape() {} // 가상 소멸자
};
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() override {
return 3.14159 * radius * radius;
}
double perimeter() override {
return 2 * 3.14159 * radius;
}
};
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() override {
return width * height;
}
double perimeter() override {
return 2 * (width + height);
}
};
int main() {
Shape* shapes[2];
shapes[0] = new Circle(5.0);
shapes[1] = new Rectangle(4.0, 6.0);
for (int i = 0; i < 2; i++) {
cout << "넓이: " << shapes[i]->area() << endl;
delete shapes[i];
}
}
Shape처럼 순수 가상 함수가 하나라도 있으면 그 클래스는 추상 클래스가 되어 Shape s;처럼 직접 인스턴스를 만들 수 없습니다. 시도하면 “cannot declare variable ‘s’ to be of abstract type ‘Shape’“(GCC) 같은 에러가 나고, 파생 클래스가 순수 가상 함수 중 하나라도 구현하지 않으면 그 파생 클래스 역시 추상으로 남아 같은 에러를 냅니다. 이 성질 덕분에 “모든 도형은 넓이와 둘레를 제공해야 한다”는 규칙을 문서가 아니라 컴파일러가 지키게 됩니다. 참고로 area()와 perimeter()는 객체 상태를 바꾸지 않으므로 실무에서는 virtual double area() const = 0;처럼 const를 붙이는 것이 맞습니다. 이때 파생 클래스에서도 const를 똑같이 붙여야 재정의로 인정되는데, 이 부분이 아래 “override 누락” 문제와 직결됩니다.
실전 예시
예시 1: 게임 캐릭터 시스템
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Character {
protected:
string name;
int hp;
int attackPower;
public:
Character(string n, int h, int ap)
: name(n), hp(h), attackPower(ap) {}
virtual ~Character() {}
virtual void attack(Character* target) {
cout << name << "의 공격!" << endl;
target->takeDamage(attackPower);
}
virtual void takeDamage(int damage) {
hp -= damage;
cout << name << "이(가) " << damage << " 데미지를 받았습니다. (HP: " << hp << ")" << endl;
}
virtual void useSkill() = 0; // 순수 가상 함수
bool isAlive() { return hp > 0; }
string getName() { return name; }
};
class Warrior : public Character {
public:
Warrior(string n) : Character(n, 150, 30) {}
void useSkill() override {
cout << name << "이(가) 강타를 사용합니다!" << endl;
attackPower += 20;
}
};
class Mage : public Character {
public:
Mage(string n) : Character(n, 80, 50) {}
void useSkill() override {
cout << name << "이(가) 파이어볼을 시전합니다!" << endl;
attackPower += 30;
}
};
class Healer : public Character {
public:
Healer(string n) : Character(n, 100, 15) {}
void useSkill() override {
cout << name << "이(가) 힐을 사용합니다!" << endl;
hp += 50;
cout << "HP 회복! (현재 HP: " << hp << ")" << endl;
}
};
int main() {
vector<Character*> party;
party.push_back(new Warrior("전사"));
party.push_back(new Mage("마법사"));
party.push_back(new Healer("힐러"));
for (auto& character : party) {
character->useSkill();
}
// 메모리 해제
for (auto& character : party) {
delete character;
}
return 0;
}
설명: 다형성을 활용하여 다양한 캐릭터 타입을 하나의 컨테이너로 관리합니다. useSkill()을 순수 가상 함수로 선언한 것이 핵심인데, 이렇게 하면 새 캐릭터 클래스(예: Archer)를 추가할 때 useSkill() 구현을 빠뜨리는 순간 컴파일 에러가 나서, “구현을 깜빡한 채 배포되는” 실수를 컴파일 타임에 원천 차단합니다. vector<Character*>로 서로 다른 타입을 하나의 컨테이너에 담을 수 있는 것도 다형성 덕분이며, for 루프가 각 원소의 실제 타입을 몰라도 되는 것이 이 패턴의 핵심 가치입니다.
예시 2: 결제 시스템
#include <iostream>
#include <string>
using namespace std;
class PaymentMethod {
public:
virtual bool pay(double amount) = 0;
virtual string getMethodName() = 0;
virtual ~PaymentMethod() {}
};
class CreditCard : public PaymentMethod {
private:
string cardNumber;
public:
CreditCard(string num) : cardNumber(num) {}
bool pay(double amount) override {
cout << "신용카드 결제: " << amount << "원" << endl;
cout << "카드번호: " << cardNumber << endl;
return true;
}
string getMethodName() override {
return "신용카드";
}
};
class BankTransfer : public PaymentMethod {
private:
string accountNumber;
public:
BankTransfer(string acc) : accountNumber(acc) {}
bool pay(double amount) override {
cout << "계좌이체: " << amount << "원" << endl;
cout << "계좌번호: " << accountNumber << endl;
return true;
}
string getMethodName() override {
return "계좌이체";
}
};
class PaymentProcessor {
public:
void processPayment(PaymentMethod* method, double amount) {
cout << "\n=== 결제 처리 ===" << endl;
cout << "결제 수단: " << method->getMethodName() << endl;
if (method->pay(amount)) {
cout << "결제 완료!" << endl;
} else {
cout << "결제 실패!" << endl;
}
}
};
int main() {
PaymentProcessor processor;
CreditCard card("1234-5678-9012-3456");
processor.processPayment(&card, 50000);
BankTransfer transfer("123-456-789012");
processor.processPayment(&transfer, 30000);
return 0;
}
설명: 전략 패턴을 사용하여 다양한 결제 방법을 유연하게 처리합니다. PaymentProcessor::processPayment는 CreditCard인지 BankTransfer인지 전혀 알 필요가 없고, 오직 PaymentMethod 인터페이스(pay(), getMethodName())만 알면 됩니다. 이후 KakaoPay나 ApplePay 같은 결제 수단이 추가돼도 PaymentProcessor 코드는 한 줄도 바꿀 필요가 없다는 것이 이 설계의 실질적인 이득입니다 — 새 결제 수단은 PaymentMethod를 상속받는 새 클래스 하나만 추가하면 됩니다.
예시 3: 파일 포맷 변환기
#include <iostream>
#include <string>
using namespace std;
class Document {
protected:
string content;
public:
Document(string c) : content(c) {}
virtual ~Document() {}
virtual void save(const string& filename) = 0;
virtual string getFormat() = 0;
};
class PDFDocument : public Document {
public:
PDFDocument(string c) : Document(c) {}
void save(const string& filename) override {
cout << "PDF로 저장: " << filename << ".pdf" << endl;
cout << "내용: " << content << endl;
}
string getFormat() override {
return "PDF";
}
};
class WordDocument : public Document {
public:
WordDocument(string c) : Document(c) {}
void save(const string& filename) override {
cout << "Word로 저장: " << filename << ".docx" << endl;
cout << "내용: " << content << endl;
}
string getFormat() override {
return "Word";
}
};
class DocumentConverter {
public:
void convert(Document* doc, const string& filename) {
cout << "\n=== 문서 변환 ===" << endl;
cout << "포맷: " << doc->getFormat() << endl;
doc->save(filename);
}
};
int main() {
DocumentConverter converter;
PDFDocument pdf("PDF 문서 내용");
converter.convert(&pdf, "report");
WordDocument word("Word 문서 내용");
converter.convert(&word, "letter");
return 0;
}
설명: 다형성으로 다양한 문서 포맷을 통일된 인터페이스로 처리합니다. 앞의 두 예제와 같은 구조가 반복되는 것이 우연이 아닙니다 — “공통 인터페이스를 정의하고, 구체 구현은 각 파생 클래스에 맡기고, 클라이언트 코드는 인터페이스만 통해 다룬다”는 패턴이 게임 캐릭터·결제 수단·문서 포맷처럼 표면적으로 전혀 달라 보이는 문제에도 똑같이 적용된다는 것을 보여줍니다. 이 반복되는 뼈대를 알아보는 것이 다형성을 언제 써야 할지 판단하는 감각을 기르는 가장 빠른 방법입니다.
자주 발생하는 문제
문제 1: 가상 소멸자 누락
기본 클래스 소멸자가 virtual이 아닌데 기본 클래스 포인터로 파생 객체를 delete하면 정의되지 않은 동작입니다. 주요 컴파일러에서는 보통 기본 클래스 소멸자만 호출되어 파생 클래스가 가진 자원이 해제되지 않습니다.
// ❌ 위험한 코드
class Base {
public:
~Base() { cout << "Base 소멸" << endl; }
};
class Derived : public Base {
private:
int* data;
public:
Derived() { data = new int[100]; }
~Derived() {
delete[] data;
cout << "Derived 소멸" << endl;
}
};
Base* ptr = new Derived();
delete ptr; // UB: 실제로는 보통 ~Derived가 호출되지 않아 data가 누수
// ✅ 올바른 코드
class Base {
public:
virtual ~Base() { cout << "Base 소멸" << endl; }
};
문제 2: override 키워드 누락
파생 클래스에서 재정의하려던 함수의 시그니처가 기본 클래스와 조금이라도 다르면(매개변수 타입, const 여부 등), 재정의가 아니라 기본 클래스 함수를 가리는 새 함수가 만들어집니다. 컴파일은 정상적으로 되므로 기본 클래스 포인터로 호출했을 때에야 엉뚱한 버전이 실행되는 것을 알게 됩니다.
// ❌ 실수하기 쉬운 코드
class Base {
public:
virtual void func(int x) {}
};
class Derived : public Base {
public:
void func(double x) {} // 오버라이드 아님! 새 함수!
};
// ✅ override로 명시
class Derived : public Base {
public:
void func(double x) override {} // 컴파일 에러!
void func(int x) override {} // OK
};
문제 3: 슬라이싱 (Slicing)
파생 객체를 기본 클래스 타입의 값으로 복사하면 파생 클래스 부분이 잘려 나갑니다.
// ❌ 슬라이싱 발생
Dog dog("바둑이");
Animal animal = dog; // Dog 정보 손실!
animal.speak(); // Animal::speak 호출
// ✅ 포인터나 참조 사용
Dog dog("바둑이");
Animal* animal = &dog;
animal->speak(); // Dog::speak 호출
슬라이싱은 Animal animal = dog;에서 Animal의 복사 생성자가 호출되기 때문에 생깁니다. 복사 생성자는 Animal 부분만 복사하고, 새로 만들어진 객체의 vptr은 Animal의 vtable을 가리키므로 speak()도 Animal 버전이 됩니다. 컴파일러는 이를 경고하지 않는 경우가 많아서 코드 리뷰에서 놓치기 쉽습니다. 특히 void handle(Animal a)처럼 함수 매개변수를 값으로 받거나, std::vector<Animal>에 파생 객체를 push_back하는 경우가 흔한 발생 지점입니다. 다형적으로 쓰려는 기본 클래스라면 복사 생성자와 대입 연산자를 protected로 두거나 = delete해서 슬라이싱 자체를 컴파일 에러로 만드는 방법도 있습니다.
세 가지 중 가장 늦게 드러나는 것은 대개 가상 소멸자 누락입니다. 프로그램은 정상 동작하는 것처럼 보이고, 파생 클래스가 파일 핸들이나 소켓 같은 자원을 들고 있을 때에야 “파일이 닫히지 않는다”, “메모리가 조금씩 늘어난다” 같은 간접 증상으로 나타나기 때문입니다. GCC와 Clang은 -Wall(정확히는 -Wdelete-non-virtual-dtor)을 켜면 “deleting object of polymorphic class type ‘Base’ which has non-virtual destructor might cause undefined behavior” 경고를 내 주므로, 경고 옵션을 켜고 빌드하는 것만으로 상당수를 미리 잡을 수 있습니다.
실무 패턴
패턴 1: 템플릿 메서드
class DataProcessor {
public:
void process() {
loadData();
validateData();
transformData();
saveData();
}
virtual ~DataProcessor() = default;
protected:
virtual void loadData() = 0;
virtual void validateData() {} // 기본 구현
virtual void transformData() = 0;
virtual void saveData() = 0;
};
class CSVProcessor : public DataProcessor {
protected:
void loadData() override {
std::cout << "CSV 로드\n";
}
void transformData() override {
std::cout << "CSV 변환\n";
}
void saveData() override {
std::cout << "CSV 저장\n";
}
};
// 사용
CSVProcessor processor;
processor.process();
패턴 2: 인터페이스 분리
// 읽기 인터페이스
class IReadable {
public:
virtual std::string read() = 0;
virtual ~IReadable() = default;
};
// 쓰기 인터페이스
class IWritable {
public:
virtual void write(const std::string& data) = 0;
virtual ~IWritable() = default;
};
// 읽기/쓰기 모두 구현
class File : public IReadable, public IWritable {
public:
std::string read() override {
return "파일 내용";
}
void write(const std::string& data) override {
std::cout << "파일 쓰기: " << data << '\n';
}
};
// 읽기 전용
class ReadOnlyFile : public IReadable {
public:
std::string read() override {
return "읽기 전용 내용";
}
};
패턴 3: 팩토리 메서드
class Product {
public:
virtual void use() = 0;
virtual ~Product() = default;
};
class ConcreteProductA : public Product {
public:
void use() override {
std::cout << "Product A\n";
}
};
class ConcreteProductB : public Product {
public:
void use() override {
std::cout << "Product B\n";
}
};
class Creator {
public:
virtual std::unique_ptr<Product> createProduct() = 0;
virtual ~Creator() = default;
void operation() {
auto product = createProduct();
product->use();
}
};
class CreatorA : public Creator {
public:
std::unique_ptr<Product> createProduct() override {
return std::make_unique<ConcreteProductA>();
}
};
FAQ
Q1: virtual 함수는 느린가요?
A: 호출 한 번에 vptr과 vtable을 거치는 간접 참조가 추가됩니다. 이 비용 자체는 작지만, 컴파일러가 호출 대상을 컴파일 타임에 알 수 없어 인라인화를 못 한다는 점이 더 큽니다. 작은 함수를 빽빽한 루프에서 호출하는 경우가 아니라면 대부분 문제가 되지 않고, 클래스나 함수에 final을 붙이면 컴파일러가 가상 호출을 직접 호출로 바꿀(devirtualization) 수 있습니다.
// 간접 참조 1회 (vtable)
animal->speak(); // vtable 조회 → 함수 호출
Q2: 모든 함수를 virtual로 만들어야 하나요?
A: 아니요, 파생 클래스가 재정의해야 하는 함수만 virtual로 만드세요. 소멸자는 기본 클래스 포인터로 delete할 수 있는 다형적 기본 클래스라면 virtual이어야 하고, 상속을 의도하지 않은 클래스에까지 붙일 필요는 없습니다.
class Base {
public:
virtual ~Base() = default; // 다형적 기본 클래스이므로 virtual
virtual void func() {} // 오버라이드 필요 시만
void helper() {} // virtual 불필요
};
Q3: 다중 상속은 언제 사용하나요?
A: 구현을 가진 클래스 여러 개를 상속하는 것은 가능하면 피하고, 순수 가상 함수만 있는 인터페이스 클래스를 여러 개 상속하거나 컴포지션을 쓰는 편이 안전합니다.
// ✅ 인터페이스 다중 상속: OK
class IReadable { virtual std::string read() = 0; };
class IWritable { virtual void write(const std::string&) = 0; };
class File : public IReadable, public IWritable { };
// ❌ 구현 다중 상속: 복잡
class A { int x; };
class B { int y; };
class C : public A, public B { }; // 멤버 이름 충돌·레이아웃 복잡도 증가
// A와 B가 같은 기본 클래스를 공유하면 다이아몬드 문제로 이어짐
Q4: override vs final?
A: override는 이 함수가 기본 클래스의 가상 함수를 재정의한다는 의도를 밝히고, 컴파일러가 실제로 재정의인지 검사하게 합니다. final은 이 함수를 더 아래의 파생 클래스에서 재정의하지 못하게 막습니다(클래스에 붙이면 상속 자체를 막습니다).
class Base {
virtual void func() {}
};
class Derived : public Base {
void func() override {} // 오버라이드
};
class Final : public Base {
void func() final {} // 더 이상 오버라이드 불가
};
Q5: 추상 클래스 vs 인터페이스?
A: C++에는 인터페이스가 없습니다. 순수 가상 함수만 있는 클래스를 인터페이스처럼 사용합니다.
// 인터페이스 (순수 가상 함수만)
class IShape {
public:
virtual double area() = 0;
virtual ~IShape() = default;
};
// 추상 클래스 (일부 구현 포함)
class Shape {
public:
virtual double area() = 0;
void print() { std::cout << "Shape\n"; } // 구현 포함
virtual ~Shape() = default;
};
Q6: 상속 vs 컴포지션?
A: 상속은 “Dog는 Animal이다” 같은 is-a 관계, 컴포지션은 “Car는 Engine을 가진다” 같은 has-a 관계를 표현합니다. 상속은 기본 클래스의 변경이 모든 파생 클래스에 전파되고 관계가 컴파일 타임에 고정되므로, 단지 코드를 재사용하려는 목적이라면 컴포지션이 결합도가 낮고 바꾸기 쉽습니다.
// 상속: is-a
class Dog : public Animal { };
// 컴포지션: has-a
class Car {
Engine engine;
};
Q7: 상속 학습 리소스는?
A:
- “Effective C++” by Scott Meyers (Item 7, 32-40)
- “C++ Primer” by Stanley Lippman
- cppreference.com - Inheritance