본문 바로가기

동적 바인딩 feat. vtable & vptr

@iamrain2025. 9. 3. 11:50

1. 이론

1.1. 바인딩(Binding)이란?

프로그래밍에서 바인딩은 프로그램의 기본 단위(변수, 함수, 클래스 등)에 그 속성(타입, 값, 주소 등)을 확정하고 연결하는 과정을 의미한다. 특히 함수 호출에 있어서는, 호출하는 코드와 실제 실행될 코드를 연결하는 과정을 말한다.

C++에서는 이러한 바인딩이 일어나는 시점에 따라 정적 바인딩(Static Binding)동적 바인딩(Dynamic Binding)으로 나뉜다.

1.2. 정적 바인딩 (Static Binding)

정적 바인딩컴파일 시간(Compile-time)에 호출될 함수가 결정되고 연결되는 방식이다. 컴파일러는 코드를 분석하여 어떤 함수를 호출해야 할지 명확하게 알 수 있으며, 해당 함수의 주소를 호출 코드에 직접 포함시킨다.

  • 특징
    • 빠른 실행 속도: 런타임에 함수를 찾을 필요 없이 즉시 호출할 수 있으므로 성능상 이점이 있다.
    • 안정성: 컴파일 시점에 모든 호출이 결정되므로, 존재하지 않는 함수를 호출하는 등의 오류를 미리 발견할 수 있다.
  • 주요 예시
    • 일반 함수 호출: myFunction();
    • 함수 오버로딩(Function Overloading): 동일한 이름이지만 매개변수의 타입이나 개수가 다른 함수들 중 어떤 것을 호출할지 컴파일 시점에 결정된다.
    • 연산자 오버로딩(Operator Overloading): a + b와 같은 연산이 어떤 코드를 실행할지 컴파일 시점에 결정된다.
    • 템플릿(Template): 템플릿 인스턴스화는 컴파일 시점에 이루어지며, 생성된 코드에 대한 함수 호출 역시 정적 바인딩이다.

1.3. 동적 바인딩 (Dynamic Binding)

동적 바인딩런타임(Runtime)에 호출될 함수가 결정되고 연결되는 방식이다. 이는 주로 상속 관계에 있는 클래스들 사이에서, 포인터나 참조를 통해 다형성(Polymorphism)을 구현할 때 사용된다.

  • 특징
    • 유연성과 확장성: 런타임에 객체의 실제 타입에 따라 적절한 함수가 호출되므로, 코드 변경 없이 새로운 기능을 유연하게 추가하고 확장할 수 있다.
    • 성능 오버헤드: 런타임에 어떤 함수를 호출할지 결정하는 과정이 필요하므로 정적 바인딩에 비해 약간의 성능 저하가 발생한다.
  • 핵심 키워드: virtual

C++에서는 부모 클래스의 함수에 virtual 키워드를 붙여 해당 함수가 동적 바인딩을 통해 호출될 것임을 명시한다.

1.4. 동적 바인딩의 핵심: vtable과 vptr

동적 바인딩은 C++ 컴파일러가 내부적으로 생성하는 가상 함수 테이블(Virtual Function Table, vtable)가상 함수 포인터(Virtual Function Pointer, vptr)를 통해 구현된다.

이 메커니즘을 이해하는 것은 C++의 다형성을 깊이 있게 이해하는 데 필수다.

1.4.1. vtable (가상 함수 테이블)

  • 정의: vtable은 가상 함수의 주소를 저장하는 함수 포인터 배열이다.
  • 생성 시점: 컴파일러가 virtual 키워드를 가진 함수가 하나라도 있는 클래스를 발견하면, 그 클래스에 대한 vtable을 하나 생성한다. 이 테이블은 해당 클래스의 모든 객체들이 공유한다.
  • 저장 위치: vtable은 프로그램의 데이터 영역(주로 읽기 전용 데이터 영역인 .rodata 세그먼트)에 정적으로 저장된다.

1.4.2. vptr (가상 함수 포인터)

  • 정의: vptr은 객체가 자신의 클래스에 해당하는 vtable을 가리키기 위해 사용하는 포인터다.
  • 생성 시점: 컴파일러는 가상 함수를 가진 클래스의 객체가 생성될 때, 객체의 메모리 레이아웃에 숨겨진 멤버 변수로 vptr을 추가한다. 일반적으로 객체의 가장 앞부분에 위치한다.
  • 초기화: 객체의 생성자가 호출될 때, vptr은 해당 객체의 클래스에 맞는 vtable의 주소로 초기화된다.

1.4.3. 메모리 구조와 상속

예시 클래스:

