C++ 가상 함수 현장 매뉴얼 | 설계·비용·override·실무 패턴 한글 정리
이 글의 핵심
가상 함수 도입 기준, 정적·동적 다형성 선택, override/final·NVI 관용구, 핫패스에서의 비용과 대안, 단위 테스트용 인터페이스 분리까지 현장 기준으로 정리합니다.
이 글에서 다루는 범위
virtual이 동적 디스패치를 만든다는 기본 설명은 C++ 가상 함수와 vtable: 호출 비용, Devirtualization, CRTP 대안과 vtable 내부에서 다룹니다. 이 글은 실제 코드에서 결정을 내릴 때 필요한 기준을 모읍니다. 가상 함수를 쓸지 템플릿·std::variant로 갈지, override와 final로 시그니처 불일치를 어떻게 막는지, NVI 관용구로 전후처리를 한곳에 모으는 방법, 핫패스에서 가상 호출을 의심할 때 무엇을 측정할지, 테스트 경계에서 인터페이스를 어떻게 나눌지를 차례로 봅니다.
가상 함수가 해결하는 문제
가상 함수는 기저 타입의 포인터나 참조로 파생 클래스의 구현을 호출하고 싶을 때 씁니다. 정적 타입만으로는 어떤 구현을 부를지 알 수 없으므로, 런타임에 객체의 vtable을 통해 호출할 함수를 고릅니다. 반대로 컴파일 시점에 구체 타입이 확정되는 코드라면 인라인과 최적화에 유리한 경우가 많아, 모든 멤버를 습관적으로 virtual로 만드는 것은 오히려 설계 의도를 흐립니다.
가상 함수의 진짜 가치는 성능이 아니라 의존 방향을 끊는 데 있습니다. Renderer가 IGraphicsDevice라는 인터페이스에만 의존하면, OpenGL 구현과 Vulkan 구현을 추가하거나 바꿔도 Renderer는 다시 컴파일할 필요가 없습니다. 헤더 하나를 바꿨을 때 수백 개 파일이 재컴파일되는 대형 C++ 코드베이스에서는 이 “컴파일 방화벽” 효과가 런타임 비용보다 훨씬 중요한 판단 기준이 되기도 합니다. 반대로 템플릿 기반 정적 다형성은 호출하는 쪽이 구현 타입을 알아야 하므로, 구현이 바뀔 때마다 사용하는 쪽도 다시 컴파일됩니다.
설계 결정 순서
- 런타임에 구현체가 바뀌는지 먼저 봅니다. 플러그인, 드라이버, 전략 패턴, 테스트 더블 주입처럼 실행 중에 구현을 고르거나 갈아 끼워야 한다면
virtual이 첫 번째 후보입니다. - 타입 집합이 닫혀 있고 컴파일 시점에 모두 알 수 있다면
std::variant와std::visit, 또는 템플릿과if constexpr조합을 먼저 검토합니다. - 핫 루프에서 아주 많이 호출된다는 이유만으로 가상 함수를 피하지는 않습니다. 프로파일로 확인한 뒤에 CRTP, 함수 포인터 테이블, 타입별 일괄 처리 같은 대안을 비교합니다.
- 기저 포인터로
delete할 가능성이 있다면 가상 소멸자와 소멸 순서를 함께 설계합니다. 다형 삭제가 없다면 소멸자를protected비가상으로 두는 선택지도 있습니다.
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 아님 (다른 타입)
};
위 예제에 override를 붙이면 컴파일러가 즉시 'void Derived::on_event(long int)' marked 'override', but does not override 같은 에러로 알려 줍니다. override가 없으면 이 코드는 에러 없이 컴파일되고(-Woverloaded-virtual 경고를 켜 두었다면 기저 함수를 가린다는 경고는 나옵니다), Base*로 on_event(42)를 호출하면 Base의 구현이 실행됩니다. 이런 불일치는 처음 작성할 때보다 기저 클래스의 시그니처를 나중에 바꿀 때 더 자주 생깁니다. 누군가 Base::on_event에 const를 추가하거나 인자 타입을 바꾸면, override가 없는 파생 클래스들은 조용히 재정의 관계가 끊어집니다. 그래서 기저에서 virtual로 선언된 함수를 재정의할 때는 항상 override를 붙이고, GCC·Clang의 -Wsuggest-override나 clang-tidy의 modernize-use-override로 누락을 CI에서 잡는 편이 좋습니다.
더 이상 상속을 허용하지 않을 클래스에는 final을 붙입니다. final에는 부수 효과도 있습니다. 컴파일러가 final 클래스의 포인터나 참조로 가상 함수를 호출할 때 더 이상 재정의가 없다는 것을 알기 때문에, 간접 호출을 직접 호출로 바꾸는 탈가상화(devirtualization)를 할 수 있습니다. 성능 때문에 final을 남발할 필요는 없지만, 상속을 의도하지 않은 구현 클래스라면 붙여 두어 손해 볼 것이 없습니다.
NVI: 가상 함수를 훅으로만 두기
로깅, 락, 불변식 검증 같은 공통 전후처리를 모든 파생 클래스에 복사하지 않으려면, 비가상 public 함수 안에서 가상 훅을 호출하는 NVI(Non-Virtual Interface) 관용구를 씁니다.
class Worker {
public:
virtual ~Worker() = default;
void run() { // 비가상: 호출 순서·예외 처리 정책을 한곳에서
before_work();
do_work(); // 파생에서 구현
after_work();
}
private:
virtual void before_work() {}
virtual void after_work() {}
virtual void do_work() = 0;
};
NVI의 핵심은 “무엇을 할지”는 파생이 정하고 “언제·어떤 순서로 할지”는 기저가 정한다는 역할 분리입니다. 파생 클래스 작성자가 run()의 로깅이나 락을 빠뜨릴 방법 자체가 없어집니다. Herb Sutter의 원래 권고대로 훅을 private virtual로 둔 것에 주목하세요. C++에서는 private 가상 함수도 파생 클래스가 재정의할 수 있고, private이면 파생 클래스가 훅을 직접 호출하지 못해 순서 보장이 더 강해집니다. 파생 클래스가 기저 구현을 불러야 하는 경우(Worker::before_work() 호출)에만 protected로 둡니다. std::unique_ptr<Worker>로 파생 객체를 소유하는 경우를 대비해 가상 소멸자도 선언했습니다.
before_work에서 예외가 나면 do_work를 건너뛸지, after_work는 반드시 실행할지 같은 계약은 run() 한곳에서 정해집니다. 이 계약을 주석으로 남겨 두면 파생 클래스 작성자가 훅 안에서 무엇을 보장해야 하는지 분명해집니다.
비용과 캐시: 언제 의심할까
다형 클래스의 객체에는 vptr이 하나 붙습니다(구현과 ABI에 따라 다르지만 보통 포인터 크기입니다). 가상 호출은 “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로 선언해 둔 것도 중요합니다. 인터페이스 포인터로 구현 객체를 소유하는 경우가 많아, 이것이 빠지면 인터페이스 포인터로 delete할 때 정의되지 않은 동작이 되고 보통 구현 클래스의 소멸자가 호출되지 않습니다.
인터페이스가 비대해지면 인터페이스 분리 원칙에 따라 쪼갭니다. 어떤 클라이언트가 쓰지 않는 메서드가 인터페이스에 들어 있다면 분리할 후보입니다. 반대로 테스트만을 위해 모든 클래스에 인터페이스를 만들면, 구현이 하나뿐인 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 slice_example() {
Derived d;
Base b = d; // 슬라이싱: d의 Derived 부분은 복사되지 않음
// 다형 동작이 필요하면 참조·포인터·스마트 포인터로 다룬다
}
다형 기저 클래스의 복사 생성자와 복사 대입 연산자를 protected로 두거나 = delete하면, 슬라이싱을 일으키는 코드가 컴파일 단계에서 막힙니다. 복제가 필요하다면 virtual std::unique_ptr<Base> clone() const처럼 가상 복제 함수를 제공하는 것이 관례입니다.
흔한 안티패턴
기본 구현이 있는 가상 함수를 두고 “파생 클래스는 반드시 재정의해야 한다”고 문서로만 약속하면, 누군가 재정의를 잊었을 때 컴파일러는 아무 말도 하지 않고 런타임에 기본 동작이 실행됩니다. 반드시 구현해야 하는 함수라면 순수 가상으로 만들어 컴파일러가 강제하게 합니다.
NVI로 설계한 클래스에서 파생 클래스가 훅을 public으로 다시 노출하면, 호출자가 기저의 run()을 거치지 않고 훅을 직접 부를 수 있게 되어 락이나 불변식 검사 같은 전후처리가 우회됩니다. 훅은 private이나 protected로 유지해야 NVI의 의미가 살아납니다.
얇은 가상 계층을 여러 겹 쌓는 것도 문제입니다. 계층마다 단순히 다음 인터페이스로 위임만 한다면, 작업 하나를 따라가는 데 여러 파일을 오가야 하고 디버거의 콜스택도 깊어져 실제 일을 하는 코드를 찾기 어려워집니다. 대체 구현이 실제로 존재하거나 예정된 경계에만 인터페이스를 둡니다.
생성자와 소멸자에서 가상 함수를 호출하면 기대와 다르게 동작합니다. 기저 클래스 생성자가 실행되는 동안 객체는 아직 기저 타입이므로, 생성자 안의 가상 호출은 파생 구현이 아니라 기저 구현으로 연결됩니다. 소멸자도 마찬가지입니다. 그 함수가 순수 가상이면 정의되지 않은 동작이며, 보통 링크 에러가 나거나 간접적으로 호출된 경우 런타임에 pure virtual method called와 함께 terminate로 종료됩니다. 초기화에 파생 동작이 필요하면 생성 후 별도의 init()을 부르거나 팩토리 함수로 감쌉니다.
가상 함수의 기본 인자도 함정입니다. 기본 인자는 정적 타입 기준으로 정해지고 함수 본문은 동적 타입 기준으로 정해집니다. Base에서 virtual void draw(int scale = 1), Derived에서 scale = 2로 선언하면 Base*로 호출할 때 Derived::draw가 scale = 1로 실행됩니다. 가상 함수에는 기본 인자를 두지 않거나, NVI의 비가상 public 함수에만 두는 것이 안전합니다.