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"; }
};
상속 구조:
다음은 mermaid 예제 코드입니다.
// 실행 예제
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만의 메서드
}
접근 지정자:
| 상속 방식 | 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 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];
}
}
추상 클래스 (순수 가상 함수)
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];
}
}
실전 예시
예시 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;
}
설명: 다형성을 활용하여 다양한 캐릭터 타입을 하나의 컨테이너로 관리합니다.
예시 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;
}
설명: 전략 패턴을 사용하여 다양한 결제 방법을 유연하게 처리합니다.
예시 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이 아님
해결법:
// ❌ 위험한 코드
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; // Derived 소멸자 호출 안됨! 누수!
// ✅ 올바른 코드
class Base {
public:
virtual ~Base() { cout << "Base 소멸" << endl; }
};
문제 2: override 키워드 누락
증상: 오버라이드 의도했으나 새 함수 생성됨
원인: 함수 시그니처 불일치
해결법:
// ❌ 실수하기 쉬운 코드
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)
증상: 파생 클래스 정보 손실
원인: 값으로 복사
해결법:
C/C++ 예제 코드입니다.
// ❌ 슬라이싱 발생
Dog dog("바둑이");
Animal animal = dog; // Dog 정보 손실!
animal.speak(); // Animal::speak 호출
// ✅ 포인터나 참조 사용
Dog dog("바둑이");
Animal* animal = &dog;
animal->speak(); // Dog::speak 호출
실무 패턴
패턴 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: 약간의 오버헤드가 있지만 대부분의 경우 무시할 수 있는 수준입니다. 유연성의 이점이 훨씬 큽니다.
// 간접 참조 1회 (vtable)
animal->speak(); // vtable 조회 → 함수 호출
Q2: 모든 함수를 virtual로 만들어야 하나요?
A: 아니요, 오버라이드가 필요한 함수만 virtual로 만드세요. 소멸자는 항상 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 { }; // 다이아몬드 문제
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"; } // 구현 포함
};
Q6: 상속 vs 컴포지션?
A:
- 상속 (“is-a”): Dog is an Animal
- 컴포지션 (“has-a”): Car has an Engine
일반적으로 컴포지션이 더 유연합니다.
// 상속: is-a
class Dog : public Animal { };
// 컴포지션: has-a
class Car {
Engine engine;
};
Q7: 가상 소멸자를 왜 사용하나요?
A: 파생 클래스 소멸자가 호출되도록 보장합니다.
// ❌ 가상 소멸자 없음: 메모리 누수
class Base {
public:
~Base() { } // virtual 없음
};
class Derived : public Base {
int* data_;
public:
Derived() { data_ = new int[100]; }
~Derived() { delete[] data_; } // 호출 안됨!
};
Base* ptr = new Derived();
delete ptr; // Derived 소멸자 호출 안됨!
// ✅ 가상 소멸자: 안전
class Base {
public:
virtual ~Base() { }
};
Q8: 상속 학습 리소스는?
A:
- “Effective C++” by Scott Meyers (Item 7, 32-40)
- “C++ Primer” by Stanley Lippman
- cppreference.com - Inheritance
관련 글: virtual, override, polymorphism.
한 줄 요약: 상속은 코드 재사용, 다형성은 동일한 인터페이스로 다양한 타입을 처리하는 C++ OOP 핵심 기능입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 가상 함수 | “Virtual Functions” 가이드
- C++ 현대적 다형성 설계: 상속 대신 합성·variant
- C++ override와 final | “가상 함수” 가이드
관련 글
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「C++ 상속과 다형성 | ‘virtual 함수’ 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「C++ 상속과 다형성 | ‘virtual 함수’ 완벽 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, 상속, 다형성, virtual, OOP 등으로 검색하시면 이 글이 도움이 됩니다.