C++ override와 final: 시그니처 실수를 컴파일 에러로 잡기와 devirtualization

이 글의 핵심

기반 클래스의 가상 함수 시그니처가 바뀌었는데 파생 클래스는 그대로라면, override가 없는 코드는 에러 없이 기반 함수를 호출하는 버그가 됩니다. 이 글은 도형, 로거, 템플릿 메서드 예제로 override와 final을 함께 쓰는 패턴을 보여주고, final 클래스와 final 함수 중 무엇을 고를지, 성능 향상을 기대할 때 측정에서 주의할 점을 정리합니다.

override란?

override 는 C++11에서 도입된 키워드로, 가상 함수를 오버라이드한다는 것을 명시적으로 표시합니다. 컴파일러가 실제로 기반 클래스의 가상 함수를 오버라이드하는지 검증하여, 오타나 시그니처 불일치로 인한 버그를 방지합니다.

class Base {
public:
    virtual void func() {
        std::cout << "Base::func" << std::endl;
    }
};

class Derived : public Base {
public:
    // override 명시
    void func() override {
        std::cout << "Derived::func" << std::endl;
    }
};

왜 필요한가?:

  • 오타 방지: 함수 이름 오타 시 컴파일 에러
  • 시그니처 검증: 매개변수나 const 불일치 감지
  • 명확한 의도: 코드 리더에게 오버라이드 의도 전달
  • 리팩토링 안전: 기반 클래스 변경 시 파생 클래스 오류 감지
class Base {
public:
    virtual void process(int x) {}
};

class Derived : public Base {
public:
    // ❌ override 없음: 오타 발견 못함
    void proccess(int x) {}  // 새 함수 생성 (버그!)
    
    // ✅ override 사용: 컴파일 에러
    void proccess(int x) override {}  // 에러: 오버라이드할 함수 없음
};

override의 동작 원리:

override는 컴파일러에게 “이 함수는 기반 클래스의 가상 함수를 오버라이드한다”고 알립니다. 컴파일러는 다음을 검증합니다:

  1. 기반 클래스에 같은 이름의 가상 함수가 있는지
  2. 시그니처(매개변수, const, 반환 타입)가 일치하는지
  3. 기반 클래스 함수가 final이 아닌지
class Base {
public:
    virtual void func(int x) const {}
};

class Derived : public Base {
public:
    // 모두 컴파일 에러
    // void func(int x) override {}           // 에러: const 누락
    // void func(double x) const override {}  // 에러: 매개변수 타입 다름
    // int func(int x) const override {}      // 에러: 반환 타입 다름 (공변 반환 제외)
    
    // ✅ 올바른 오버라이드
    void func(int x) const override {}
};

가상 함수 오버라이드란 무엇인가

기반 클래스에 virtual이 붙은 멤버 함수는 동적 디스패치 대상입니다. 파생 클래스에서 같은 시그니처로 선언하면, 기반 포인터/참조를 통해 호출할 때 파생 구현이 실행됩니다. 이것이 오버라이드입니다.

  • vtable: 대부분의 구현에서 가상 함수는 함수 포인터 테이블을 통해 간접 호출됩니다.
  • 시그니처 일치: 이름·매개변수·const·참조 한정자·일부 반환 타입(공변 반환)까지 맞아야 같은 슬롯을 덮어씁니다. 하나라도 다르면 새 함수가 될 수 있어 버그가 납니다.
struct Base {
    virtual void f(int) const {}
    virtual ~Base() = default;
};
struct Derived : Base {
    void f(int) const override {}  // Base::f를 오버라이드
};

override 키워드가 중요한 이유

override는 컴파일러에게 검증을 맡기는 표식입니다. 없으면 다음 실수가 조용히 통과합니다.

  1. 이름 오타: draw 대신 drwa — 새 함수가 생김.
  2. const 누락: 기반은 void f() const, 파생은 void f() — 오버라이드 아님.
  3. 매개변수 타입 변경: int → size_t 등 — 또 다른 새 함수.
  4. 기반 쪽 변경: 기반에서 virtual이 빠지거나 시그니처가 바뀌면, 파생은 전혀 다른 함수를 들고 있을 수 있음.

