반응형

[PART8.상속과 인터페이스 사용법(9/11)] 명시적 인터페이스 구현 — IFoo.Method()

같은 이름 메서드 충돌 회피 / 인터페이스 멤버를 클래스 공용 API에서 숨기기 / 인터페이스 캐스팅을 통해서만 호출


1. 문제 제기 — Dispose() 가 자동완성에 보이면 안 되는 이유

Unity에서 파일 스트림을 다루는 클래스를 하나 만들었다고 가정해 봅니다. IDisposable 을 구현해야 using 문법과 호환되니 Dispose() 메서드를 넣었습니다. 그런데 이 클래스에는 이미 도메인 친화적인 Close() 라는 이름이 있습니다. 사용자에게는 Close() 만 보여주고 싶고, Dispose()using 블록 끝에서만 자동으로 호출되었으면 합니다.

C#
using System;

public class FileHandle : IDisposable
{
    public void Close() { /* 리소스 정리 */ }
    public void Dispose() { Close(); } // ← 이렇게 두면 인스펙터·자동완성에 둘 다 노출
}

var f = new FileHandle();
f.Close();   // 의도한 호출
f.Dispose(); // 의도하지 않은 호출 — 두 개의 같은 동작이 공개 API 에 동시에 노출됨

또 다른 시나리오는 더 직접적입니다. C# 의 가장 흔한 컬렉션 인터페이스 IEnumerable<T> 는 정의상 비제네릭 IEnumerable 도 함께 상속합니다. 이 둘은 모두 GetEnumerator() 라는 같은 이름의 메서드를 요구하지만 반환 타입이 다릅니다. IEnumerable<T>IEnumerator<T> 를, IEnumerable 은 비제네릭 IEnumerator 를 반환해야 합니다.

C#
public class Box<T> : IEnumerable<T>
{
    public IEnumerator<T> GetEnumerator() => /* ... */;
    public IEnumerator GetEnumerator() => /* ... */; // ← 컴파일 에러: 같은 시그니처
}

C# 의 메서드 오버로드 규칙은 반환 타입만 다른 메서드를 같은 클래스에 둘 수 없습니다. 두 인터페이스를 동시에 구현해야 하는데 시그니처가 충돌하면 어떻게 해결할까요. 그리고 위의 FileHandle 예시처럼 인터페이스에서 요구하는 메서드를 공개 API 에서 의도적으로 숨기고 싶다면 어떤 도구가 필요할까요.

이 두 문제를 동시에 해결하는 도구가 명시적 인터페이스 구현(Explicit Interface Implementation) 입니다. 인터페이스 이름을 메서드 앞에 붙여 "이 메서드는 이 인터페이스 계약에만 속한다"고 컴파일러에 못 박는 문법입니다.


2. 개념 정의 — "어느 인터페이스의 멤버인지" 를 이름에 박는다

2.1 비유 — 같은 회사에 다니는 두 부서장의 명함

한 사람이 회사에 두 개의 부서장 명함을 동시에 들고 있다고 상상해 봅니다. 한 명함은 "마케팅팀 김철수", 다른 명함은 "영업팀 김철수" 입니다. 외부에서 사람을 찾을 때 "마케팅팀 김철수 부탁드립니다" 라고 부서를 지정해야 그 명함이 응답합니다. 그냥 "김철수 씨" 라고 부르면 어느 명함을 내밀어야 할지 모호해집니다.

명시적 인터페이스 구현이 정확히 이렇게 동작합니다. void IDisposable.Dispose() 라고 쓰면 "이 Dispose()IDisposable 부서의 명함" 이라는 뜻이 되어, 호출자는 반드시 IDisposable 타입으로 캐스팅한 뒤에야 이 메서드를 부를 수 있습니다.

2.2 시각화 — 암시적 vs 명시적 구현의 호출 경로

암시적 구현 — public 으로 노출

2.3 기본 코드 — 두 패턴을 한 화면에서 비교

C#
public interface IWorker
{
    void Work();
}

// 암시적 구현 — 메서드를 public 으로 선언
public class ImplicitWorker : IWorker
{
    public void Work() => System.Console.WriteLine("Implicit");
}

