← 문제로

28. 확장 메서드의 정적 바인딩 vs 인스턴스 메서드의 동적 디스패치

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 확장 메서드의 정적 바인딩 vs 인스턴스 메서드의 동적 디스패치

난이도: 중상

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

요약

확장 메서드는 실제로는 obj.Method() 문법을 흉내 낸 정적 메서드 호출이다. 어떤 확장 메서드가 호출될지는 오직 컴파일 시점에 변수의 정적 타입만 보고 결정되며, 런타임에 그 객체가 실제로 어떤 타입인지는 전혀 관여하지 않는다(가상 디스패치가 아니다). 게다가 인스턴스 메서드(진짜 멤버)는 항상 확장 메서드보다 우선한다. 그래서 player/asBase/asInterface 가 모두 같은 PlayerSession 객체를 가리켜도, 변수의 정적 타입이 다르면 Describe() 호출은 서로 다른 메서드로 해석된다.

출력 예측

PlayerSession(인스턴스 메서드) Id=1
BaseSession 확장 메서드 Id=1
ISession 확장 메서드 Id=1
  • player.Describe(): 정적 타입이 PlayerSession 이고 그 클래스에 진짜 인스턴스 메서드 Describe() 가 있으므로, 확장 메서드 후보는 아예 고려되지 않고 인스턴스 메서드가 선택된다.
  • asBase.Describe(): 정적 타입이 BaseSession. 인스턴스 멤버가 없으므로 확장 메서드를 찾는데, SessionExtensionsBaseSession 용과 ISession 용 두 오버로드가 모두 적용 가능하다 — 이때 C# 오버로드 해석 규칙에 따라 더 구체적인(더 파생된) 매개변수 타입BaseSession 오버로드가 선택된다.
  • asInterface.Describe(): 정적 타입이 ISession. BaseSession 매개변수 오버로드는 ISession 타입 인자에 적용될 수 없으므로(반대 방향 캐스팅은 암시적 변환이 아님) ISession 오버로드만 후보로 남아 그것이 선택된다.

핵심 개념

  • 확장 메서드는 문법 설탕: asBase.Describe() 는 컴파일러에 의해 SessionExtensions.Describe(asBase) 라는 정적 메서드 호출로 번역된다(IL 상에서도 callSessionExtensions.Describe 를 직접 호출 — 가상 호출(callvirt)이 아니다).
  • 바인딩 시점은 컴파일타임: 어떤 확장 메서드가 선택될지는 호출식의 정적 타입만으로 결정된다. 런타임 다형성(가상 함수 오버라이드)과 달리, 확장 메서드는 실행 중에 "실제 객체가 무엇인지"를 보고 메서드를 바꾸지 않는다.
  • 인스턴스 멤버 우선 규칙: C# 오버로드 해석은 먼저 일반 멤버 검색(인스턴스 메서드, 프로퍼티 등)을 수행하고, 그 결과가 하나라도 있으면 확장 메서드는 아예 후보에 들어가지 않는다. 확장 메서드는 "멤버가 없을 때만 시도되는 최후 수단"이다.
  • 여러 확장 메서드 후보 간 우선순위: 같은 이름의 확장 메서드가 여러 개 적용 가능하면 일반 오버로드 해석 규칙(더 구체적인 파라미터 타입 우선)이 그대로 적용된다. 단, 서로 다른 네임스페이스의 확장 메서드 세트라면 가까운(더 구체적으로 using 된) 네임스페이스가 우선하고, 같은 네임스페이스 안에서는 시그니처 구체성으로 비교한다.

단계별 내부 동작

  1. 컴파일러의 멤버 조회: expr.Describe() 를 만나면 컴파일러는 먼저 expr정적 타입을 기준으로 Describe 라는 이름의 인스턴스 멤버가 있는지 찾는다.
  2. 없으면 확장 메서드 검색: 현재 스코프에서 보이는 모든 정적 클래스의 확장 메서드 중 this 매개변수 타입에 expr 의 정적 타입이 암시적으로 변환 가능한 것들을 후보로 모은다.
  3. 오버로드 해석: 후보가 여럿이면 일반 오버로드 해석 규칙(가장 구체적인 파라미터 타입, 더 가까운 네임스페이스 등)으로 하나를 확정한다. 실패하거나 모호하면 컴파일 에러.
  4. IL 로 번역: 최종 선택된 메서드가 인스턴스 멤버면 callvirt(가상이면 동적 디스패치, 비가상이면 정적으로 귀결), 확장 메서드면 call SessionExtensions.Describe(...) 로 컴파일된다. 이 결정은 컴파일 시점에 고정되어 런타임에 바뀌지 않는다.

흔한 오해·함정

  • "확장 메서드도 오버라이드된다" ✗ — 확장 메서드는 정적 메서드이므로 override, virtual 개념 자체가 없다. 파생 클래스가 같은 시그니처의 확장 메서드를 "재정의"하는 것은 불가능하며, 대신 파생 클래스가 진짜 인스턴스 메서드를 정의하면 그것이 우선순위에서 확장 메서드를 가려버린다(위 PlayerSession.Describe() 처럼).
  • "인터페이스 변수든 구체 타입 변수든 같은 객체면 같은 결과" ✗ — 확장 메서드는 정적 타입에 묶이므로, 같은 객체를 가리키는 변수라도 **선언된 타입(캐스팅 상태)**에 따라 결과가 달라질 수 있다. 이는 인스턴스 메서드(가상 함수)의 "런타임 타입 기준 디스패치"와 정반대다.
  • "나중에 인터페이스/베이스 클래스에 같은 이름의 멤버가 생겨도 기존 호출은 안전하다" ✗ — 예를 들어 ISession 인터페이스에 나중에 string Describe() 프로퍼티나 메서드가 추가되면 (C# 8+ 의 기본 인터페이스 메서드 포함), 기존에 asInterface.Describe() 로 확장 메서드를 호출하던 코드가 컴파일은 그대로 되지만 조용히 그 새 인스턴스 멤버를 호출하도록 바뀐다 — 경고 없이 동작이 바뀌는 대표적인 API 진화 함정이다. 라이브러리 설계 시 확장 메서드 이름이 나중에 인터페이스 멤버와 충돌하지 않도록 네이밍에 주의해야 한다.
  • "확장 메서드는 성능이 느리다" ✗ — 결국 평범한 정적 메서드 호출로 컴파일되므로 인스턴스 메서드 호출과 성능 차이는 사실상 없다(가상 호출이 아니라 오히려 비가상 정적 호출이라 더 단순할 수도 있다).

면접 포인트 (예상 질문)

  1. obj.ExtMethod() 호출이 컴파일러에 의해 실제로 어떤 코드로 번역되는가? 왜 이것이 "가상 디스패치가 아니다"라고 말할 수 있는가?
  2. 같은 객체를 가리키는 인터페이스 타입 변수와 구체 타입 변수에서 확장 메서드 호출 결과가 달라질 수 있는 이유는? 인스턴스 메서드였다면 어떻게 달랐을까?
  3. 라이브러리에 확장 메서드로 제공하던 기능을 나중에 인터페이스의 기본 멤버로 승격시킬 때 기존 호출자 코드에 어떤 위험이 있는가? 어떻게 완화할 수 있는가?