override를 붙이면 위 경우 즉시 컴파일 에러가 나므로, 다형성 계약이 깨지는 것을 빌드 단계에서 막습니다. GCC는 이렇게 알려 줍니다.

error: 'void Derived::f(int)' marked 'override', but does not override

4번이 override가 가장 큰 값을 하는 경우입니다. 처음 코드를 작성할 때는 시그니처를 맞춰 쓰기 때문에 실수가 드물지만, 몇 달 뒤 누군가 기반 클래스의 process(int)를 process(int, Options)로 바꾸면 override가 없는 파생 클래스들은 모두 컴파일되면서 “이제 아무도 호출하지 않는 함수”가 됩니다. 테스트가 파생 클래스를 직접 호출한다면 통과하고, 기반 포인터로 호출하는 운영 코드만 기본 구현으로 동작하게 됩니다. 제가 레거시 코드에 override를 일괄로 붙이는 작업을 할 때마다 이렇게 이미 끊어져 있던 오버라이드가 몇 개씩 발견되곤 합니다.

기존 코드에서 빠진 override를 찾으려면 GCC의 -Wsuggest-override나 clang-tidy의 modernize-use-override를 쓰면 됩니다. Clang은 한 클래스 안에서 일부 함수에만 override를 붙이면 -Winconsistent-missing-override 경고를 기본으로 내므로, 하나라도 붙이기 시작했다면 클래스 전체에 일관되게 붙이는 것이 좋습니다.

override의 장점

class Base {
public:
    virtual void func(int x) {}
};

class Derived : public Base {
public:
    // ❌ 오타 (새 함수 생성)
    void fucn(int x) {}  // 오타!
    
    // ✅ override (컴파일 에러)
    void fucn(int x) override {}  // 에러: 오버라이드할 함수 없음
};

final이란?

final 은 C++11에서 도입된 키워드로, 상속이나 오버라이드를 금지합니다. 클래스에 사용하면 더 이상 상속할 수 없으며, 가상 함수에 사용하면 더 이상 오버라이드할 수 없습니다.

// 클래스 final: 상속 금지
class FinalClass final {
public:
    void func() {}
};

// class Derived : public FinalClass {};  // 에러

// 함수 final: 오버라이드 금지
class Base {
public:
    virtual void func() final {}
};

class Derived : public Base {
public:
    // void func() override {}  // 에러: final 함수
};

왜 필요한가?:

  • 설계 의도 명시: 더 이상 확장하지 않을 클래스/함수 표시
  • 불변식 보호: 중요한 로직이 파생 클래스에서 바뀌지 않도록 막음 (컴파일 타임 규칙일 뿐, 악의적인 코드를 막는 보안 장치는 아님)
  • 성능 최적화: 컴파일러가 devirtualization 수행 가능
  • 안정성: 예상치 못한 상속으로 인한 버그 방지
// ❌ final 없음: 의도하지 않은 상속 가능
class SecurityManager {
public:
    virtual void authenticate() {
        // 중요한 보안 로직
    }
};

class HackedManager : public SecurityManager {
public:
    void authenticate() override {
        // 보안 우회!
    }
};

// ✅ final 사용: 상속 금지
class SecurityManager final {
public:
    void authenticate() {
        // 중요한 보안 로직 (안전)
    }
};

// class HackedManager : public SecurityManager {};  // 에러

final을 어긴 코드는 다음과 같은 에러를 냅니다. 클래스를 상속하면 cannot derive from 'final' base 'Base' in derived type 'Derived', 함수를 재정의하면 virtual function 'virtual void Derived::func()' overriding final function입니다. 참고로 override와 final은 예약어가 아니라 문맥 키워드라서, 이 위치가 아닌 곳에서는 int final = 0; 같은 변수 이름으로 쓸 수 있습니다. C++11 이전 코드와의 호환을 위해 이렇게 설계되었습니다.

final의 성능 이점:

final을 사용하면 컴파일러가 devirtualization을 수행할 수 있습니다. 가상 함수 호출을 직접 호출로 변환하여 vtable 조회 오버헤드를 제거합니다.

class Base {
public:
    virtual void func() final {
        // 더 이상 오버라이드 없음
        // 컴파일러가 직접 호출로 최적화 가능
    }
};

// 호출 시
Base* ptr = new Base();
ptr->func();  // 직접 호출로 최적화 가능 (vtable 조회 불필요)

final 클래스 vs final 함수:

특징final 클래스final 함수
적용 대상클래스 전체특정 가상 함수
효과상속 금지오버라이드 금지
사용 위치클래스 선언가상 함수 선언
성능모든 가상 함수 최적화해당 함수만 최적화
// final 클래스
class FinalClass final {
    virtual void func1() {}
    virtual void func2() {}
};

// final 함수
class Base {
    virtual void func1() final {}  // 이 함수만 final
    virtual void func2() {}        // 오버라이드 가능
};

final 클래스 vs final 함수 — 선택 가이드

구분의미쓰는 때
class D finalD를 상속할 수 없음타입 계층을 여기서 끝낸다고 확정할 때(보안·성능·불변식).
virtual void f() finalf는 파생에서 더 이상 오버라이드 불가파이프라인의 한 단계만 고정하고 나머지 훅은 열어둘 때.

final 클래스는 해당 클래스의 모든 가상 호출에 대해 “이 아래로는 더 이상 파생이 없다”는 정보를 컴파일러에 줄 수 있어 최적화(아래 ‘성능’ 참고)에 유리한 경우가 있습니다. final 함수는 한 메서드만 닫을 때 씁니다. 확장 가능성을 나중에 열어야 할지 불확실하면 남용하지 않는 것이 좋습니다.

실전 예시

예시 1: 도형 클래스

class Shape {
public:
    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 {
        std::cout << "Drawing Circle" << std::endl;
    }
};

class Rectangle final : public Shape {
private:
    double width, height;
    
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    
    double area() const override final {
        return width * height;
    }
    
    void draw() const override {
        std::cout << "Drawing Rectangle" << std::endl;
    }
};

// class Square : public Rectangle {};  // 에러: final 클래스

예시 2: 로거 계층

class Logger {
public:
    virtual void log(const std::string& message) {
        std::cout << "[LOG] " << message << std::endl;
    }
    
    virtual ~Logger() = default;
};

class FileLogger : public Logger {
private:
    std::ofstream file;
    
public:
    FileLogger(const std::string& filename) : file(filename) {}
    
    void log(const std::string& message) override {
        file << "[FILE] " << message << std::endl;
    }
};

class SecureLogger final : public FileLogger {
public:
    SecureLogger(const std::string& filename) 
        : FileLogger(filename) {}
    
    void log(const std::string& message) override final {
        // 암호화 후 로깅
        FileLogger::log("ENCRYPTED: " + message);
    }
};

예시 3: 템플릿 메서드 패턴

class GameCharacter {
public:
    void takeDamage(int damage) {
        beforeDamage();
        applyDamage(damage);
        afterDamage();
    }
    
    virtual ~GameCharacter() = default;
    
protected:
    virtual void beforeDamage() {}
    virtual void applyDamage(int damage) = 0;
    virtual void afterDamage() {}
};

class Warrior : public GameCharacter {
private:
    int health = 100;
    int armor = 50;
    
protected:
    void beforeDamage() override {
        std::cout << "방어 자세" << std::endl;
    }
    
    void applyDamage(int damage) override final {
        int actualDamage = std::max(0, damage - armor);
        health -= actualDamage;
        std::cout << "데미지: " << actualDamage << std::endl;
    }
    
    void afterDamage() override {
        if (health <= 0) {
            std::cout << "전사 사망" << std::endl;
        }
    }
};

예시 4: 인터페이스 구현

class IDatabase {
public:
    virtual void connect() = 0;
    virtual void disconnect() = 0;
    virtual void query(const std::string& sql) = 0;
    virtual ~IDatabase() = default;
};

