C++ 가상 함수 현장 매뉴얼 | 설계·비용·override·실무 패턴 한글 정리
이 글의 핵심
가상 함수 도입 기준, 정적·동적 다형성 선택, override/final·NVI 관용구, 핫패스에서의 비용과 대안, 단위 테스트용 인터페이스 분리까지 현장 기준으로 정리합니다.
이 글의 목적
이미 virtual이 동적 디스패치를 만든다는 설명은 다른 글에서 다루기 쉽습니다. 여기서는 현장에서 의사결정을 내릴 때 필요한 기준과 습관을 모읍니다.
- 언제 가상 함수를 쓰고, 언제 템플릿·함수 객체·std::variant로 가는지
override/final로 조용한 시그니처 불일치를 막는 법- NVI(Non-Virtual Interface)로 예외 안전·불변식을 지키는 패턴
- 핫패스에서 간접 호출·vptr을 의심할 때의 대응
- 테스트·모듈 경계에서 인터페이스를 어떻게 쪼갤지
기본 개념은 C++ 가상 함수 심화 가이드와 vtable 내부를 함께 보면 더 탄탄해집니다.
1. 가상 함수가 “해결하는 문제”를 한 문장으로
기저 타입의 포인터·참조로 파생 구현을 호출하고 싶을 때, 컴파일러는 정적 타입만으로는 어떤 구현을 부를지 모르므로 런타임에 vtable을 통해 목적지를 고릅니다.
반대로, 컴파일 시점에 구체 타입이 확정되면 인라인·최적화에 유리한 경우가 많아, “무조건 virtual”은 오히려 설계를 흐리게 합니다.
2. 설계 결정 트리 (실무용)
- 다형성이 “런타임에 구현체가 바뀌는가?”
- 예: 플러그인, 드라이버, 전략 패턴, 테스트 더블 주입 →
virtual후보.
- 예: 플러그인, 드라이버, 전략 패턴, 테스트 더블 주입 →
- 타입 집합이 닫혀 있고 컴파일 타임에 모두 안다
std::variant+std::visit, 또는 템플릿 +if constexpr조합을 먼저 검토.
- 핫 루프에서 수백만 번 호출
- 프로파일 전제 없이 가정하지 말 것. 측정 후 CRTP, 함수 포인터 테이블 수동 관리, 분기 예측 친화적 설계 등을 비교.
- 기저 포인터로
delete할 가능성- 가상 소멸자·소멸 순서와 함께 설계. (다형 삭제가 없으면 다른 선택지도 있음.)
3. override는 “예의”가 아니라 안전장치
시그니처가 살짝만 달라도 재정의가 아니라 새 멤버 함수가 됩니다. 재현하기 어려운 버그로 이어집니다.
class Base {
public:
virtual void on_event(int code);
};
class Derived : public Base {
public:
void on_event(long code); // 의도한 override 아님 (다른 타입)
};
습관적으로 다음을 적용합니다.
- 기저에서
virtual을 단 멤버 → 파생에서는 항상override - 더 이상 상속 확장을 막을 클래스 →
final class또는final멤버
4. NVI: 가상 함수를 “후킹 포인트”로만 두기
공통 전후처리(로깅, 락, 불변식 검증)를 모든 파생에 복사하지 않으려면, 비가상 public 인터페이스 안에서 protected virtual 훅을 부르는 NVI 관용구가 많이 쓰입니다.
class Worker {
public:
void run() { // 비가상: 호출 순서·예외 처리 정책을 한곳에서
before_work();
do_work(); // 파생에서 구현
after_work();
}
protected:
virtual void before_work() {}
virtual void after_work() {}
virtual void do_work() = 0;
};
실무 팁: before_work에서 예외가 나면 do_work를 호출하지 않을지, 롤백할지 같은 계약을 문서나 주석으로 고정해 두면 팀 합의가 빨라집니다.
5. 비용과 캐시: 언제 의심할까
- 객체마다 vptr(구현·ABI에 따라 보통 포인터 크기)이 붙습니다.
- 가상 호출은 간접 분기라 인라인이 어렵고, 분기 예측 실패 시 비용이 커질 수 있습니다.
대응 예시 (상황별):
- 소수의 큰 작업을 가상으로 분기 → 대체로 문제 없음.
- 작은 함수가 루프 안에서 수억 번 → 프로파일 후 정적 다형성 또는 수동 디스패치 검토.
- 데이터 지역성: 다형 객체를 포인터 배열로 흩뿌리면 캐시 미스가 늘 수 있어, SOA·배치 업데이트 같은 자료구조 관점의 최적화가 더 큰 효과를 내는 경우가 많습니다.
6. 인터페이스 경계와 테스트
다형성은 경계에서 특히 빛을 발합니다.
- 생산 코드는
IResource같은 얇은 인터페이스에 의존. - 테스트에서는 가짜 구현으로 I/O·시간·랜덤을 고정.
struct IClock {
virtual ~IClock() = default;
virtual std::chrono::system_clock::time_point now() const = 0;
};
팁: 인터페이스가 비대해지면 인터페이스 분리 원칙으로 쪼갭니다. “한 클라이언트가 쓰지 않는 메서드”가 보이면 후보입니다.
7. 객체 슬라이싱: 가상 함수와 별개로 터지는 크래시
값 의미로 파생 타입 객체를 기저 타입 변수에 대입·전달하면 파생 전용 부분이 잘리고 버퍼처럼 잘못 읽히는 Undefined Behavior가 발생할 수 있습니다.
struct Base {
virtual ~Base() = default;
int b = 1;
};
struct Derived : Base {
int d = 2;
};
void leak_by_slice() {
Derived d;
Base b = d; // 슬라이싱: d의 Derived 부분은 복사되지 않음
// 필요한 건 참조 또는 포인터(또는 스마트 포인터 소유 계층)
}
가상 호출 문제와 별개로, 설계 회의에서 “항상 참조/unique_ptr/이동”으로 규약을 박아 두면 사고 반복을 줄입니다.
8. 흔한 안티패턴
- 기본 구현을 둔 가상 함수를 파생에서 “필수로 오버라이드해야 한다”고만 믿기 → 잊으면 런타임에만 드러남. 순수 가상으로 올리거나 문서·assert로 보강.
- 비가상 public 멤버가 내부에서 가상을 부르는데, 파생이 public 가상을 또 노출 → 슬라이싱·계약 혼란. NVI로 정리.
- 도깅 비용 과소평가: 가상 레이어를 너무 얇게 쌓아 작업 단위 호출만 수백 번 → 디버깅·스택이 지저분해짐.
9. 마무리 체크리스트
- 파생 클래스의 가상 재정의에
override를 붙였는가? - 더 이상 상속하지 않을 클래스·함수에
final을 검토했는가? - 기저 포인터로 소멸·소유권 이전이 있다면 소멸자 설계를 별도 글 가이드와 맞췄는가?
- 성능 이슈는 측정 후 인터페이스를 바꿨는가?
- 테스트·플러그인 경계에서 인터페이스 크기가 적절한가?
가상 함수는 C++ 다형성의 기본 도구이지만, 최신 C++에서는 대안 표현(variant, concepts, 실행 정책)과 함께 두고 경계와 비용을 기준으로 고르는 것이 성숙한 코드베이스의 표준입니다.