본문으로 건너뛰기
Previous
Next
C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자

C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자

C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자

이 글의 핵심

가상 함수의 동적 디스패치, vtable·vptr 레이아웃, 가상 상속·다이아몬드 문제, 순수 가상·추상 클래스, 가상 소멸자 필요 조건, 프로덕션 다형성 패턴까지 한 번에 정리합니다.

가상 함수가 왜 필요할까? (실생활 비유)

커피숍 예시로 이해하기: 여러분이 커피숍 사장이라고 상상해보세요. “음료”라는 카테고리 안에 아메리카노, 라떼, 프라푸치노 등 다양한 종류가 있습니다. 손님이 주문할 때 “음료 하나 주세요”라고만 하면, 실제로는 그 손님이 주문한 구체적인 음료를 만들어야 합니다.

프로그래밍에서도 마찬가지입니다. “동물”이라는 기본 개념이 있고, 개·고양이·새처럼 구체적인 동물들이 있습니다. speak() (울음소리 내기)라는 행동은 모든 동물이 할 수 있지만, 실제로 내는 소리는 동물마다 다릅니다. 이때 virtual 키워드를 쓰면, “동물” 포인터로 가리켜도 실제 동물의 울음소리가 나오게 할 수 있습니다.

가상 함수란?

가상 함수(virtual function)실행 시점(런타임)에 실제 객체의 타입을 보고 어떤 함수를 호출할지 결정하는 멤버 함수입니다. 쉽게 말해, “겉으로는 동물인데, 실제로는 개”인 경우, 개의 speak()를 호출해주는 것입니다.

첫 번째 예제: 동물 울음소리

// 기본 "동물" 클래스 - 모든 동물의 공통 특성
class Animal {
public:
    // virtual 키워드: "이 함수는 나중에 다시 정의될 수 있어요"
    virtual void speak() {
        cout << "Animal sound" << endl;  // 기본 동물 소리
    }
};

// 개 클래스 - Animal을 상속받아 확장
class Dog : public Animal {
public:
    // override 키워드: "부모의 speak()를 다시 정의합니다"
    void speak() override {
        cout << "Woof!" << endl;  // 개는 멍멍!
    }
};

// 고양이 클래스 - Animal을 상속받아 확장
class Cat : public Animal {
public:
    void speak() override {
        cout << "Meow!" << endl;  // 고양이는 야옹!
    }
};

int main() {
    // 중요! Animal* 타입이지만 실제로는 Dog 객체를 가리킴
    Animal* animal1 = new Dog();
    // Animal* 타입이지만 실제로는 Cat 객체를 가리킴
    Animal* animal2 = new Cat();
    
    // virtual 덕분에 실제 객체(Dog, Cat)의 함수가 호출됨!
    animal1->speak();  // 출력: Woof! (Dog의 speak 호출)
    animal2->speak();  // 출력: Meow! (Cat의 speak 호출)
    
    // 메모리 정리
    delete animal1;
    delete animal2;
}

핵심 포인트: Animal* 타입의 포인터지만, virtual 덕분에 실제 객체가 개인지 고양이인지를 런타임에 확인하고 올바른 speak()를 호출합니다.

virtual vs non-virtual: 차이가 뭘까?

핵심 차이: virtual이 없으면 포인터 타입만 보고 함수를 결정하고, virtual이 있으면 실제 객체의 타입을 보고 함수를 결정합니다.

비교 예제

class Base {
public:
    // virtual이 없는 일반 함수
    void nonVirtual() {
        cout << "Base::nonVirtual" << endl;
    }
    
    // virtual이 있는 가상 함수
    virtual void virtualFunc() {
        cout << "Base::virtualFunc" << endl;
    }
};

class Derived : public Base {
public:
    // 같은 이름의 함수를 만들었지만 virtual이 없음
    void nonVirtual() {
        cout << "Derived::nonVirtual" << endl;
    }
    
