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 등
가상 상속과 다이아몬드 문제
다중 상속에서 동일한 비가상 기저 클래스가 서로 다른 경로로 두 번 이상 들어오면, 파생 객체 안에 동일 기저의 서브객체가 복수 생깁니다. 이를 다이아몬드 구조라고 부릅니다. 예를 들어 A를 B, C가 각각 상속하고 D가 B와 C를 동시에 상속하면, D 안에 A가 두 벌 생길 수 있어 A의 멤버에 접근할 때 모호함이 생기거나, 논리적으로 하나여야 할 기저 상태가 두 개로 갈라집니다.
이를 막으려면 B, C가 A를 virtual 상속하도록 하면 됩니다. 그러면 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: 다음 상황에서 사용하세요:
- “종류가 여러 개”인 경우: 동물(개, 고양이, 새…), 도형(원, 사각형, 삼각형…)
- “같은 인터페이스, 다른 구현”: 모두
draw()가 있지만 그리는 방법은 다름 - 런타임에 타입이 결정: 사용자 입력에 따라 어떤 객체를 쓸지 결정
쉬운 판단 기준: “이 함수를 자식 클래스마다 다르게 구현해야 하나?” → 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, 가상 상속, 추상 클래스, 가상 소멸자」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 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 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
이 글에서 다루는 키워드 (관련 검색어)
C++, virtual, polymorphism, 다형성, OOP, vtable, 가상상속 등으로 검색하시면 이 글이 도움이 됩니다.