// 명시적 구현 — 인터페이스 이름을 메서드 앞에 붙이고 접근 제한자 생략
public class ExplicitWorker : IWorker
{
    void IWorker.Work() => System.Console.WriteLine("Explicit");
}

public static class Demo
{
    public static void Run()
    {
        var imp = new ImplicitWorker();
        imp.Work();              // OK — 클래스 인스턴스로 직접 호출
        ((IWorker)imp).Work();   // OK — 인터페이스로 캐스팅해서도 호출 가능

        var exp = new ExplicitWorker();
        // exp.Work();           // ❌ 컴파일 에러: 'ExplicitWorker' does not contain a definition for 'Work'
        ((IWorker)exp).Work();   // OK — 캐스팅해야만 호출 가능
    }
}
void IWorker.Work() — 명시적 인터페이스 구현 문법 (Explicit Interface Implementation) 메서드 이름 앞에 인터페이스명. 을 붙여 "이 메서드는 해당 인터페이스 계약에만 속한다" 고 선언한다. 접근 제한자(public 등)는 쓸 수 없으며, 컴파일러가 자동으로 인터페이스를 통해서만 접근 가능하도록 제한한다.
예시: void IDisposable.Dispose() { /* 정리 */ } 클래스 인스턴스로는 호출 불가, ((IDisposable)obj).Dispose() 로만 호출 가능.

쉽게 말해, 명시적 구현은 "이 메서드는 인터페이스로 봐야만 보인다" 라는 뜻입니다. 정확한 정의로 옮기면 — 명시적 인터페이스 구현은 메서드 식별자에 인터페이스 한정자를 추가해 해당 멤버를 클래스의 가시 표면(public surface)에서 제거하고, 오직 인터페이스 메서드 테이블의 해당 슬롯을 통해서만 호출 가능하도록 만드는 구현 방식입니다.

2.4 IL 로 확인하기 — public 인가 private 인가

위 두 클래스를 컴파일해서 IL 을 비교해 봅니다.

IL
// === 암시적 구현 ===
.class public auto ansi beforefieldinit ImplicitWorker
    extends [System.Runtime]System.Object
    implements IWorker
{
    .method public final hidebysig newslot virtual    // 접근성: public
        instance void Work () cil managed             // 메서드명: Work (인터페이스 한정자 없음)
    {
        IL_0000: nop
        IL_0001: ldstr "Implicit"
        IL_0006: call void [System.Console]System.Console::WriteLine(string)
        IL_000b: nop
        IL_000c: ret
    }
}

// === 명시적 구현 ===
.class public auto ansi beforefieldinit ExplicitWorker
    extends [System.Runtime]System.Object
    implements IWorker
{
    .method private final hidebysig newslot virtual   // 접근성: private (← 핵심 차이)
        instance void IWorker.Work () cil managed     // 메서드명: IWorker.Work (한정자 포함)
    {
        .override method instance void IWorker::Work()  // ← vtable 슬롯 매핑 지시어
        IL_0000: nop
        IL_0001: ldstr "Explicit"
        IL_0006: call void [System.Console]System.Console::WriteLine(string)
        IL_000b: nop
        IL_000c: ret
    }
}

IL 분석 포인트

  1. 접근성이 public 에서 private 으로 바뀐다
  2. 메서드 이름에 인터페이스 한정자가 박힌다
  3. .override 지시어가 vtable 슬롯을 인터페이스에 연결한다
  4. 호출 측은 callvirt 로 동일

3. 내부 동작 — 인터페이스 vtable 슬롯과 클래스 멤버 테이블의 분리

3.1 SVG — 클래스 멤버 테이블 vs 인터페이스 슬롯

CLR 이 ExplicitWorker 타입을 로드한 직후의 메모리 구조

3.2 왜 클래스 인스턴스로는 부를 수 없는가

핵심은 C# 컴파일러가 메서드 이름을 조회할 때 두 종류의 테이블을 본다는 점 입니다.

  • obj.Method() 형태의 호출은 클래스 멤버 테이블 에서 Method 를 찾습니다.
  • ((IInterface)obj).Method() 형태의 호출은 인터페이스 vtable 슬롯 에서 Method 를 찾습니다.

