
가상 함수, 편하지만 느린 그 녀석
C++을 쓰다 보면 virtual 키워드를 자주 만나게 되거든요. 부모 클래스에 가상 함수를 만들어두고, 자식 클래스가 각자 다르게 구현하면, 같은 코드로 다양한 동작을 처리할 수 있어서 정말 편해요. 이걸 객체지향에서는 다형성(polymorphism)이라고 부르는데요, 사실 이 편리함 뒤에는 작은 비용이 숨어 있어요.
가상 함수를 호출할 때 컴파일러는 "이 객체가 정확히 어떤 클래스의 인스턴스지?"를 런타임에 확인해야 해요. 그래서 각 객체마다 vtable(가상 함수 테이블)이라는 함수 포인터 표를 가리키는 포인터가 숨어 있고, 호출할 때마다 그 표를 한 번 거쳐서 진짜 함수를 찾아가거든요. 짧게 말하면 함수 하나 부를 때 메모리를 한두 번 더 읽고, CPU 분기 예측도 어려워져요. 핫 루프(반복문에서 엄청 자주 도는 코드)에서는 이 작은 비용이 쌓여서 꽤 큰 차이를 만들어내요.
그런데 만약 컴파일러가 "아, 이 호출은 무조건 이 클래스의 함수만 부르는 거잖아?"라고 확신할 수 있다면 어떨까요? 그러면 vtable을 거치지 않고 바로 그 함수를 호출하도록 코드를 바꿔버릴 수 있어요. 이걸 디버추얼라이제이션(devirtualization) 이라고 불러요. 가상 호출을 일반 호출로 격하시킨다는 뜻이죠. 인라이닝과도 잘 어울려서, 한 번 디버추얼라이즈가 되면 함수 본문이 호출 지점에 통째로 펼쳐지는 추가 최적화까지 따라올 수 있어요.
컴파일러가 확신할 수 있는 순간들
Arthur O'Dwyer가 정리한 글을 보면, 컴파일러가 디버추얼라이제이션을 시도하는 대표적인 패턴들이 있어요. 첫 번째는 객체의 정확한 동적 타입을 컴파일러가 알 수 있는 경우 예요. 예를 들어 함수 안에서 Derived d; Base& b = d; b.foo(); 처럼 객체를 직접 만들어서 부르면, 컴파일러는 b가 결국 Derived라는 걸 알기 때문에 Derived::foo()를 바로 부를 수 있어요. 너무 당연해 보이지만, 컴파일러가 이걸 추적할 수 있는 건 함수 스코프 안에서 객체 생성과 호출이 한눈에 보일 때뿐이에요.
두 번째는 final 키워드 예요. 클래스나 멤버 함수에 final을 붙이면, "이 함수는 더 이상 오버라이드 안 됩니다" 또는 "이 클래스는 더 이상 상속 안 됩니다"라는 약속을 컴파일러에게 해주는 거거든요. 이러면 컴파일러는 안심하고 가상 호출을 직접 호출로 바꿔요. 성능이 중요한 코드에서 가상 함수가 꼭 필요하다면 final은 거의 공짜로 얻을 수 있는 최적화 카드인 셈이에요.
세 번째는 익명 네임스페이스(anonymous namespace)나 정적 함수처럼 외부에서 보이지 않는 클래스 인 경우예요. 어떤 클래스가 한 번역 단위(.cpp 파일) 바깥으로 절대 노출되지 않는다면, 컴파일러는 그 클래스를 상속한 다른 자식이 있을 수 없다는 걸 분석으로 알아낼 수 있어요. 이때도 디버추얼라이제이션이 가능해져요. LTO(Link-Time Optimization)를 켜면 더 넓은 범위에서 이런 분석이 가능해지고요.
왜 컴파일러는 그렇게 소심한가
반대로 디버추얼라이제이션이 잘 안 되는 경우도 있어요. 가장 큰 이유는 헤더 파일에 선언만 있고 자식 클래스의 정의가 보이지 않을 때 예요. C++의 분할 컴파일 모델 때문에 컴파일러는 "내가 지금 보고 있는 파일 바깥에 누가 또 이 클래스를 상속할지 모른다"고 보수적으로 가정해요. 다른 번역 단위에서 누군가 자식 클래스를 만들고 거기서 함수를 오버라이드했다면, 우리가 보는 호출이 그쪽으로 가야 할 수도 있으니까요.
또 흥미로운 포인트는 생성자와 소멸자 안에서의 가상 호출 이에요. C++ 표준에 따르면 생성자/소멸자 안에서 가상 함수를 부르면, 현재 생성/소멸 중인 그 클래스의 버전이 호출되도록 정해져 있어요. 자식 클래스의 오버라이드 버전이 아니라요. 그래서 이 경우는 사실상 정적 호출과 다를 바 없어서 컴파일러가 자동으로 디버추얼라이즈해버려요. 다만 이 동작 자체가 초보자에게는 함정이라서, 일반적으로는 생성자/소멸자에서 가상 함수를 부르지 말라는 게 정설이에요.
업계 맥락과 실무 팁
이 주제는 게임 엔진, 고성능 미들웨어, 금융 거래 시스템처럼 매 마이크로초가 아쉬운 분야에서 특히 중요해요. Unreal Engine 같은 곳에서 final을 적극적으로 쓰는 것도 같은 맥락이고요. LLVM이나 GCC 같은 컴파일러도 매년 디버추얼라이제이션 패스를 더 똑똑하게 만들고 있어요. Rust 같은 신생 언어들이 기본적으로 정적 디스패치를 선택하고, 다형성이 필요할 때만 dyn Trait로 명시하게 만든 것도 이런 비용을 언어 차원에서 더 투명하게 드러내려는 시도라고 볼 수 있어요.
한국 개발자라면 어떻게 활용할 수 있을까요? 일단 "가상 함수는 무조건 느리다"는 미신은 버리는 게 좋아요. 대부분의 코드에서는 가상 호출 비용이 무시할 만하거든요. 다만 프로파일러로 핫스팟을 찾았는데 거기서 가상 호출이 병목이라면, final을 붙이거나 클래스 계층 구조를 헤더로 노출해서 컴파일러에게 힌트를 주는 것만으로도 꽤 큰 개선을 얻을 수 있어요. LTO를 켜는 것도 빌드 시간만 허용된다면 한 번 시도해볼 만한 옵션이고요. Godbolt 같은 사이트에 코드를 붙여놓고 어셈블리를 보면, 내가 짠 가상 호출이 실제로 디버추얼라이즈됐는지 눈으로 확인할 수 있어서 학습용으로도 좋아요.
마무리
한 줄로 정리하면, 가상 함수의 비용을 컴파일러가 없애주는 건 우리가 충분한 정보를 주었을 때뿐 이에요. final 키워드 하나, 헤더 노출 하나, 익명 네임스페이스 하나가 성능을 가르는 결정적 차이가 될 수 있거든요.
여러분은 평소 C++ 코드에서 final을 얼마나 자주 쓰시나요? 혹시 가상 함수 호출이 성능 병목이 됐던 경험이 있다면, 그때 어떤 방식으로 풀어내셨는지도 궁금하네요.
🔗 출처: Hacker News
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공