← 문제로

23. 이름 숨김(name hiding)과 오버로드 해석의 스코프 규칙

난이도 최상
내 리뷰 · C++
해설 · C++

해설 — 이름 숨김(name hiding)과 오버로드 해석의 스코프 규칙

난이도: 최상

답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트

요약

LoginHandlerHandle(int) 를 새로 선언하는 순간, 기반 클래스 PacketHandlerBaseHandle 이라는 이름을 가진 오버로드 전체(세 개 모두)가 파생 클래스 스코프에서 가려진다. C++ 의 이름 조회(name lookup)는 "가장 안쪽 스코프에서 그 이름을 찾으면 즉시 멈추고, 그 스코프 안에서만 오버로드 해석을 수행"하기 때문이다 — 인자 타입이 더 잘 맞는 함수가 바깥 스코프(기반 클래스)에 있어도 조회 자체가 거기까지 가지 않는다. 그래서 lh.Handle("ping")Base::Handle(const char*) 가 있음에도 컴파일 에러(LoginHandler::Handle(int)const char* 를 넣으려다 실패)가 난다. 반면 base->Handle(42) 처럼 기반 클래스 포인터로 호출하면 이름 조회가 PacketHandlerBase 스코프에서 이뤄지므로 숨김의 영향을 받지 않고, Handle(int) 는 비가상이라 정적 타입 기준으로 Base::Handle(int) 가 호출된다(동적 타입은 무시). Handle(double) 은 가상이고 파생 클래스가 정확히 같은 시그니처로 오버라이드 했으므로, 정적/동적 타입 어느 경로로 불러도 vtable을 통해 LoginHandler::Handle(double) 이 호출된다.

출력 예측

LoginHandler::Handle(int) code=42
LoginHandler::Handle(double) value=3.1
Base::Handle(int) code=42
LoginHandler::Handle(double) value=3.1

그리고 주석 처리된 lh.Handle("ping"); 의 주석을 풀면:

error: invalid conversion from 'const char*' to 'int'
note: initializing argument 1 of 'void LoginHandler::Handle(int)'

컴파일 자체가 실패한다. Base::Handle(const char*) 가 더 정확히 맞는 오버로드임에도 후보에 오르지 못한다.

핵심 개념

  • 이름 조회는 2단계: (1) 스코프에서 이름 찾기 → (2) 그 스코프 안에서 오버로드 해석. 파생 클래스 스코프에 Handle 이라는 이름이 하나라도 선언돼 있으면, 조회는 거기서 멈춘다. 기반 클래스 스코프까지 올라가서 "더 잘 맞는 오버로드가 있는지" 찾지 않는다.
  • 이름 숨김(name hiding)은 오버로딩이 아니라 스코프 규칙. 파생 클래스의 Handle(int) 은 기반의 Handle(int) 를 "오버로드"하는 게 아니라, 기반 클래스 스코프에 있는 Handle 이라는 이름 전체를 가린다(hide). 시그니처가 다르든 같든 상관없이 이름 단위로 가려진다.
  • using Base::Handle; 로 끌어오기(using-declaration). 파생 클래스 안에 using PacketHandlerBase::Handle; 을 선언하면, 기반의 Handle 오버로드 세트가 파생 클래스 스코프에 함께 도입되어 파생 자신의 Handle(int) 와 한 오버로드 집합을 이룬다. 이렇게 하면 lh.Handle("ping") 도 정상적으로 Base::Handle(const char*) 를 찾는다.
  • 비가상 함수의 "숨김"과 가상 함수의 "오버라이드"는 다른 메커니즘. Handle(double) 처럼 시그니처가 정확히 일치하고 virtual 인 경우엔 오버라이드가 되어 vtable 슬롯을 교체한다 (동적 디스패치). Handle(int) 처럼 파생 쪽이 virtual 을 안 붙였어도 시그니처가 같으면 여전히 오버라이드가 성립하지만(암묵적으로 virtual 유지), 이 문제의 핵심은 오버라이드 여부가 아니라 이름 조회 단계에서 나머지 오버로드들이 안 보이게 된다는 스코프 문제다.