암시적 구현은 메서드를 두 테이블에 모두 등록합니다 — 클래스의 public Work() 도, 인터페이스 슬롯의 IWorker::Work 도 같은 본문을 가리킵니다. 그래서 어느 쪽에서든 호출이 됩니다.

명시적 구현은 메서드를 인터페이스 vtable 슬롯에만 등록합니다. 메타데이터에서 접근성이 private 이고 이름이 IWorker.Work (점 포함) 이므로 클래스 멤버 테이블에서는 매칭되는 항목이 없습니다. 컴파일러는 obj.Work() 호출에서 정의를 찾지 못해 에러를 냅니다.

CLR(Common Language Runtime) — .NET 가상 머신 C# 코드는 IL(Intermediate Language) 로 컴파일된 뒤 CLR 위에서 실행된다. CLR 은 타입 로드, 메모리 관리, 메서드 디스패치, GC 등을 담당한다. Unity 의 IL2CPP 는 IL 을 다시 C++ 로 변환하지만 인터페이스 디스패치 등 핵심 시멘틱은 동일하게 보존한다.

3.3 IEnumerable 충돌 케이스 — 같은 이름 다른 반환 타입

명시적 구현이 가장 빈번하게 등장하는 곳이 IEnumerable<T> 입니다. 표준 라이브러리에서 IEnumerable<T> 는 정의상 IEnumerable 을 상속하므로, 한 클래스가 IEnumerable<T> 를 구현하면 자동으로 두 인터페이스를 모두 구현해야 합니다.

C#
using System.Collections;
using System.Collections.Generic;

public class Box<T> : IEnumerable<T>
{
    private readonly List<T> items = new();
    public void Add(T item) => items.Add(item);

    // 제네릭 버전 — 이쪽이 foreach 의 우선 대상
    public IEnumerator<T> GetEnumerator() => items.GetEnumerator();