class MySQLDatabase : public IDatabase {
public:
    void connect() override {
        std::cout << "MySQL 연결" << std::endl;
    }
    
    void disconnect() override {
        std::cout << "MySQL 연결 해제" << std::endl;
    }
    
    void query(const std::string& sql) override {
        std::cout << "MySQL 쿼리: " << sql << std::endl;
    }
};

class PostgreSQLDatabase final : public IDatabase {
public:
    void connect() override {
        std::cout << "PostgreSQL 연결" << std::endl;
    }
    
    void disconnect() override {
        std::cout << "PostgreSQL 연결 해제" << std::endl;
    }
    
    void query(const std::string& sql) override final {
        std::cout << "PostgreSQL 쿼리: " << sql << std::endl;
    }
};

override와 const

class Base {
public:
    virtual void func() const {}
};

class Derived : public Base {
public:
    // ❌ const 누락 (새 함수)
    void func() {}
    
    // ✅ override (에러 발견)
    void func() override {}  // 에러: const 불일치
    
    // ✅ 올바른 오버라이드
    void func() const override {}
};

자주 발생하는 문제

문제 1: 오타

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

class Derived : public Base {
public:
    // ❌ 오타 (새 함수 생성)
    void proccess() {}  // 오타!
    
    // ✅ override (에러 발견)
    void proccess() override {}  // 에러
};

문제 2: 시그니처 불일치

class Base {
public:
    virtual void func(int x) {}
};

class Derived : public Base {
public:
    // ❌ 매개변수 타입 다름
    void func(double x) {}
    
    // ✅ override (에러 발견)
    void func(double x) override {}  // 에러
};

문제 3: final 상속 시도

class Base final {
public:
    void func() {}
};

// ❌ final 클래스 상속
// class Derived : public Base {};  // 에러

// ✅ 컴포지션 사용
class Derived {
private:
    Base base;
    
public:
    void func() {
        base.func();
    }
};

문제 4: final 함수 오버라이드

class Base {
public:
    virtual void func() final {}
};

class Derived : public Base {
public:
    // ❌ final 함수 오버라이드
    // void func() override {}  // 에러
};

override와 final 조합

class Base {
public:
    virtual void func1() {}
    virtual void func2() {}
};

class Middle : public Base {
public:
    void func1() override {
        // 오버라이드만
    }
    
    void func2() override final {
        // 오버라이드하고 더 이상 오버라이드 금지
    }
};

class Derived : public Middle {
public:
    void func1() override {
        // OK
    }
    
    // void func2() override {}  // 에러: final
};

성능 고려사항

// final 클래스: devirtualization 가능
class FinalClass final {
public:
    virtual void func() {
        // 컴파일러가 직접 호출로 최적화 가능
    }
};

// final 함수: 인라인 가능
class Base {
public:
    virtual void func() final {
        // 더 이상 오버라이드 없음 → 인라인 가능
    }
};

성능 영향: vtable, devirtualization, 측정 시 유의점

  • 일반적인 가상 호출: 정적 분석만으로는 대상 함수가 확정되지 않으면 간접 분기(vtable 경유)가 됩니다. 나노초 단위라도 핫 루프에서는 누적될 수 있습니다.
  • final / final class: 파생이 없다는 사실이 알려지면 컴파일러는 직접 호출로 바꾸거나 인라인할 여지가 커집니다(구현·최적화 수준 의존, UB 없이 가능한 경우).
  • override 자체: 컴파일 타임 표식이라 런타임 비용은 없습니다. 오히려 잘못된 오버라이드를 막아 불필요한 가상 호출을 줄이는 데 도움이 됩니다.

실무: final을 성능 때문에 붙이기 전에 프로파일로 병목이 가상 호출인지 확인하는 것이 안전합니다. API를 닫는 것은 설계·유지보수와의 트레이드오프가 더 큽니다.