단계별 내부 동작

  1. 컴파일러가 lh.Handle(42) 를 컴파일할 때, lh 의 정적 타입 LoginHandler 스코프에서 Handle 이름을 찾는다. LoginHandler::Handle(int) 가 발견되는 즉시 조회를 멈춘다 (기반 클래스 스코프는 아예 보지 않는다).
  2. 그 스코프(LoginHandler) 안에서만 오버로드 해석을 수행: 후보가 Handle(int) 하나뿐이라 바로 선택된다. 비가상이므로 정적 바인딩으로 LoginHandler::Handle(int) 호출.
  3. lh.Handle(3.14): LoginHandler 스코프에서 Handle(double) 이 발견돼 조회가 멈춘다. 가상 함수이므로 vtable 조회를 거치지만, 동적 타입도 LoginHandler 라 결과는 동일하게 LoginHandler::Handle(double).
  4. lh.Handle("ping") (주석 해제 시): LoginHandler 스코프에서 Handle 을 찾고 멈추므로 후보는 Handle(int) 뿐. const char*int 암묵적 변환이 불가능해(포인터→정수는 금지) 오버로드 해석 실패 → 컴파일 에러.
  5. base->Handle(42): base 의 정적 타입은 PacketHandlerBase* 이므로 이름 조회가 PacketHandlerBase 스코프에서 이뤄진다(파생 클래스의 숨김 규칙이 적용될 여지가 없다 — 애초에 그 스코프를 보지 않으므로). Handle(int) 는 비가상이라 정적 타입 기준으로 Base::Handle(int) 를 직접 호출(동적 타입 LoginHandler 는 무시).
  6. base->Handle(3.14): Handle(double) 은 가상이므로, 정적 타입에서 오버로드 해석으로 슬롯을 찾은 뒤 vtable 을 통해 동적 타입(LoginHandler)의 오버라이드로 디스패치된다 → LoginHandler::Handle(double).

흔한 오해·함정

  • "파생 클래스가 오버로드 하나만 새로 선언하면 나머지 오버로드는 자동으로 상속된다" ✗ — 가장 흔한 오해. 이름이 같으면 시그니처와 무관하게 기반의 오버로드 세트 전체가 숨겨진다. 이는 컴파일 에러가 아니라 "호출 시 원치 않는 함수가 선택되거나 컴파일 실패"로 나타나서 발견이 늦어지기 쉽다.
  • "이름 숨김은 가상 함수에만 적용된다" ✗ — 가상 여부와 무관하게 스코프/이름 조회 규칙 이다. 비가상 함수도 똑같이 숨긴다. 이 문제의 Handle(int) 가 바로 그 예다. 다만 시그니처가 정확히 일치하는 가상 함수는 "숨김"이 아니라 "오버라이드"로 취급된다는 차이는 있다(오버라이드된 함수도 여전히 그 이름으로 호출 가능하다는 점에서 결과적으로 드러나 보이지 않을 뿐).
  • "using Base::Handle; 은 그냥 편의 문법이다" ✗ — 단순 문법 설탕이 아니라, 이름 조회가 참조하는 오버로드 후보 집합 자체를 바꾸는 선언이다. 이걸 빠뜨리면 기반 클래스의 의도된 오버로드들이 파생 클래스 사용자에게 "없는 것처럼" 보인다.
  • "경고라도 뜨겠지" ✗ — 일부 컴파일러는 -Woverloaded-virtual 로 가상 함수 관련 숨김만 경고할 뿐, 비가상 오버로드 숨김은 기본적으로 아무 경고 없이 조용히 통과한다. 실무에서는 파생 클래스가 기반의 오버로드 세트를 일부만 재정의할 계획이면 반드시 using Base::함수명; 을 습관적으로 추가해야 한다.

면접 포인트 (예상 질문)

  1. 파생 클래스에서 기반 클래스와 이름이 같은 함수를 선언하면 왜 기반의 다른 오버로드까지 전부 가려지는가? 이것이 "오버로드 해석 실패"가 아니라 "이름 조회" 문제인 이유는?
  2. using Base::Handle; 을 추가하면 내부적으로 무슨 일이 일어나는가?
  3. base->Handle(42)lh.Handle(42) 의 결과가 다른 이유는? (정적 타입에 따른 이름 조회 스코프 차이 + 비가상 함수의 정적 바인딩)
  4. 이름 숨김 규칙이 가상 함수 오버라이드와 상호작용할 때 어떤 차이가 생기는가? (시그니처 완전 일치 + virtual → 오버라이드로 vtable 교체, 그 외 → 단순 숨김)