    // override로 부모의 가상 함수를 재정의
    void virtualFunc() override {
        cout << "Derived::virtualFunc" << endl;
    }
};

int main() {
    // Base* 타입 포인터지만 실제로는 Derived 객체
    Base* ptr = new Derived();
    
    // ❌ virtual이 없어서 Base 버전이 호출됨 (정적 바인딩)
    ptr->nonVirtual();   // 출력: Base::nonVirtual
    
    // ✅ virtual 덕분에 Derived 버전이 호출됨 (동적 바인딩)
    ptr->virtualFunc();  // 출력: Derived::virtualFunc
    
    delete ptr;
}

용어 설명:

  • 정적 바인딩: 컴파일 시점에 “포인터 타입”만 보고 결정 (빠름)
  • 동적 바인딩: 실행 시점에 “실제 객체 타입”을 확인하고 결정 (유연함)

초보자 팁: virtual 없이는 “겉모습”만 보고, virtual이 있으면 “실제 정체”를 보고 판단한다고 기억하세요!

순수 가상 함수: “구현은 너희가 해!”

순수 가상 함수(pure virtual function)구현을 강제하는 함수입니다. = 0을 붙이면 “이 함수는 반드시 자식 클래스에서 구현해야 해요”라는 의미입니다.

왜 필요할까?

예를 들어 “도형”이라는 개념은 추상적입니다. “도형의 넓이”는 삼각형이냐 사각형이냐에 따라 계산 방법이 완전히 다릅니다. 따라서 Shape 클래스에서는 “넓이를 계산하는 함수가 있어야 한다”는 규칙만 정하고, 실제 계산은 각 도형 클래스가 알아서 하도록 강제하는 것입니다.

// 추상 클래스: 직접 만들 수 없는 "개념"
class Shape {
public:
    // = 0이 붙으면 "순수 가상 함수" - 구현 강제!
    virtual double area() const = 0;
    virtual void draw() const = 0;
    
    // 가상 소멸자 (나중에 설명)
    virtual ~Shape() = default;
};
class Circle : public Shape {
private:
    double radius;
    
public:
    Circle(double r) : radius(r) {}
    
    double area() const override {
        return 3.14159 * radius * radius;
    }
    
    void draw() const override {
        cout << "Drawing Circle" << endl;
    }
};
class Rectangle : public Shape {
private:
    double width, height;
    
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    
    double area() const override {
        return width * height;
    }
    
    void draw() const override {
        cout << "Drawing Rectangle" << endl;
    }
};
int main() {
    // ❌ 에러! Shape는 추상 클래스라 직접 만들 수 없음
    // Shape shape;
    
    // ✅ 하지만 Shape* 포인터는 OK! (실제로는 구체적인 도형 가리킴)
    vector<unique_ptr<Shape>> shapes;
    
    // 반지름 5인 원 추가
    shapes.push_back(make_unique<Circle>(5.0));
    // 가로 4, 세로 6인 직사각형 추가
    shapes.push_back(make_unique<Rectangle>(4.0, 6.0));
    
    // 모든 도형을 순회하면서
    for (const auto& shape : shapes) {
        shape->draw();                           // 각 도형의 그리기 방법 호출
        cout << "Area: " << shape->area() << endl;  // 각 도형의 넓이 계산 호출
    }
    // 출력 예시:
    // Drawing Circle
    // Area: 78.5398
    // Drawing Rectangle
    // Area: 24
}

핵심 개념:

  • 추상 클래스: 순수 가상 함수가 하나라도 있으면 직접 객체를 만들 수 없음
  • 구체 클래스: 모든 순수 가상 함수를 구현한 클래스는 객체를 만들 수 있음
  • 인터페이스 역할: “이런 기능이 있어야 해”라는 규약만 정의

### 순수 가상 함수와 추상 클래스 심화