class Parent {
public:
    int parent_data;
    virtual void func1() { /* ... */ }
    virtual void func2() { /* ... */ }
};

class Child : public Parent {
public:
    int child_data;
    void func1() override { /* ... */ } // 재정의
    // func2는 재정의하지 않음
};

메모리 및 vtable 구조:

  1. Parent 클래스
    • Parent_VTable: [ &Parent::func1, &Parent::func2 ]
    • Parent 객체 (p_obj)의 메모리 구조:
      p_obj:
      +----------------+
      | VPTR           |  -> Parent_VTable
      +----------------+
      | parent_data    |
      +----------------+
  2. Child 클래스
    • Child_VTable: [ &Child::func1, &Parent::func2 ]
      • func1Child에서 재정의했으므로, Child::func1의 주소로 교체.
      • func2는 재정의하지 않았으므로, 부모의 Parent::func2 주소를 그대로 상속.
    • Child 객체 (c_obj)의 메모리 구조:
      c_obj:
      +----------------+
      | VPTR           |  -> Child_VTable
      +----------------+
      | parent_data    |  (Parent로부터 상속)
      +----------------+
      | child_data     |
      +----------------+

1.4.4. 가상 함수 호출 과정

Parent* ptr = new Child(); ptr->func1(); 이 코드가 실행될 때의 내부 동작은 아래와 같다.

  1. 객체 생성: new Child()가 호출되면, Child 객체를 위한 메모리가 할당되고 생성자가 실행된다. 이때 Child 객체 내의 VPTRChild_VTable을 가리키도록 설정된다.
  2. 포인터 ptr: ptrParent* 타입이지만, 실제로는 Child 객체의 주소를 담고 있다.
  3. ptr->func1() 호출:
    a. vptr 접근: ptr이 가리키는 객체의 주소로 가서 숨겨진 멤버 VPTR의 값을 읽는다. 이 값은 Child_VTable의 주소다.
    b. vtable 인덱싱: func1Parent 클래스에서 첫 번째 가상 함수이므로, V-Table의 0번 인덱스에 해당한다. 컴파일러는 이 오프셋을 알고 있다.
    c. 함수 주소 획득: Child_VTable의 0번 인덱스에 저장된 주소를 가져온다. 이 주소는 Child::func1의 실제 주소다.
    d. 함수 실행: 획득한 주소로 점프하여 Child::func1 함수를 실행한다.

이처럼 포인터의 정적 타입(Parent*)이 아닌, 포인터가 실제 가리키는 객체(Child)의 VPTR을 통해 vtable에 접근하므로 런타임에 실제 객체에 맞는 함수를 호출할 수 있게 된다.

1.4.5. 동적 바인딩의 오버헤드

  • 메모리 오버헤드:
    • 객체마다: VPTR을 저장할 공간이 추가로 필요하다. (32비트 시스템에서는 4바이트, 64비트에서는 8바이트)
    • 클래스마다: V-Table을 저장할 공간이 필요하다. 가상 함수 개수만큼의 포인터 크기를 가진다.
  • 성능 오버헤드:
    • 가상 함수 호출 시, 정적 바인딩(직접 호출)에 비해 2~3번의 추가적인 메모리 접근이 필요하다. (VPTR 읽기 -> V-Table 주소 읽기 -> 함수 주소 읽기)
    • 이로 인해 약간의 성능 저하가 발생하지만, 현대 CPU의 캐시와 분기 예측 기능 덕분에 대부분의 애플리케이션에서는 그 영향이 미미하다. 그럼에도 불구하고 극단적인 최적화가 필요한 경우(e.g., 초당 수백만 번 호출되는 내부 루프)에는 동적 바인딩 사용을 재고할 수 있다.

1.5. 정적 바인딩 vs 동적 바인딩

구분 정적 바인딩 (Static Binding) 동적 바인딩 (Dynamic Binding)
결정 시점 컴파일 시간 런타임
구현 방식 직접 함수 주소 호출 V-Table을 통한 간접 호출
성능 빠름 (오버헤드 없음) 약간의 오버헤드 발생 (메모리 접근, 간접 호출)
메모리 추가 메모리 없음 객체당 VPTR(포인터 크기), 클래스당 V-Table
유연성 낮음 (하드 코딩된 호출) 높음 (객체 타입에 따라 동작 변경 가능)
주 사용처 성능이 매우 중요하고, 호출 대상이 명확할 때 다형성을 활용한 확장성 있는 설계가 필요할 때
  • 언제 동적 바인딩을 사용해야 하는가?
    • 상속 관계에서 부모 클래스 포인터로 자식 객체를 다루며, 자식마다 다른 동작을 수행하게 하고 싶을 때. (e.g., Character* 배열에 Warrior, Mage 등 다양한 직업을 넣고 일괄적으로 attack()을 호출)
    • 라이브러리나 프레임워크를 설계할 때, 사용자가 정의한 타입을 플러그인처럼 동작하게 하고 싶을 때.
  • 언제 정적 바인딩을 사용해야 하는가?
    • 가상 함수가 필요 없는 일반적인 클래스나 유틸리티 함수.
    • CRTP(Curiously Recurring Template Pattern)와 같이 템플릿을 이용해 컴파일 타임 다형성을 구현하여 성능을 극대화하고 싶을 때.
    •  