    // 비제네릭 버전 — 명시적 구현으로 숨김
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// 호출
var box = new Box<int> { 1, 2, 3 };
foreach (var x in box) { /* 제네릭 GetEnumerator() 사용 */ }

IEnumerable raw = box;
foreach (var x in raw) { /* 비제네릭 GetEnumerator() 사용 — 박싱 발생 가능 */ }

이 코드를 IL 로 보면 두 GetEnumerator() 가 분리되어 들어가 있는 것이 보입니다.

IL
// 제네릭 — public 으로 노출
.method public final hidebysig newslot virtual
    instance class IEnumerator`1<!T> GetEnumerator () cil managed
{
    // ... List<T>::GetEnumerator() 호출
}

// 비제네릭 — 명시적 구현으로 숨김 (메서드명에 인터페이스 한정자)
.method private final hidebysig newslot virtual
    instance class IEnumerator System.Collections.IEnumerable.GetEnumerator () cil managed
{
    .override method instance class IEnumerator [System.Runtime]System.Collections.IEnumerable::GetEnumerator()
    IL_0000: ldarg.0
    IL_0001: call instance class IEnumerator`1<!0> Box`1<!T>::GetEnumerator()  // 제네릭 버전 재사용
    IL_0006: ret
}

IL 분석 포인트

  1. 두 개의 GetEnumerator() 가 공존한다
  2. 비제네릭 버전이 제네릭 버전을 재사용한다
  3. foreach 가 어떻게 우선순위를 결정하는가

4. 실전 적용 — 언제 명시적 구현을 선택할지의 판단 기준

4.1 케이스 1 — 도메인 친화적 이름을 노출하고 인터페이스 멤버를 숨기기

IDisposable.Dispose() 가 의미상 적절하지 않은 도메인에서 자주 쓰이는 패턴입니다.

Before — 두 메서드가 모두 노출되어 혼란

C#
public class FileHandle : IDisposable
{
    public void Close()   { /* 리소스 정리 */ }
    public void Dispose() { Close(); } // 같은 일을 하는 메서드가 두 개 노출
}

var f = new FileHandle();
f.Close();    // 사용자는 어느 쪽을 불러야 할지 헷갈린다
f.Dispose();  // 인스펙터·자동완성에 둘 다 등장

After — Close() 만 공개, Dispose()using 전용으로 숨김

C#
public class FileHandle : IDisposable
{
    public void Close() { /* 리소스 정리 */ }

    // 명시적 구현 — using 블록과 컴파일러 호환만 유지하고 공개 API 에서 숨김
    void IDisposable.Dispose() => Close();
}

// 호출
var f = new FileHandle();
f.Close();      // ✅ 도메인에 어울리는 이름만 보임
// f.Dispose(); // ❌ 컴파일 에러 — 의도적으로 숨김

using (var f2 = new FileHandle())
{
    // 블록 끝에서 ((IDisposable)f2).Dispose() 가 자동 호출됨
}

using 문법은 컴파일러가 내부적으로 IDisposable 로 캐스팅한 뒤 Dispose() 를 호출하기 때문에, 명시적 구현이어도 정상 동작합니다. 사용자는 Close() 만 보지만 using 호환성은 그대로 유지됩니다.

4.2 케이스 2 — 두 인터페이스가 같은 시그니처를 요구할 때

도메인 모델에서 한 객체가 여러 역할을 가질 수 있습니다. 같은 이름의 메서드를 두 인터페이스에서 다르게 구현하고 싶다면 명시적 구현이 유일한 답입니다.

C#
interface IConsoleLogger { void Log(string message); }
interface IFileLogger    { void Log(string message); }

public class ReportService : IConsoleLogger, IFileLogger
{
    // 콘솔용 로그 — 즉시 출력
    void IConsoleLogger.Log(string message)
        => System.Console.WriteLine($"[Console] {message}");

    // 파일용 로그 — 디스크에 기록
    void IFileLogger.Log(string message)
        => System.IO.File.AppendAllText("log.txt", $"[File] {message}\n");
}

// 호출
var report = new ReportService();
((IConsoleLogger)report).Log("started"); // 콘솔 출력
((IFileLogger)report).Log("started");    // 파일 기록

암시적 구현 하나로는 이 두 행동을 분리할 방법이 없습니다. public void Log(string message) 를 한 번만 쓰면 두 인터페이스가 모두 같은 본문을 공유하게 됩니다.

4.3 케이스 3 — Unity EventSystem 핸들러의 공개 API 보호

Unity UI 의 IPointerClickHandler, IDragHandler 같은 인터페이스는 EventSystem 이 내부적으로 호출합니다. 이 메서드들이 컴포넌트의 공개 API 로 노출되면 다른 스크립트에서 실수로 직접 호출하거나, 인스펙터의 UnityEvent 슬롯에 잡혀 들어갈 수 있습니다.

추정: Unity 의 EventSystem 이 명시적 구현된 IPointerClickHandler.OnPointerClick 을 정상적으로 디스패치한다는 점은 EventSystem 의 내부 코드가 인터페이스 타입으로 캐스팅 후 호출하는 일반 패턴을 따른다는 데서 유추한 결과다. 실제 Unity 버전에 따라 동작 차이가 있을 수 있으니, 핵심 컴포넌트에 적용할 때는 한 번 검증한다.

Before — 공개 API 가 오염된 경우

C#
using UnityEngine;
using UnityEngine.EventSystems;

public class ClickableButton : MonoBehaviour, IPointerClickHandler
{
    // public 으로 구현 — 외부 스크립트가 실수로 호출 가능
    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("Clicked");
    }
}

// 다른 스크립트에서
var btn = GetComponent<ClickableButton>();
btn.OnPointerClick(null); // ⚠️ 실수로 호출 — null PointerEventData 로 NRE 가능

After — 인터페이스 타입에만 노출

C#
using UnityEngine;
using UnityEngine.EventSystems;

public class ClickableButton : MonoBehaviour, IPointerClickHandler
{
    // 명시적 구현 — EventSystem 만이 인터페이스 캐스팅으로 호출
    void IPointerClickHandler.OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("Clicked");
    }
}

// 다른 스크립트에서
var btn = GetComponent<ClickableButton>();
// btn.OnPointerClick(null); // ❌ 컴파일 에러 — 실수 차단

IL 관점에서의 차이: 두 경우 모두 EventSystem 측 호출은 callvirt instance void IPointerClickHandler::OnPointerClick(...) 로 동일합니다. EventSystem 은 컴포넌트를 가져온 뒤 인터페이스 타입으로 보고 호출하므로 명시적 구현이어도 디스패치는 정상 동작합니다.

4.4 Unity 핫패스에서의 박싱 주의

struct 가 인터페이스를 명시적으로 구현하면 호출 시 박싱이 발생할 수 있습니다.

C#
public interface IUpdater { void Tick(); }

public struct FastUpdater : IUpdater
{
    void IUpdater.Tick() { /* 짧은 작업 */ }
}

// Update() 핫패스에서
void Update()
{
    FastUpdater u = default;

    // u.Tick();        // ❌ 컴파일 에러 — 명시적 구현은 인스턴스로 호출 불가
    ((IUpdater)u).Tick(); // ⚠️ 박싱 발생 — 매 프레임 힙 할당 → GC 압박
}

문제의 본질: ((IUpdater)u) 캐스팅은 값 타입 FastUpdater 를 힙에 박스로 감싸는 작업입니다. IL 에서는 box FastUpdater 명령이 발생하고, 이 박스 객체는 작업 후 GC 대상이 됩니다.

해결책: 핫패스에서 호출할 메서드는 struct 에서는 암시적 구현 으로 두고 직접 호출합니다. 컴파일러가 constrained. 접두사로 박싱 없이 디스패치하거나, 제네릭 호출에서는 JIT 가 박싱을 회피할 수 있습니다.

C#
public struct FastUpdater : IUpdater
{
    public void Tick() { /* 짧은 작업 */ } // 암시적 — 직접 호출 시 박싱 없음
}

void Update()
{
    FastUpdater u = default;
    u.Tick(); // ✅ 박싱 없음 — IL 레벨에서 call instance void FastUpdater::Tick()
}
Boehm GC / IL2CPP — Unity 의 모바일 빌드는 IL2CPP 백엔드를 사용하며 GC 는 Boehm conservative GC 를 사용한다. 핫패스에서 박싱이 한 번 발생하면 작은 객체이지만 매 프레임 힙 할당이 누적되어 GC 스파이크(프레임 끊김) 의 원인이 된다. Unity Profiler 의 GC.Alloc 항목에서 박싱 흔적을 확인할 수 있다.

5. 함정과 주의사항

5.1 ❌ 명시적 구현된 메서드를 virtual 로 만들려고 시도

명시적 구현된 메서드는 언어 레벨에서 virtual 키워드를 붙일 수 없습니다. IL 레벨에서는 인터페이스 디스패치 때문에 virtual 이지만, C# 문법상 파생 클래스가 일반 override 로 재정의할 수 없습니다.

C#
public interface IWorker { void Work(); }

public class Base : IWorker
{
    void IWorker.Work() => System.Console.WriteLine("Base");
    // virtual void IWorker.Work() ❌ 컴파일 에러: "modifier 'virtual' is not valid"
}

public class Derived : Base
{
    // void IWorker.Work() ❌ Derived 가 IWorker 를 다시 구현하지 않으면 불가
}

올바른 패턴 — 파생 클래스에서 동작을 바꾸려면 인터페이스 재선언 + 명시적 구현

C#
public class Base : IWorker
{
    void IWorker.Work() => System.Console.WriteLine("Base");
}

// Derived 가 IWorker 를 다시 선언하면, 자체 명시적 구현으로 슬롯을 덮을 수 있다
public class Derived : Base, IWorker
{
    void IWorker.Work() => System.Console.WriteLine("Derived");
}

IWorker w = new Derived();
w.Work(); // "Derived"

또 다른 우회: 명시적 구현 본문이 보호된(protected) 가상 메서드를 호출하도록 하고, 파생 클래스는 그 protected 메서드를 override 합니다.

C#
public class Base : IWorker
{
    void IWorker.Work() => OnWork();
    protected virtual void OnWork() => System.Console.WriteLine("Base");
}

public class Derived : Base
{
    protected override void OnWork() => System.Console.WriteLine("Derived");
}

5.2 ❌ 명시적 구현된 멤버에 private 등 접근 제한자 명시

C#
public class Worker : IWorker
{
    private void IWorker.Work() { } // ❌ 컴파일 에러: "explicit interface implementation cannot have access modifiers"
}

명시적 구현은 컴파일러가 자동으로 private 으로 처리하므로 사용자가 접근 제한자를 쓸 수 없습니다. 그냥 void IWorker.Work() { } 로 작성합니다.

5.3 ❌ DIM 다이아몬드 충돌을 명시적 구현 없이 무시

C# 8.0 이상에서 인터페이스가 기본 메서드(default interface method, DIM)를 가질 수 있게 되었습니다. 두 인터페이스가 같은 메서드에 서로 다른 기본 구현을 제공한다면 어느 쪽을 쓸지 모호한 다이아몬드 충돌(diamond ambiguity) 이 발생합니다.

C#
interface ILeft  { void Run() => System.Console.WriteLine("Left"); }
interface IRight { void Run() => System.Console.WriteLine("Right"); }

public class Worker : ILeft, IRight { } // ❌ 컴파일러가 Run() 호출을 모호하다고 판단

// IWorker w = new Worker();
// w.Run(); // 컴파일 에러: 어떤 Run 을 부를지 모호

해결 — Worker 가 명시적 구현으로 두 인터페이스를 분리하거나, 한 곳에 합쳐 처리

C#
public class Worker : ILeft, IRight
{
    // 둘을 분리해서 보존
    void ILeft.Run()  => System.Console.WriteLine("Left in Worker");
    void IRight.Run() => System.Console.WriteLine("Right in Worker");
}

((ILeft)new Worker()).Run();  // "Left in Worker"
((IRight)new Worker()).Run(); // "Right in Worker"

// 또는 한 메서드로 통합
public class WorkerMerged : ILeft, IRight
{
    public void Run() => System.Console.WriteLine("Merged");
}

명시적 구현은 DIM 다이아몬드를 우아하게 풀어내는 유일한 도구 입니다. 일반 암시적 구현으로는 같은 시그니처를 두 갈래로 분리할 수 없습니다.

5.4 ❌ struct 의 명시적 구현 호출 시 박싱

앞서 4.4 에서 본 박싱 함정을 다시 강조합니다. struct 에서 명시적 구현은 그 자체로 박싱 트리거 입니다 — 호출하려면 인터페이스 캐스팅이 필요하고, 인터페이스 캐스팅은 박싱이기 때문입니다.

C#
public struct Counter : IDisposable
{
    void IDisposable.Dispose() { /* ... */ }
}

void Update()
{
    Counter c = default;

    // c.Dispose();         // ❌ 컴파일 에러
    using (c) { /* ... */ } // ⚠️ using 도 IDisposable 캐스팅을 거치므로 박싱

    // 핫패스라면 struct 에서 IDisposable 명시적 구현은 피한다
}

제네릭 + where T : IDisposable 조합은 박싱을 회피할 수 있다: JIT 이 constrained.T callvirt 패턴으로 직접 호출하기 때문입니다. 하지만 인터페이스 변수에 직접 담는 순간 박싱입니다.

5.5 ❌ 패턴 매칭의 is 도 명시적 구현엔 통하지만, 도구 호출은 캐스팅 필요

C#
public class Worker : IWorker
{
    void IWorker.Work() => System.Console.WriteLine("Hi");
}

object obj = new Worker();

if (obj is IWorker w)
{
    w.Work(); // ✅ OK — 패턴 매칭 결과 w 는 IWorker 타입
}
// (obj as Worker)?.Work(); // ❌ Worker 타입에는 Work 가 없음

명시적 구현은 항상 인터페이스 타입을 통해야 한다는 원칙이 모든 맥락에 일관됩니다.


6. C# 버전별 변화

명시적 인터페이스 구현 자체는 C# 1.0 부터 존재했습니다. 그 후에도 인터페이스 기능 변화에 맞춰 의미가 확장되었습니다.

6.1 C# 1.0 — 도입

C# 의 첫 버전부터 명시적 구현 문법이 있었습니다. 처음부터 IEnumerable 의 두 GetEnumerator() 충돌을 해결하기 위한 핵심 도구로 설계되었습니다.

6.2 C# 8.0 — DIM 과 함께 활용도 증가

C# 8.0(2019) 의 default interface method(DIM) 도입은 명시적 구현의 활용 범위를 넓혔습니다. 다중 인터페이스가 같은 시그니처에 서로 다른 기본 구현을 제공할 때, 클래스가 두 동작을 모두 보존하려면 명시적 구현이 유일한 해결책이 되었습니다.

C#
// C# 8 이전 — DIM 자체가 없었으므로 다이아몬드 문제 자체가 인터페이스에서 발생하지 않음
// C# 8 이후
interface IA { void Foo() => System.Console.WriteLine("A"); } // 기본 구현 있음
interface IB { void Foo() => System.Console.WriteLine("B"); }

public class C : IA, IB
{
    // 다이아몬드 충돌 — 명시적 구현으로 분리
    void IA.Foo() => System.Console.WriteLine("C as A");
    void IB.Foo() => System.Console.WriteLine("C as B");
}

6.3 C# 11 — 정적 추상 인터페이스 멤버에서도 동일 규칙

C# 11(2022) 의 정적 추상 인터페이스 멤버(static abstract interface members)에서도 명시적 구현 문법이 그대로 적용됩니다.

C#
public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T a, T b);
    static abstract T Zero { get; }
}

public readonly struct Money : IAddable<Money>
{
    public decimal Amount { get; init; }

    // 명시적 구현 — 정적 멤버에도 동일 문법
    static Money IAddable<Money>.operator +(Money a, Money b)
        => new Money { Amount = a.Amount + b.Amount };

    static Money IAddable<Money>.Zero => new Money { Amount = 0m };
}

이전 PART 8 의 8번 토픽에서 다룬 "정적 추상 인터페이스 멤버" 도 같은 명시적 구현 규칙을 따릅니다 — 인스턴스 멤버와 동일하게, 클래스의 공개 정적 표면에서는 보이지 않고 인터페이스 제약을 통해서만 호출됩니다.


7. 정리

명시적 인터페이스 구현은 단순한 문법 장식이 아니라 C# 의 인터페이스 시스템에서 이름 충돌 해결과 API 캡슐화 를 책임지는 핵심 도구입니다. 다음 체크리스트로 핵심을 정리합니다.

  • [ ] 문법은 반환타입 인터페이스명.메서드명() — 접근 제한자(public 등) 는 쓸 수 없다.
  • [ ] 호출 가능성은 인터페이스 타입에서만 — 클래스 인스턴스로는 호출 불가, 항상 캐스팅 필요.
  • [ ] IL 메타데이터는 private + .override — 컴파일러가 접근성을 잠그고 vtable 슬롯에만 매핑한다.
  • [ ] 세 가지 대표 사용 케이스IEnumerable<T> 충돌 해소, Dispose() 같은 인터페이스 멤버 숨기기, 동일 시그니처 두 인터페이스 분리.
  • [ ] DIM 다이아몬드 충돌의 표준 해법 — C# 8 이후 두 인터페이스 기본 구현이 같은 시그니처면 명시적 구현으로 분리한다.
  • [ ] struct 핫패스에서는 신중히 — 호출 시 박싱이 강제되므로 매 프레임 호출 경로에서는 피한다.
  • [ ] virtual 불가, 재정의는 인터페이스 재선언으로 — 파생 클래스에서 동작을 바꾸려면 자식 클래스에 인터페이스를 다시 선언하거나, protected 가상 훅으로 위임한다.
  • [ ] Unity 실전에서는 EventSystem 핸들러 보호에 활용IPointerClickHandler 등을 명시적으로 구현하면 공개 API 오염을 막고 EventSystem 디스패치는 그대로 유지된다.
반응형

+ Recent posts