devirtualization이 일어나려면 컴파일러가 호출 지점에서 정적 타입이 final 클래스(또는 final 함수를 가진 타입)라는 것을 알아야 합니다. Base*로 호출하는 코드는 Derived가 final이어도 여전히 가상 호출입니다. 실제 대상이 Derived인지 컴파일러가 알 수 없기 때문입니다. 반대로 Derived&나 Derived*로 호출하는 코드라면 final 덕분에 더 파생된 클래스가 없다는 것이 보장되어 직접 호출이나 인라인이 가능해집니다. 그래서 성능 효과는 “구체 타입으로 호출하는 코드가 얼마나 많은가”에 달려 있고, 인터페이스 포인터로만 다루는 설계에서는 거의 차이가 없습니다. 링크 타임 최적화(LTO)와 -fwhole-program-vtables 같은 옵션을 쓰면 final 없이도 파생 클래스가 없는 경우를 컴파일러가 알아내는 경우가 있습니다.

final의 설계상 비용은 주로 테스트에서 드러납니다. 목(mock) 객체를 만들 때 대상 클래스를 상속해 가상 함수를 바꾸는 방식이 흔한데, 구현 클래스가 final이면 이 방법을 쓸 수 없습니다. 그래서 구현 클래스를 final로 닫을 때는 테스트가 그 클래스가 아니라 인터페이스(IPaymentProcessor 같은 추상 클래스)에 의존하도록 설계해 두어야 합니다.

실무 패턴

패턴 1: 인터페이스 + final 구현

// 인터페이스 (순수 가상 함수)
class IPaymentProcessor {
public:
    virtual void processPayment(double amount) = 0;
    virtual bool validateCard(const std::string& cardNumber) = 0;
    virtual ~IPaymentProcessor() = default;
};

// final 구현 (더 이상 확장 불필요)
class SecurePaymentProcessor final : public IPaymentProcessor {
public:
    void processPayment(double amount) override {
        if (validateCard(currentCard_)) {
            // 결제 처리
            std::cout << "결제 완료: $" << amount << '\n';
        }
    }
    
    bool validateCard(const std::string& cardNumber) override final {
        // 보안 검증 로직 (변경 금지)
        return cardNumber.length() == 16;
    }
    
private:
    std::string currentCard_;
};

패턴 2: 템플릿 메서드 + final

class DataProcessor {
public:
    // 템플릿 메서드 (변경 금지)
    void process() final {
        loadData();
        validateData();
        transformData();
        saveData();
    }
    
    virtual ~DataProcessor() = default;
    
protected:
    virtual void loadData() = 0;
    virtual void validateData() = 0;
    virtual void transformData() = 0;
    virtual void saveData() = 0;
};

class CSVProcessor : public DataProcessor {
protected:
    void loadData() override {
        std::cout << "CSV 로드\n";
    }
    
    void validateData() override {
        std::cout << "CSV 검증\n";
    }
    
    void transformData() override {
        std::cout << "CSV 변환\n";
    }
    
    void saveData() override {
        std::cout << "CSV 저장\n";
    }
};

패턴 3: 계층적 final

class Base {
public:
    virtual void step1() {}
    virtual void step2() {}
    virtual void step3() {}
    virtual ~Base() = default;
};

class Middle : public Base {
public:
    void step1() override final {
        // step1은 더 이상 오버라이드 불가
    }
    
    void step2() override {
        // step2는 계속 오버라이드 가능
    }
};

class Derived : public Middle {
public:
    // void step1() override {}  // 에러: final
    
    void step2() override {
        // OK
    }
    
    void step3() override {
        // OK
    }
};

FAQ

Q1: override는 언제 사용해야 하나요?

A: 가상 함수를 오버라이드할 때 항상 사용하세요. 오타, 시그니처 불일치, const 누락 등을 컴파일 타임에 감지합니다.

class Base {
public:
    virtual void process(int x) const {}
};

class Derived : public Base {
public:
    // ✅ override 사용: 모든 오류 감지
    void process(int x) const override {}
    
    // ❌ override 없음: 오류 발견 못함
    // void proccess(int x) const {}  // 오타
    // void process(int x) {}         // const 누락
    // void process(double x) const {} // 타입 다름
};

Q2: final은 언제 사용해야 하나요?