2. 실습 코드: 게임 캐릭터 스킬 시스템

아래 코드는 다양한 종류의 스킬을 Skill이라는 공통 기반 클래스로 다루는 예시다.
Character는 스킬을 Skill* 포인터로 관리하며, 동적 바인딩을 통해 각 스킬의 고유한 효과를 발동시킨다.

Skill.h

#pragma once
#include <iostream>
#include <string>
#include <vector>

// 전방 선언: Character 클래스가 존재한다는 것을 컴파일러에게 알림
class Character;

// 스킬의 기반이 되는 추상 클래스
class Skill {
public:
    // 생성자: 스킬 이름과 마나 소모량을 초기화
    Skill(const std::string& name, int manaCost) : _name(name), _manaCost(manaCost) {}
    // 가상 소멸자: 자식 클래스의 소멸자가 호출되도록 보장
    virtual ~Skill() {}

    // 스킬의 핵심 동작을 정의하는 순수 가상 함수
    // 자식 클래스는 이 함수를 반드시 구현해야 함
    virtual void execute(Character& caster, Character& target) = 0;

    // 스킬 이름을 반환하는 getter
    std::string getName() const { return _name; }
    // 마나 소모량을 반환하는 getter
    int getManaCost() const { return _manaCost; }

protected:
    std::string _name; // 스킬 이름
    int _manaCost;     // 마나 소모량
};

// 화염구 스킬
class Fireball : public Skill {
public:
    Fireball() : Skill("화염구", 10) {}
    // execute 함수를 재정의하여 화염구의 효과를 구현
    void execute(Character& caster, Character& target) override;
};

// 치유 스킬
class Heal : public Skill {
public:
    Heal() : Skill("치유", 15) {}
    // execute 함수를 재정의하여 치유의 효과를 구현
    void execute(Character& caster, Character& target) override;
};

// 번개 사슬 스킬
class ChainLightning : public Skill {
public:
    ChainLightning() : Skill("번개 사슬", 25) {}
    // execute 함수를 재정의하여 번개 사슬의 효과를 구현
    void execute(Character& caster, Character& target) override;
};

Character.h

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "Skill.h"

// 게임 캐릭터를 표현하는 클래스
class Character {
public:
    // 생성자: 캐릭터 이름, 체력, 마나를 초기화
    Character(const std::string& name, int hp, int mp) : _name(name), _hp(hp), _maxHp(hp), _mp(mp), _maxMp(mp) {}

    // 소멸자: 보유한 스킬들의 메모리를 해제
    ~Character() {
        for (Skill* skill : _skills) {
            delete skill;
        }
        _skills.clear();
    }

    // 캐릭터가 데미지를 받는 함수
    void takeDamage(int damage) {
        _hp -= damage;
        if (_hp < 0) _hp = 0;
        std::cout << _name << "이(가) " << damage << "의 피해를 입었습니다. 현재 HP: " << _hp << std::endl;
    }

    // 캐릭터가 체력을 회복하는 함수
    void heal(int amount) {
        _hp += amount;
        if (_hp > _maxHp) _hp = _maxHp;
        std::cout << _name << "이(가) " << amount << "의 체력을 회복했습니다. 현재 HP: " << _hp << std::endl;
    }

    // 마나를 소모하는 함수
    void consumeMana(int amount) {
        _mp -= amount;
    }

    // 새로운 스킬을 배우는 함수
    void learnSkill(Skill* newSkill) {
        _skills.push_back(newSkill);
        std::cout << _name << "이(가) 새로운 스킬 [" << newSkill->getName() << "]을(를) 배웠습니다." << std::endl;
    }