`virtual void f() = 0` 형태의 **순수 가상 함수**가 하나라도 있으면 해당 클래스는 **추상 클래스(abstract class)**가 되어 직접 인스턴스화할 수 없습니다. 컴파일러는 모든 순수 가상 함수를 **구체 클래스(concrete class)**에서 구현했는지 검사합니다. 순수 가상 함수에도 **본문을 함께 정의**할 수 있는데(예: 인터페이스 기본 동작을 헤더와 분리할 때), 이 경우에도 추상성은 유지되며 파생 클래스에서 `override`로 호출하거나 재정의할 수 있습니다.

**인터페이스 역할**을 강조하려면 public 순수 가상만 두고 데이터 멤버를 최소화하는 편이 일반적입니다. 반면 **부분 추상**으로 공통 멤버나 비가상 멤버 함수를 기저에 두고, 변형이 필요한 지점만 순수 가상으로 남기는 설계도 흔합니다. 추상 기저는 `std::unique_ptr<Interface>`와 함께 쓰일 때 **소유권 경계**와 **수명**을 명확히 하기 좋습니다. 다만 추상 클래스라도 **생성자**는 존재할 수 있고, 파생 클래스 생성자에서 기저 생성자를 호출하는 흐름은 그대로입니다.

## 실전 예시
### 예시 1: 파일 시스템
```cpp
class FileSystemNode {
protected:
    string name;
    
public:
    FileSystemNode(const string& n) : name(n) {}
    virtual ~FileSystemNode() = default;
    
    virtual void print(int indent = 0) const = 0;
    virtual size_t getSize() const = 0;
};
class File : public FileSystemNode {
private:
    size_t size;
    
public:
    File(const string& n, size_t s) : FileSystemNode(n), size(s) {}
    
    void print(int indent = 0) const override {
        cout << string(indent, ' ') << "- " << name 
             << " (" << size << " bytes)" << endl;
    }
    
    size_t getSize() const override {
        return size;
    }
};
class Directory : public FileSystemNode {
private:
    vector<unique_ptr<FileSystemNode>> children;
    
public:
    Directory(const string& n) : FileSystemNode(n) {}
    
    void add(unique_ptr<FileSystemNode> node) {
        children.push_back(move(node));
    }
    
    void print(int indent = 0) const override {
        cout << string(indent, ' ') << "+ " << name << "/" << endl;
        for (const auto& child : children) {
            child->print(indent + 2);
        }
    }
    
    size_t getSize() const override {
        size_t total = 0;
        for (const auto& child : children) {
            total += child->getSize();
        }
        return total;
    }
};
int main() {
    auto root = make_unique<Directory>("root");
    
    auto docs = make_unique<Directory>("docs");
    docs->add(make_unique<File>("readme.txt", 1024));
    docs->add(make_unique<File>("guide.pdf", 5120));
    
    root->add(move(docs));
    root->add(make_unique<File>("main.cpp", 2048));
    
    root->print();
    cout << "Total size: " << root->getSize() << " bytes" << endl;
}

예시 2: 전략 패턴

class PaymentStrategy {
public:
    virtual ~PaymentStrategy() = default;
    virtual void pay(double amount) = 0;
};
class CreditCardPayment : public PaymentStrategy {
private:
    string cardNumber;
    
public:
    CreditCardPayment(const string& card) : cardNumber(card) {}
    
    void pay(double amount) override {
        cout << "카드 " << cardNumber << "로 " 
             << amount << "원 결제" << endl;
    }
};
class PayPalPayment : public PaymentStrategy {
private:
    string email;
    
public:
    PayPalPayment(const string& e) : email(e) {}
    
    void pay(double amount) override {
        cout << "PayPal " << email << "로 " 
             << amount << "원 결제" << endl;
    }
};
class ShoppingCart {
private:
    unique_ptr<PaymentStrategy> paymentStrategy;
    double total = 0;
    
public:
    void setPaymentStrategy(unique_ptr<PaymentStrategy> strategy) {
        paymentStrategy = move(strategy);
    }
    
    void addItem(double price) {
        total += price;
    }
    
    void checkout() {
        if (paymentStrategy) {
            paymentStrategy->pay(total);
            total = 0;
        }
    }
};
int main() {
    ShoppingCart cart;
    cart.addItem(10000);
    cart.addItem(20000);
    
    cart.setPaymentStrategy(make_unique<CreditCardPayment>("1234-5678"));
    cart.checkout();
    
    cart.addItem(15000);
    cart.setPaymentStrategy(make_unique<PayPalPayment>("[email protected]"));
    cart.checkout();
}

예시 3: 로거

class Logger {
public:
    virtual ~Logger() = default;
    virtual void log(const string& message) = 0;
};
class ConsoleLogger : public Logger {
public:
    void log(const string& message) override {
        cout << "[Console] " << message << endl;
    }
};
class FileLogger : public Logger {
private:
    string filename;
    
public:
    FileLogger(const string& file) : filename(file) {}
    
    void log(const string& message) override {
        ofstream ofs(filename, ios::app);
        ofs << "[File] " << message << endl;
    }
};
class Application {
private:
    unique_ptr<Logger> logger;
    
public:
    void setLogger(unique_ptr<Logger> l) {
        logger = move(l);
    }
    
    void run() {
        if (logger) {
            logger->log("Application started");
            // ...
            logger->log("Application finished");
        }
    }
};
int main() {
    Application app;
    
    app.setLogger(make_unique<ConsoleLogger>());
    app.run();
    
    app.setLogger(make_unique<FileLogger>("app.log"));
    app.run();
}

vtable과 vptr: 가상 함수의 비밀 무기

“어떻게 런타임에 올바른 함수를 찾을까?”

C++는 vtable(가상 함수 테이블)과 vptr(가상 함수 테이블 포인터)이라는 메커니즘을 사용합니다.

쉬운 비유: 전화번호부

  • vtable: 각 클래스마다 가지고 있는 “함수 주소 목록” (전화번호부)
  • vptr: 객체 안에 숨어있는 “전화번호부를 가리키는 포인터”
객체 생성 시:
Dog 객체 → vptr → Dog용 vtable [speak=Dog::speak 주소]
Cat 객체 → vptr → Cat용 vtable [speak=Cat::speak 주소]

함수 호출 시:
animal->speak() 
→ animal의 vptr 확인 
→ vtable에서 speak 찾기 
→ 해당 주소로 점프!

내부 동작 3단계

Animal* animal = new Dog();
animal->speak();

// 컴파일러가 하는 일 (개념적으로):
// 1단계: animal 객체의 vptr을 찾는다
// 2단계: vptr이 가리키는 vtable을 찾는다  
// 3단계: vtable에서 speak() 함수 주소를 찾아 호출한다

메모리 추가 비용

가상 함수를 쓰면:

  • 객체마다 vptr 포인터 하나 추가 (보통 8바이트)
  • 함수 호출 시 간접 참조 한 번 더 (미세하게 느림)

초보자 팁: 대부분의 경우 이 비용은 무시할 만합니다. “성능 때문에 virtual을 안 써야 하나?” 고민하기보다는, 먼저 코드를 깔끔하게 만드는 게 중요합니다!

다중 상속에서는 기저 클래스마다 별도의 vptr·vtable이 필요할 수 있고, 두 번째 이후 기저로 포인터를 바꿀 때 this 포인터 조정이 일어납니다. 이때 썽크(thunk) 코드가 끼어 올바른 this로 가상 호출을 이어주기도 합니다. 따라서 다중 상속·가상 상속이 겹칠수록 레이아웃과 호출 비용이 커지며, 성능·단순성 측면에서 단일 상속 + 컴포지션이 우선 검토 대상이 됩니다.

// 개념적 주석: 실제 레이아웃은 ABI/컴파일러에 의존
class Base {
public:
    virtual void func1() {}
    virtual void func2() {}
    virtual ~Base() = default;
};
// Base 인스턴스 ≈ [ vptr ] + [ 비트 필드·데이터 멤버 ... ]
// vptr → vtable[0]=&Base::func1, [1]=&Base::func2, [2]=destructor pair 등

가상 상속과 다이아몬드 문제

다중 상속에서 동일한 비가상 기저 클래스가 서로 다른 경로로 두 번 이상 들어오면, 파생 객체 안에 동일 기저의 서브객체가 복수 생깁니다. 이를 다이아몬드 구조라고 부릅니다. 예를 들어 AB, C가 각각 상속하고 DBC를 동시에 상속하면, D 안에 A두 벌 생길 수 있어 A의 멤버에 접근할 때 모호함이 생기거나, 논리적으로 하나여야 할 기저 상태두 개로 갈라집니다.

이를 막으려면 B, CAvirtual 상속하도록 하면 됩니다. 그러면 D에는 A 서브객체가 하나만 존재합니다. 대신 구현체는 가상 베이스 오프셋을 해석하기 위한 vbptr 등 추가 메타데이터를 둘 수 있어, 객체 크기·접근 비용이 커집니다. 그래서 실무에서는 다이아몬드를 설계로 피할 수 있는지(인터페이스 분리, 컴포지션)를 먼저 검토하는 경우가 많습니다.

// 개념 예시: 가상 상속으로 기저 A 단일화
struct A {
    int value = 1;
    virtual ~A() = default;
    virtual void f() {}
};
struct B : virtual A { /* ... */ };
struct C : virtual A { /* ... */ };
struct D : B, C {
    // A는 D 안에서 한 번만 존재
};

가상 소멸자: 메모리 누수를 막는 핵심!

문제 상황

class Base {
public:
    ~Base() { }  // ❌ virtual이 없음!
};

class Derived : public Base {
private:
    int* bigData;  // 큰 데이터를 가리키는 포인터
public:
    Derived() {
        bigData = new int[10000];  // 메모리 할당
    }
    ~Derived() {
        delete[] bigData;  // 메모리 해제
    }
};

int main() {
    Base* ptr = new Derived();  // Base 포인터지만 실제로는 Derived
    delete ptr;                  // 💥 문제 발생!
    // Base 소멸자만 호출됨
    // Derived 소멸자가 호출 안 됨
    // bigData 메모리 해제 안 됨 = 메모리 누수!
}

해결책: 가상 소멸자 사용

class Base {
public:
    virtual ~Base() {  // ✅ virtual 추가!
        cout << "~Base()" << endl;
    }
};

class Derived : public Base {
private:
    int* data;
public:
    Derived() : data(new int[100]) {}
    
    ~Derived() {
        cout << "~Derived()" << endl;
        delete[] data;  // 이제 정상적으로 호출됨!
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  
    // 출력:
    // ~Derived()  ← 먼저 파생 클래스 소멸자
    // ~Base()     ← 그 다음 기저 클래스 소멸자
}

초보자가 자주 하는 실수: “내 클래스는 상속받을 일이 없을 것 같아서 virtual 안 붙였어요” → 나중에 누군가 상속받으면 버그 발생!

황금 규칙: 다른 클래스가 상속받을 수 있는 클래스라면 무조건 가상 소멸자를 만드세요!

가상 소멸자가 호출되면 파생 → 기저 순으로 소멸 규칙이 적용되며, 가장 파생 클래스의 소멸자 본문이 먼저 실행된 뒤 기저 쪽으로 진행됩니다. 가상 소멸자가 “항상” 필요한 것은 아닙니다. 기저 클래스를 다형적으로 삭제하지 않고, 문서/설계상 기저 포인터로 파생을 잡지 않는다면 비가상 소멸자를 유지할 수도 있습니다. 혹은 다형 인터페이스이지만 delete를 막고 싶다면 protected ~Base(); 같은 패턴으로 잘못된 스택/힙 삭제를 제한하기도 합니다.

class Base {
public:
    virtual ~Base() {
        cout << "~Base()" << endl;
    }
};
class Derived : public Base {
private:
    int* data;

public:
    Derived() : data(new int[100]) {}

    ~Derived() {
        cout << "~Derived()" << endl;
        delete[] data;
    }
};
int main() {
    Base* ptr = new Derived();
    delete ptr;  // ~Derived() 후 ~Base() — 가상 소멸자 덕분에 안전
}

프로덕션 다형성 패턴

인터페이스 + 팩토리 + 스마트 포인터 조합은 서비스 코드에서 가장 흔한 형태입니다. 구현 타입은 컴파일 의존을 줄이기 위해 .cpp에 두고, 헤더에는 추상 기저와 std::unique_ptr<Interface> create...() 정도만 노출합니다. NVI(Non-Virtual Interface) 패턴은 public 비가상 멤버가 템플릿 메서드처럼 전후처리를 고정하고, private/protected 가상 함수로만 확장점을 열어 불변식(invariant)을 지키기 쉽게 합니다.

테스트에서는 동일 인터페이스에 목(mock) 구현을 꽂아 I/O·네트워크를 대체합니다. 성능이 중요한 경로에서는 final로 더 이상 오버라이드되지 않음을 알리거나, 컴파일 타임 다형성(CRTP 등)으로 가상 호출을 없애는 방안을 비교합니다. 핫패스는 프로파일로 확인한 뒤에만 최적화하는 것이 안전합니다. 광범위한 virtual 남발보다 경계가 명확한 소수 인터페이스가 유지보수에 유리한 경우가 많습니다.

// NVI 스케치: 공개 비가상 인터페이스 + protected 가상 구현 훅
class Engine {
public:
    void run() {          // 고정된 전후처리
        on_before();
        do_work();        // 파생에서만 오버라이드
        on_after();
    }
    virtual ~Engine() = default;

protected:
    virtual void do_work() = 0;
    virtual void on_before() {}
    virtual void on_after() {}
};

초보자가 자주 하는 실수 TOP 3

실수 1: 가상 소멸자를 안 만듦

// ❌ 가상 소멸자 없음
class Base {
public:
    ~Base() {
        cout << "~Base()" << endl;
    }
};
class Derived : public Base {
private:
    int* data;
    
public:
    Derived() : data(new int[100]) {}
    
    ~Derived() {
        cout << "~Derived()" << endl;
        delete[] data;  // 호출 안됨!
    }
};
Base* ptr = new Derived();
delete ptr;  // 메모리 누수
// ✅ 가상 소멸자
class Base {
public:
    virtual ~Base() {
        cout << "~Base()" << endl;
    }
};

실수 2: override를 안 붙여서 오타 발견 못 함

class Base {
public:
    virtual void calculate() {}
};

// ❌ 오타가 있는데 컴파일은 됨 (새 함수로 인식)
class Derived : public Base {
public:
    void calcuate() {}  // 오타! calculate가 아니라 calcuate
    // 부모 함수를 재정의한 게 아니라 새 함수를 만든 것!
};

int main() {
    Base* ptr = new Derived();
    ptr->calculate();  // Base의 calculate가 호출됨 (의도와 다름!)
}

해결책: 항상 override 붙이기!

class Derived : public Base {
public:
    void calcuate() override {}  
    // ✅ 컴파일 에러 발생!
    // "부모에 calcuate라는 함수가 없어요!"
};

초보자 팁: override는 귀찮아도 무조건 붙이세요. 오타를 컴파일 단계에서 잡아줍니다!

실수 3: 객체 슬라이싱 (Object Slicing)

가장 헷갈리는 버그!

class Base {
public:
    virtual void print() {
        cout << "I'm Base" << endl;
    }
};

class Derived : public Base {
private:
    int extraData = 42;  // Derived만의 추가 데이터
public:
    void print() override {
        cout << "I'm Derived with " << extraData << endl;
    }
};

// ❌ 슬라이싱 발생!
Derived d;
Base b = d;     // Derived를 Base에 "복사"
                // 이때 Derived의 extraData는 잘려나감!
b.print();      // 출력: I'm Base (Derived 부분이 사라짐)

// ✅ 올바른 방법: 포인터나 참조 사용
Base* ptr = &d;
ptr->print();   // 출력: I'm Derived with 42

왜 이런 일이?: Base b = d;복사입니다. Base 크기만큼만 복사되므로 Derived의 추가 부분은 잘려나갑니다.

초보자 팁: 다형성을 쓸 땐 항상 포인터(*)나 참조(&)를 사용하세요!

초보자 FAQ (자주 묻는 질문)

Q1: 가상 함수는 언제 써야 하나요?

A: 다음 상황에서 사용하세요:

  1. “종류가 여러 개”인 경우: 동물(개, 고양이, 새…), 도형(원, 사각형, 삼각형…)
  2. “같은 인터페이스, 다른 구현”: 모두 draw()가 있지만 그리는 방법은 다름
  3. 런타임에 타입이 결정: 사용자 입력에 따라 어떤 객체를 쓸지 결정

쉬운 판단 기준: “이 함수를 자식 클래스마다 다르게 구현해야 하나?” → Yes면 virtual!

Q2: virtual을 쓰면 얼마나 느려지나요?

A: 걱정하지 마세요!

  • 추가 비용: 함수 호출 시 포인터 한 번 더 따라가기 (나노초 단위)
  • 대부분의 프로그램에서는 전혀 체감 안 됨
  • 진짜 느리다면 문제는 virtual이 아니라 알고리즘일 확률 99%

초보자 조언: 성능 걱정보다 코드를 이해하기 쉽게 만드는 게 먼저입니다!

Q3: override를 꼭 써야 하나요?

A: 필수는 아니지만 강력 추천!

// override 없이도 작동함 (C++11 이전 방식)
class Dog : public Animal {
    void speak() { }  // 작동은 함
};

// override 있으면 실수를 막아줌 (C++11 이후 권장)
class Dog : public Animal {
    void speak() override { }  // 오타가 있으면 컴파일 에러!
};

결론: 귀찮아도 override 붙이세요. 나중에 디버깅 시간을 엄청 절약합니다!

Q4: 가상 소멸자는 언제 필요한가요?

A: 포인터로 삭제할 가능성이 1%라도 있으면 무조건 필요!

// 이런 코드가 있을 수 있다면
Base* ptr = new Derived();
delete ptr;  // ← 이게 있으면 가상 소멸자 필수!

// 항상 스택에서만 쓴다면 가상 소멸자 불필요
void func() {
    Derived d;
    // ...
}  // 자동 소멸

Q5: 순수 가상 함수(= 0)는 언제 써요?

A: “이 함수는 반드시 구현해!”라고 강제하고 싶을 때

class Shape {
    virtual double area() = 0;  // "넓이 계산은 필수야!"
};

// Circle에서 area()를 구현 안 하면 컴파일 에러!

사용 예: 인터페이스를 정의할 때 (Java의 interface와 비슷)

Q6: 더 공부하려면 뭘 봐야 하나요?

A:

  • : “Effective C++” (Scott Meyers)
  • 사이트: cppreference.com - C++ 레퍼런스
  • : “C++ Primer” (초보자용 두꺼운 교과서)

초보자를 위한 체크리스트

가상 함수를 쓸 때 이것만 기억하세요:

  • 상속받을 수 있는 클래스라면 가상 소멸자 만들기
  • 재정의하는 함수에는 override 붙이기
  • 다형성 쓸 때는 포인터(*)나 참조(&) 사용하기
  • 순수 가상 함수(= 0)로 구현 강제하기
  • 성능 걱정은 프로파일러로 확인한 후에 하기

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「[2026] C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「[2026] C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, virtual, polymorphism, 다형성, OOP, vtable, 가상상속 등으로 검색하시면 이 글이 도움이 됩니다.