C++ 가상 함수 현장 매뉴얼 | 설계·비용·override·실무 패턴 한글 정리
이 글의 핵심
가상 함수 도입 기준, 정적·동적 다형성 선택, override/final·NVI 관용구, 핫패스에서의 비용과 대안, 단위 테스트용 인터페이스 분리까지 현장 기준으로 정리합니다.
이 글의 목적
이미 virtual이 동적 디스패치를 만든다는 설명은 다른 글에서 다루기 쉽습니다. 여기서는 현장에서 의사결정을 내릴 때 필요한 기준과 습관을 모읍니다.
- 언제 가상 함수를 쓰고, 언제 템플릿·함수 객체·std::variant로 가는지
override/final로 조용한 시그니처 불일치를 막는 법- NVI(Non-Virtual Interface)로 예외 안전·불변식을 지키는 패턴
- 핫패스에서 간접 호출·vptr을 의심할 때의 대응
- 테스트·모듈 경계에서 인터페이스를 어떻게 쪼갤지
기본 개념은 C++ 가상 함수 심화 가이드와 vtable 내부를 함께 보면 더 탄탄해집니다.
가상 함수가 “해결하는 문제”를 한 문장으로
기저 타입의 포인터·참조로 파생 구현을 호출하고 싶을 때, 컴파일러는 정적 타입만으로는 어떤 구현을 부를지 모르므로 런타임에 vtable을 통해 목적지를 고릅니다.
반대로, 컴파일 시점에 구체 타입이 확정되면 인라인·최적화에 유리한 경우가 많아, “무조건 virtual”은 오히려 설계를 흐리게 합니다.
가상 함수의 진짜 가치는 성능이 아니라 의존 방향을 끊는 것입니다. Renderer가 IGraphicsDevice라는 인터페이스에만 의존하면, OpenGL 구현과 Vulkan 구현을 추가하거나 바꿔도 Renderer는 다시 컴파일할 필요가 없습니다. 헤더 하나를 바꿨을 때 수백 개 파일이 재컴파일되는 대형 C++ 코드베이스에서는 이 “컴파일 방화벽” 효과가 런타임 비용보다 훨씬 중요한 판단 기준이 되기도 합니다. 반대로 템플릿 기반 정적 다형성은 호출하는 쪽이 구현 타입을 알아야 하므로, 구현이 바뀔 때마다 사용하는 쪽도 다시 컴파일됩니다.
설계 결정 트리 (실무용)
- 다형성이 “런타임에 구현체가 바뀌는가?”
- 예: 플러그인, 드라이버, 전략 패턴, 테스트 더블 주입 →
virtual후보.
- 예: 플러그인, 드라이버, 전략 패턴, 테스트 더블 주입 →
- 타입 집합이 닫혀 있고 컴파일 타임에 모두 안다
std::variant+std::visit, 또는 템플릿 +if constexpr조합을 먼저 검토.
- 핫 루프에서 수백만 번 호출
- 프로파일 전제 없이 가정하지 말 것. 측정 후 CRTP, 함수 포인터 테이블 수동 관리, 분기 예측 친화적 설계 등을 비교.
- 기저 포인터로
delete할 가능성- 가상 소멸자·소멸 순서와 함께 설계. (다형 삭제가 없으면 다른 선택지도 있음.)
2번의 “닫힌 타입 집합”이 가장 자주 놓치는 분기입니다. 예를 들어 도형이 원·사각형·삼각형 세 가지로 고정되어 있고 새 도형보다 새 연산(면적, 둘레, 직렬화, 충돌 검사)이 자주 추가된다면, 가상 함수보다 std::variant<Circle, Rect, Triangle>이 맞습니다. 가상 함수 방식은 연산을 추가할 때마다 기저 클래스와 모든 파생 클래스를 고쳐야 하지만, variant 방식은 std::visit에 넘길 함수 하나만 추가하면 됩니다. 반대로 타입이 계속 늘어나는 구조(플러그인)라면 variant는 모든 visit 지점을 고쳐야 하므로 가상 함수가 유리합니다. 어느 축으로 확장되는지가 선택 기준입니다.
using Shape = std::variant<Circle, Rect, Triangle>;
double area(const Shape& s) {
return std::visit([](const auto& x) { return x.area(); }, s); // 힙 할당·vptr 없음
}
variant는 객체를 값으로 저장하므로 std::vector<Shape>가 연속된 메모리에 놓이고, 포인터를 따라가는 간접 참조도 없습니다. 대신 가장 큰 대안 타입 크기만큼 모든 원소가 공간을 차지하고, 새 대안 타입을 추가하면 visit하는 모든 코드가 다시 컴파일됩니다.
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멤버
위 예제에 override를 붙이면 컴파일러가 즉시 'void Derived::on_event(long)' marked 'override', but does not override라는 에러로 알려 줍니다. override가 없으면 이 코드는 경고 없이 컴파일되고, Base*로 on_event(42)를 호출하면 Base의 구현이 실행됩니다. 이런 불일치는 처음 작성할 때보다 기저 클래스의 시그니처를 나중에 바꿀 때 더 자주 생깁니다. 누군가 Base::on_event에 const를 추가하거나 인자 타입을 바꾸면, override가 없는 파생 클래스들은 조용히 재정의 관계가 끊어집니다. GCC·Clang의 -Wsuggest-override나 clang-tidy의 modernize-use-override로 누락을 CI에서 강제할 수 있습니다.
final에는 부수 효과도 있습니다. 컴파일러가 final 클래스의 포인터나 참조로 가상 함수를 호출할 때 더 이상 재정의가 없다는 것을 알기 때문에, 간접 호출을 직접 호출로 바꾸는 탈가상화(devirtualization)를 할 수 있습니다. 성능 때문에 final을 남발할 필요는 없지만, 상속을 의도하지 않은 구현 클래스라면 붙여 두어 손해 볼 것이 없습니다.
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를 호출하지 않을지, 롤백할지 같은 계약을 문서나 주석으로 고정해 두면 팀 합의가 빨라집니다.
NVI의 핵심은 “무엇을 할지”는 파생이 정하고 “언제·어떤 순서로 할지”는 기저가 정한다는 역할 분리입니다. 파생 클래스 작성자가 run()의 로깅이나 락을 빠뜨릴 방법 자체가 없어집니다. Herb Sutter의 원래 권고는 훅을 protected가 아니라 private virtual로 두는 것입니다. C++에서는 private 가상 함수도 파생 클래스가 재정의할 수 있고, private이면 파생 클래스가 훅을 직접 호출하지 못해 순서 보장이 더 강해집니다. 파생 클래스가 기저 구현을 불러야 하는 경우(Base::before_work() 호출)에만 protected로 둡니다. 예제의 Worker에는 가상 소멸자가 없는데, std::unique_ptr<Worker>로 파생 객체를 소유할 계획이라면 virtual ~Worker() = default;를 반드시 추가해야 합니다.
비용과 캐시: 언제 의심할까
- 객체마다 vptr(구현·ABI에 따라 보통 포인터 크기)이 붙습니다.
- 가상 호출은 간접 분기라 인라인이 어렵으며, 분기 예측 실패 시 비용이 커질 수 있습니다.
대응 예시 (상황별):
- 소수의 큰 작업을 가상으로 분기 → 대체로 문제 없음.
- 작은 함수가 루프 안에서 수억 번 → 프로파일 후 정적 다형성 또는 수동 디스패치 검토.
- 데이터 지역성: 다형 객체를 포인터 배열로 흩뿌리면 캐시 미스가 늘 수 있어, SOA·배치 업데이트 같은 자료구조 관점의 최적화가 더 큰 효과를 내는 경우가 많습니다.
가상 호출 자체의 비용은 “vptr 읽기 → vtable에서 함수 주소 읽기 → 간접 호출”입니다. 같은 타입의 객체를 연속으로 호출하면 분기 예측이 거의 항상 맞고 vtable도 캐시에 있으므로 비용은 작습니다. 비용이 커지는 조건은 두 가지입니다. 하나는 타입이 무작위로 섞인 컨테이너를 순회해 분기 예측이 계속 틀리는 경우이고, 다른 하나는 함수 본문이 아주 작아서(getter 수준) 인라인이 막히는 손실이 호출 자체보다 커지는 경우입니다. 그래서 흔한 개선책은 가상 함수를 없애는 것이 아니라, 객체를 타입별로 정렬하거나 타입별 컨테이너로 나눠 같은 타입을 몰아서 처리하는 것입니다.
프로파일러에서 가상 호출이 의심될 때는 함수 자체의 시간보다 호출 지점의 branch-misses와 캐시 미스를 보는 것이 정확합니다. Linux라면 perf stat -e branch-misses,cache-misses로 전후를 비교할 수 있습니다. 측정 없이 CRTP로 바꾸면 코드는 복잡해지는데 병목은 그대로인 경우가 많습니다.
인터페이스 경계와 테스트
다형성은 경계에서 특히 빛을 발합니다.
- 생산 코드는
IResource같은 얇은 인터페이스에 의존. - 테스트에서는 가짜 구현으로 I/O·시간·랜덤을 고정.
struct IClock {
virtual ~IClock() = default;
virtual std::chrono::system_clock::time_point now() const = 0;
};
팁: 인터페이스가 비대해지면 인터페이스 분리 원칙으로 쪼갭니다. “한 클라이언트가 쓰지 않는 메서드”가 보이면 후보입니다.
IClock 같은 인터페이스가 있으면 테스트에서 시간을 원하는 값으로 고정한 FakeClock을 주입할 수 있어, “자정을 넘기는 순간”, “토큰 만료 1초 전” 같은 상황을 실제로 기다리지 않고 검증할 수 있습니다. 인터페이스의 가상 소멸자를 = default로 선언해 둔 것도 중요합니다. 인터페이스 포인터로 구현 객체를 소유하는 경우가 많아, 이것이 빠지면 구현 클래스의 소멸자가 호출되지 않습니다.
다만 테스트만을 위해 모든 클래스에 인터페이스를 만들면, 구현이 하나뿐인 IUserService/UserService 쌍이 수십 개 생기고 코드 탐색이 어려워집니다. 가짜 구현이 실제로 필요한 경계, 즉 I/O·시간·난수·외부 서비스에만 인터페이스를 두는 편이 실용적입니다.
객체 슬라이싱: 가상 함수와 별개로 터지는 크래시
값 의미로 파생 타입 객체를 기저 타입 변수에 대입·전달하면 파생 전용 부분이 잘리고, 결과는 완전한 Base 객체가 됩니다. 슬라이싱 자체는 정의된 동작이라 크래시가 나지 않는다는 점이 오히려 위험합니다. 복사된 b의 vptr은 Base의 vtable을 가리키므로, 이후 가상 함수를 호출하면 파생 클래스의 재정의가 아니라 Base의 구현이 조용히 실행됩니다. std::vector<Base>에 파생 객체를 push_back했을 때 모든 원소가 기저 동작만 하는 버그가 대표적입니다.
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/이동”으로 규약을 박아 두면 사고 반복을 줄입니다. 다형 기저 클래스의 복사 생성자와 복사 대입 연산자를 protected로 두거나 = delete하면, 슬라이싱을 일으키는 코드가 컴파일 단계에서 막힙니다. 복제가 필요하다면 virtual std::unique_ptr<Base> clone() const처럼 가상 복제 함수를 제공하는 것이 관례입니다.
흔한 안티패턴
- 기본 구현을 둔 가상 함수를 파생에서 “필수로 오버라이드해야 한다”고만 믿기 → 잊으면 런타임에만 드러남. 순수 가상으로 올리거나 문서·assert로 보강.
- 비가상 public 멤버가 내부에서 가상을 부르는데, 파생이 public 가상을 또 노출 → 슬라이싱·계약 혼란. NVI로 정리.
- 간접 계층 비용 과소평가: 가상 레이어를 너무 얇게 여러 겹 쌓으면, 작업 하나에 인터페이스 호출이 수백 번 오가 디버깅할 때 콜스택이 지저분해지고 “실제 일을 하는 코드”를 찾기 어려워집니다.
- 생성자·소멸자에서 가상 함수 호출: 기저 클래스 생성자가 실행되는 동안 객체는 아직 기저 타입이므로, 생성자 안의 가상 호출은 파생 구현이 아니라 기저 구현으로 연결됩니다. 소멸자도 마찬가지입니다. 그 함수가 순수 가상이면 링크 에러가 나거나, 간접적으로 호출된 경우 런타임에
pure virtual method called와 함께terminate로 죽습니다. 초기화에 파생 동작이 필요하면 생성 후 별도의init()을 부르거나 팩토리 함수로 감쌉니다. - 가상 함수의 기본 인자: 기본 인자는 정적 타입 기준으로 정해지고 함수 본문은 동적 타입 기준으로 정해집니다.
Base에서virtual void draw(int scale = 1),Derived에서scale = 2로 선언하면Base*로 호출할 때Derived::draw가scale = 1로 실행됩니다. 가상 함수에는 기본 인자를 두지 않거나, NVI의 비가상 public 함수에만 두는 것이 안전합니다.
마무리 체크리스트
- 파생 클래스의 가상 재정의에
override를 붙였는가? - 더 이상 상속하지 않을 클래스·함수에
final을 검토했는가? - 기저 포인터로 소멸·소유권 이전이 있다면 소멸자 설계를 별도 글 가이드와 맞췄는가?
- 성능 이슈는 측정 후 인터페이스를 바꿨는가?
- 테스트·플러그인 경계에서 인터페이스 크기가 적절한가?
가상 함수는 C++ 다형성의 기본 도구이지만, 최신 C++에서는 대안 표현(variant, concepts, 템플릿)과 함께 두고 경계와 비용을 기준으로 고르는 편이 좋습니다.
같이 보면 좋은 글
- C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
- C++ vtable과 vptr: 가상 함수 호출이 동작하는 방식과 메모리·호출 비용
- C++ 상속과 다형성