    // 스킬을 사용하는 함수
    void useSkill(int skillIndex, Character& target) {
        if (skillIndex < 0 || skillIndex >= _skills.size()) {
            std::cout << "잘못된 스킬 인덱스입니다." << std::endl;
            return;
        }

        Skill* skillToUse = _skills[skillIndex];
        if (_mp < skillToUse->getManaCost()) {
            std::cout << "마나가 부족하여 스킬을 사용할 수 없습니다." << std::endl;
            return;
        }

        consumeMana(skillToUse->getManaCost());
        std::cout << _name << "이(가) " << target.getName() << "에게 [" << skillToUse->getName() << "] 시전!" << std::endl;

        // Q1: 아래의 함수 호출은 정적 바인딩인가요, 동적 바인딩인가요?
        // 그 이유는 무엇이며, 이 호출이 내부적으로 어떻게 동작하는지 V-Table과 관련지어 설명해보세요.
        // 제출: 
        skillToUse->execute(*this, target);
    }

    // 캐릭터 정보를 출력하는 함수
    void printStatus() const {
        std::cout << "--- " << _name << " 상태 ---" << std::endl;
        std::cout << "HP: " << _hp << "/" << _maxHp << std::endl;
        std::cout << "MP: " << _mp << "/" << _maxMp << std::endl;
        std::cout << "보유 스킬:" << std::endl;
        for (size_t i = 0; i < _skills.size(); ++i) {
            std::cout << "  " << i << ": " << _skills[i]->getName() << " (마나 " << _skills[i]->getManaCost() << ")" << std::endl;
        }
        std::cout << "--------------------" << std::endl;
    }

    std::string getName() const { return _name; }

private:
    std::string _name;
    int _hp, _maxHp;
    int _mp, _maxMp;
    std::vector<Skill*> _skills; // 다양한 스킬들을 부모 클래스 포인터로 관리
};

Skill.cpp

#include "Character.h"
#include "Skill.h"

// Fireball 스킬의 구체적인 구현
void Fireball::execute(Character& caster, Character& target) {
    int damage = 25;
    std::cout << "거대한 화염구가 " << target.getName() << "을(를) 강타합니다!" << std::endl;
    target.takeDamage(damage);
}

// Heal 스킬의 구체적인 구현
void Heal::execute(Character& caster, Character& target) {
    int healAmount = 30;
    std::cout << "성스러운 빛이 " << target.getName() << "을(를) 감싸 치유합니다." << std::endl;
    target.heal(healAmount);
}

// Q2: 만약 ChainLightning의 execute 함수에서 'override' 키워드를 제거하면 컴파일 또는 실행에 어떤 영향을 미칠까요?
// 'override' 키워드의 주된 용도는 무엇인가요?
// 제출:
void ChainLightning::execute(Character& caster, Character& target) /* override */ {
    int initialDamage = 15;
    std::cout << "날카로운 번개가 " << target.getName() << "에게 적중하고, 주위로 뻗어나갑니다!" << std::endl;
    target.takeDamage(initialDamage);
    // (실제 게임이라면 주변 다른 타겟에게도 연쇄적으로 데미지를 주는 로직이 추가될 것입니다)
}

main.cpp

#include "Character.h"
#include "Skill.h"

int main() {
    // 캐릭터 생성
    Character mage("마법사", 100, 80);
    Character dummy("허수아비", 200, 0);

    // 스킬을 배워 캐릭터에 추가
    // Q3: 아래 코드에서 `new Fireball()`과 같이 자식 클래스 타입으로 객체를 생성하여
    // 부모 클래스인 `Skill*` 타입의 포인터로 받고 있습니다. 이를 무엇이라고 부르며, 왜 이런 방식이 가능한가요?
    // 제출:
    mage.learnSkill(new Fireball());
    mage.learnSkill(new Heal());
    mage.learnSkill(new ChainLightning());

    std::cout << std::endl;

    // 초기 상태 출력
    mage.printStatus();
    dummy.printStatus();

    std::cout << "=== 전투 시작 ===" << std::endl;

    // 스킬 사용
    mage.useSkill(0, dummy); // 화염구 사용
    std::cout << std::endl;

    mage.useSkill(2, dummy); // 번개 사슬 사용
    std::cout << std::endl;

    mage.useSkill(1, mage);  // 자신에게 치유 사용
    std::cout << std::endl;

    // 최종 상태 출력
    mage.printStatus();
    dummy.printStatus();

    // Q4: main 함수가 종료될 때, 생성된 스킬 객체들(Fireball, Heal 등)은 어떤 과정을 통해 메모리에서 해제되나요?
    // 만약 Skill 클래스의 소멸자에서 'virtual' 키워드를 제거한다면 어떤 문제가 발생할 수 있을까요?
    // 제출:
    return 0;
}

'C++' 카테고리의 다른 글

RTTI (Run-Time Type Information)  (1) 2025.09.05
vtable과 vptr  (0) 2025.09.05
구조체  (0) 2025.09.03
클래스  (0) 2025.09.02
가상 함수와 순수 가상 함수  (0) 2025.09.02
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차