A:

  • 더 이상 상속/오버라이드가 불필요할 때: 설계가 완성됨
  • 보안/안정성이 중요할 때: 중요한 로직 보호
  • 성능 최적화가 필요할 때: devirtualization 활용
// 보안: 인증 로직 보호
class AuthenticationManager final {
    // 더 이상 상속 불가
};

// 성능: 최적화 가능
class HighPerformanceClass {
public:
    virtual void criticalPath() final {
        // devirtualization 가능
    }
};

Q3: override 없이도 동작하나요?

A: 네, 동작합니다. 하지만 override를 사용하는 것이 강력히 권장됩니다. 안전성과 명확성을 크게 향상시킵니다.

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

class Derived : public Base {
public:
    // 동작하지만 권장하지 않음
    void func() {}
    
    // 권장: override 사용
    void func() override {}
};

Q4: final의 성능 이점은?

A: devirtualization과 인라인 최적화가 가능합니다. 가상 함수 호출 오버헤드(vtable 조회)를 제거할 수 있습니다.

class Base {
public:
    virtual void func() final {
        // 컴파일러가 직접 호출로 최적화 가능
    }
};

// 호출 시
Base* ptr = new Base();
ptr->func();  // vtable 조회 없이 직접 호출 가능

성능 비교:

  • 일반 가상 함수: vtable 조회 + 간접 호출. 호출 자체의 비용은 작지만 인라인이 막혀 주변 최적화가 줄어드는 영향이 더 큼
  • final 함수(구체 타입으로 호출할 때): 직접 호출 + 인라인 가능

실제 차이는 함수 본문 크기, 분기 예측, 캐시 상태에 따라 크게 달라지므로 고정된 나노초 수치로 판단하지 말고 해당 코드에서 측정해야 합니다.

Q5: override와 virtual의 차이는?

A:

  • virtual: 기반 클래스에서 가상 함수 선언
  • override: 파생 클래스에서 오버라이드 명시
class Base {
public:
    virtual void func() {}  // virtual: 가상 함수 선언
};

class Derived : public Base {
public:
    void func() override {}  // override: 오버라이드 명시
};

함께 쓸 필요는 없음: 파생 클래스에서 virtual void func() override처럼 둘 다 써도 컴파일되지만, override가 이미 가상 함수임을 뜻하므로 virtual은 중복입니다. C++ Core Guidelines(C.128)도 virtual, override, final 중 정확히 하나만 쓰라고 권합니다.

Q6: override와 final을 함께 사용할 수 있나요?

A: 예, 가능합니다. 기반 클래스 함수를 오버라이드하면서 동시에 더 이상의 오버라이드를 금지합니다.

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

class Middle : public Base {
public:
    void func() override final {
        // 오버라이드하고 더 이상 오버라이드 금지
    }
};

class Derived : public Middle {
public:
    // void func() override {}  // 에러: final
};

Q7: final 클래스를 사용하면 어떤 단점이 있나요?

A: 확장성 제한이 주요 단점입니다. 나중에 기능을 확장하고 싶어도 불가능합니다.

class FinalClass final {
    // 좋은 설계였지만...
};

// 나중에 확장이 필요해도 불가능
// class ExtendedClass : public FinalClass {};  // 에러

권장: 정말 확실할 때만 final 사용. 불확실하면 final 없이 설계.

Q8: 소멸자에도 override를 붙이나요?

A: 기반 클래스 소멸자가 virtual이면 파생 클래스 소멸자에 ~Derived() override;를 붙일 수 있고, 기반 소멸자가 가상이 아니면 컴파일 에러가 나므로 가상 소멸자 누락을 잡는 용도로 쓸 수 있습니다. 소멸자는 이름이 달라도 서로 오버라이드되는 특별한 경우입니다. 규칙 자체는 cppreference의 override와 final 페이지에 정리되어 있습니다.

관련 글: Virtual Functions, Inheritance.

override는 가상 함수 오버라이드를 명시하며, final은 상속/오버라이드를 금지하는 C++11 키워드입니다.


같이 보면 좋은 글