C++ 이름 은닉(Name Hiding): 파생 클래스에서 기반 오버로드가 안 보일 때 using 선언
이 글의 핵심
베이스 클래스에 분명히 있는 함수가 파생 클래스 객체에서 호출되지 않는다면, 대개 이름 탐색이 파생 클래스 스코프에서 멈추는 이름 은닉 때문입니다. 시그니처가 달라도 이름만 같으면 가려지기 때문에 오버라이딩을 의도했다가 은닉을 만드는 실수가 흔합니다. using 선언을 쓰는 위치와 override 키워드가 어디까지 이런 실수를 잡아 주는지도 함께 짚습니다.
들어가며: “베이스 클래스 함수가 안 보여요”
C++에서 파생 클래스에서 같은 이름의 함수를 정의하면, 베이스 클래스의 모든 오버로드가 가려지는 이름 은닉(Name Hiding) 문제가 발생합니다.
// ❌ 이름 은닉
class Base {
public:
void foo(int x) {
std::cout << "Base::foo(int): " << x << '\n';
}
void foo(double x) {
std::cout << "Base::foo(double): " << x << '\n';
}
};
class Derived : public Base {
public:
void foo(std::string s) {
std::cout << "Derived::foo(string): " << s << '\n';
}
};
int main() {
Derived d;
d.foo("Hello"); // OK
d.foo(42); // ❌ 컴파일 에러: no matching function
d.foo(3.14); // ❌ 컴파일 에러
}
d.foo(42)의 에러 메시지는 보통 no matching function for call to 'Derived::foo(int)' 다음에 candidate: void Derived::foo(std::string)만 후보로 나열합니다. Base::foo(int)가 정확히 맞는 함수인데도 후보 목록에조차 나오지 않는다는 점이 이 문제의 핵심 단서입니다. 컴파일러가 Base를 아예 들여다보지 않았다는 뜻이기 때문입니다.
이 글에서 다루는 것:
- 이름 은닉이란?
- using 선언으로 해결
- 오버로딩 vs 오버라이딩
- 실전 예시
이름 은닉이란?
이름 은닉 발생
class Base {
public:
void foo(int x) {
std::cout << "Base::foo(int)\n";
}
};
class Derived : public Base {
public:
void foo(double x) { // Base::foo(int)를 가림
std::cout << "Derived::foo(double)\n";
}
};
int main() {
Derived d;
d.foo(3.14); // Derived::foo(double)
d.foo(42); // ⚠️ 컴파일은 됨: int가 double로 변환되어 Derived::foo(double) 호출
// (Base::foo(int)는 후보에도 오르지 않음)
}
이유: 파생 클래스의 foo가 베이스 클래스의 foo를 완전히 가림.
이 예제는 앞의 예제보다 더 위험합니다. Derived::foo(double)로 42를 받을 수 있으므로 에러 없이 컴파일되고, 개발자가 기대한 Base::foo(int) 대신 Derived::foo(double)이 조용히 호출됩니다. 에러가 나는 경우는 오히려 운이 좋은 편이고, 암시적 변환이 가능한 경우에는 동작만 달라진 채 테스트를 통과할 수 있습니다.
이 동작은 C++의 이름 탐색(name lookup) 규칙에서 나옵니다. 컴파일러는 d.foo를 만나면 먼저 Derived 스코프에서 foo라는 이름을 찾고, 하나라도 찾으면 거기서 탐색을 멈춥니다. 그다음에 찾은 후보들만으로 오버로드 해석을 합니다. 즉 “이름 찾기”가 “시그니처 맞추기”보다 먼저 일어나고, 기반 클래스는 파생 클래스에서 이름을 하나도 찾지 못했을 때만 탐색됩니다. 매개변수 타입이 달라도, virtual 여부가 달라도, 반환 타입이 달라도 이름이 같기만 하면 가려지는 이유가 이것입니다.
C++이 이렇게 설계된 이유는 기반 클래스가 나중에 바뀌었을 때 파생 클래스의 동작이 조용히 바뀌는 것을 막기 위해서입니다. 만약 모든 스코프의 foo를 한꺼번에 오버로드 해석한다면, 라이브러리 기반 클래스에 foo(int)가 새로 추가되는 순간 파생 클래스에서 foo(double)을 부르던 d.foo(42)가 갑자기 기반 클래스 함수로 바뀌게 됩니다. 은닉 규칙은 “파생 클래스가 정의한 이름은 파생 클래스가 책임진다”는 원칙이고, 기반 클래스의 오버로드가 필요하면 using으로 명시적으로 가져오게 한 것입니다.
using 선언
해결책: using 선언
class Base {
public:
void foo(int x) {
std::cout << "Base::foo(int)\n";
}
void foo(double x) {
std::cout << "Base::foo(double)\n";
}
};
class Derived : public Base {
public:
using Base::foo; // ✅ 베이스 클래스의 foo를 가져옴
void foo(std::string s) {
std::cout << "Derived::foo(string)\n";
}
};
int main() {
Derived d;
d.foo(42); // Base::foo(int)
d.foo(3.14); // Base::foo(double)
d.foo("Hello"); // Derived::foo(string)
}
using Base::foo;는 특정 시그니처가 아니라 이름 foo의 모든 오버로드를 파생 클래스 스코프로 가져옵니다. using Base::foo(int);처럼 하나만 고를 수는 없습니다. 가져온 함수와 파생 클래스의 함수가 같은 스코프에 있는 것처럼 함께 오버로드 해석에 참여하고, 시그니처가 정확히 같은 함수가 파생 클래스에 있으면 파생 클래스 쪽이 우선합니다.
using 선언을 둔 접근 지정 영역이 가져온 함수의 접근 수준이 됩니다. private: 아래에 using Base::foo;를 쓰면 기반 클래스에서 public이던 foo가 Derived를 통해서는 private이 됩니다. 반대로 기반 클래스의 protected 함수를 파생 클래스의 public 영역에서 using하면 공개할 수도 있습니다. 기반 클래스에서 private인 멤버는 파생 클래스에서 접근할 수 없으므로 using으로도 가져올 수 없습니다.
오버로딩 vs 오버라이딩
오버로딩: 같은 이름, 다른 시그니처
class MyClass {
public:
void foo(int x) {}
void foo(double x) {} // 오버로딩
void foo(std::string s) {} // 오버로딩
};
오버라이딩: 가상 함수 재정의
class Base {
public:
virtual void foo(int x) {
std::cout << "Base::foo\n";
}
};
class Derived : public Base {
public:
void foo(int x) override { // 오버라이딩
std::cout << "Derived::foo\n";
}
};
이름 은닉 vs 오버라이딩
class Base {
public:
virtual void foo(int x) {
std::cout << "Base::foo(int)\n";
}
virtual void foo(double x) {
std::cout << "Base::foo(double)\n";
}
};
class Derived : public Base {
public:
void foo(int x) override { // Base::foo(int) 오버라이딩
std::cout << "Derived::foo(int)\n";
}
// Base::foo(double)는 가려짐!
};
int main() {
Derived d;
d.foo(42); // Derived::foo(int)
d.foo(3.14); // ⚠️ 컴파일은 됨: 3.14가 int로 잘려 Derived::foo(int) 호출
}
세 개념을 한 줄로 구분하면 다음과 같습니다. 오버로딩은 같은 스코프 안에서 이름이 같고 매개변수가 다른 함수들이고, 오버라이딩은 파생 클래스가 기반 클래스의 가상 함수를 같은 시그니처로 다시 정의해 동적 디스패치를 바꾸는 것이며, 은닉은 파생 클래스 스코프에 같은 이름이 생겨 기반 클래스의 이름이 탐색에서 가려지는 것입니다. 이 예제는 오버라이딩과 은닉이 동시에 일어납니다. foo(int)는 제대로 오버라이딩되었지만, 같은 이름을 선언했기 때문에 foo(double)은 가려졌습니다.
d.foo(3.14)는 에러가 아니라 double에서 int로의 축소 변환을 거쳐 Derived::foo(int)를 호출합니다. 3.14가 3이 되어 전달되므로 첫 예제보다 결과가 더 틀어지는데, -Wconversion을 켜지 않으면 경고도 없습니다. 흥미로운 점은 Base& b = d; b.foo(3.14);처럼 기반 클래스 참조로 호출하면 Base 스코프에서 탐색하므로 Base::foo(double)이 정상적으로 호출된다는 것입니다. 같은 객체라도 어떤 타입의 표현식으로 호출하느냐에 따라 다른 함수가 불리는 셈이라, 이런 코드는 디버깅할 때 매우 혼란스럽습니다. 가상 함수 오버로드 집합 중 일부만 오버라이딩할 때 GCC·Clang의 -Woverloaded-virtual 경고가 바로 이 상황을 알려 줍니다(Clang은 -Wall에 포함, GCC는 13부터 -Wall에 일부 포함).
해결책:
class Derived : public Base {
public:
using Base::foo; // ✅ Base::foo(double) 가져옴
void foo(int x) override {
std::cout << "Derived::foo(int)\n";
}
};
실전 예시
예시 1: 생성자
class Base {
public:
Base(int x) {
std::cout << "Base(int)\n";
}
Base(double x) {
std::cout << "Base(double)\n";
}
};
class Derived : public Base {
public:
using Base::Base; // ✅ 베이스 생성자 상속
Derived(std::string s) : Base(0) {
std::cout << "Derived(string)\n";
}
};
int main() {
Derived d1(42); // Base(int)
Derived d2(3.14); // Base(double)
Derived d3("Hello"); // Derived(string)
}
생성자는 엄밀히 말하면 이름 은닉과 다른 이유로 “보이지 않습니다”. 생성자는 상속되지 않는 것이 기본이라, using이 없으면 Derived d1(42);는 Derived에 int를 받는 생성자가 없어서 에러가 납니다. C++11의 상속 생성자(using Base::Base;)는 기반 클래스의 생성자 목록을 파생 클래스 생성자처럼 쓸 수 있게 해 줍니다. 파생 클래스에 같은 시그니처의 생성자를 직접 정의하면 그쪽이 우선합니다.
주의할 점은 상속된 생성자로 객체를 만들면 파생 클래스에서 추가한 멤버는 기본 초기화만 된다는 것입니다. Derived에 int count_; 같은 멤버가 있다면 지역 변수로 만든 Derived d1(42)에서 count_는 초기화되지 않은(쓰레기) 값이 됩니다. 상속 생성자를 쓸 때는 파생 클래스 멤버에 int count_ = 0;처럼 기본값을 선언해 두어야 안전합니다. 또 복사·이동 생성자와 기본 생성자는 상속 대상에서 제외됩니다.
예시 2: 연산자 오버로딩
class Base {
public:
void operator()(int x) {
std::cout << "Base::operator()(int)\n";
}
};
class Derived : public Base {
public:
using Base::operator(); // ✅ 베이스 operator() 가져옴
void operator()(std::string s) {
std::cout << "Derived::operator()(string)\n";
}
};
int main() {
Derived d;
d(42); // Base::operator()(int)
d("Hello"); // Derived::operator()(string)
}
연산자도 이름이 operator(), operator=, operator<<인 함수일 뿐이므로 같은 규칙이 적용됩니다. 특히 operator=는 컴파일러가 모든 클래스에 복사 대입 연산자를 암시적으로 선언하므로, 기반 클래스에 operator=(int) 같은 대입 연산자를 정의해 두어도 파생 클래스의 암시적 operator=가 이를 항상 가립니다. 파생 클래스에서 d = 5;가 되기를 원한다면 using Base::operator=;가 필요합니다.
제가 이 문제를 가장 자주 본 곳은 이벤트 핸들러나 방문자(visitor) 계층이었습니다. 기반 클래스에 handle(const KeyEvent&), handle(const MouseEvent&)처럼 이벤트 타입별 가상 함수 오버로드를 두고, 파생 클래스는 관심 있는 이벤트 하나만 오버라이딩하는 구조입니다. 파생 클래스를 통해 직접 handler.handle(mouseEvent)를 호출하는 코드가 생기는 순간 은닉이 문제가 됩니다. MouseEvent가 KeyEvent로 변환되지 않으면 컴파일 에러로 드러나지만, 이벤트 타입들이 공통 기반 클래스를 가지고 있고 오버로드 중 하나가 그 기반 클래스를 받는다면 엉뚱한 핸들러가 조용히 불립니다. 이런 계층에서는 오버로드 대신 onKey, onMouse처럼 이름 자체를 다르게 짓는 것이 가장 확실한 예방책입니다.
예시 3: 템플릿 기반 클래스 (비슷해 보이는 다른 문제)
template<typename T>
class Base {
public:
void log(const char* msg) { std::cout << msg << '\n'; }
};
template<typename T>
class Derived : public Base<T> {
public:
void run() {
// log("start"); // ❌ 에러: there are no arguments to 'log'
// that depend on a template parameter
this->log("start"); // ✅ this->로 기반 클래스 멤버임을 알림
Base<T>::log("again"); // ✅ 한정 이름 (단, 가상 함수라면 동적 디스패치가 꺼짐)
}
using Base<T>::log; // ✅ 또는 using 선언으로 한 번에
};
파생 클래스에 log라는 이름이 없는데도 에러가 난다는 점에서 이름 은닉과는 원인이 다릅니다. 기반 클래스가 템플릿 매개변수 T에 의존하면, 컴파일러는 템플릿을 정의하는 시점에 그 기반 클래스를 탐색하지 않습니다. Base<T>가 나중에 어떤 T에 대해 특수화되어 log가 없을 수도 있기 때문입니다(두 단계 이름 탐색). MSVC는 예전에 이 규칙을 느슨하게 적용해서 Windows에서 잘 빌드되던 코드가 GCC·Clang이나 /permissive- 옵션에서 이 에러를 내는 경우가 흔합니다. 해결 방법이 using 선언과 겹치기 때문에 함께 알아 두면 좋습니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. override 키워드를 붙이면 이름 은닉 실수를 막을 수 있나요?
A. override는 const 누락이나 매개변수 타입 차이처럼 시그니처가 어긋나 의도와 달리 새 함수가 만들어지는 경우를 컴파일 에러로 잡아 줍니다. 하지만 파생 클래스에서 같은 이름의 함수를 하나 선언하면 기반 클래스의 다른 오버로드가 모두 가려지는 현상 자체는 막지 못하므로 using Base::f;가 여전히 필요합니다. GCC와 Clang의 -Woverloaded-virtual 경고를 켜 두면 가상 함수 오버로드가 가려지는 경우를 빌드 단계에서 알 수 있습니다.