본문 바로가기

C#

C# 인터페이스의 명시적 구현 활용과 주의사항

명시적 인터페이스 구현은 클래스의 공용 API를 간결하게 유지하면서, 인터페이스 요구사항을 충족하거나 이름 충돌을 해결할 때 유용합니다. 다만 호출성이 낮아지고 디버깅이 어려워질 수 있어 목적을 명확히 하고 사용해야 합니다.

1. 명시적 구현이란?

클래스/구조체가 인터페이스 멤버를 인터페이스 이름으로 한정해서 구현하는 방법입니다. 이렇게 구현된 멤버는 클래스 인스턴스에서 바로 보이지 않고, 인터페이스로 캐스팅해야 호출할 수 있습니다.

public interface IFoo { void Run(); }
public interface IBar { void Run(); }

public class Worker : IFoo, IBar
{
    void IFoo.Run() { Console.WriteLine("Foo.Run"); }
    void IBar.Run() { Console.WriteLine("Bar.Run"); }
}

var w = new Worker();
// w.Run(); // 컴파일 에러
((IFoo)w).Run(); // Foo.Run
((IBar)w).Run(); // Bar.Run

2. 언제 사용하나요?

- 이름 충돌 해결: 서로 다른 인터페이스가 같은 시그니처를 가질 때 역할을 분리합니다.
- 공용 API 최소화: 내부적으로만 필요하거나 드물게 쓰이는 기능을 숨깁니다.
- 접근 수준 분리: 읽기 전용은 공개, 쓰기는 인터페이스로만 허용하는 등 세밀한 제어가 가능합니다.

3. 호출 방법과 기본 문법

- 명시적으로 구현한 멤버는 클래스 변수로 직접 호출할 수 없습니다. 인터페이스로 캐스팅해야 합니다.
- 접근 제한자, virtual/override/abstract 키워드를 사용할 수 없습니다(이미 인터페이스 슬롯 구현이기 때문입니다).

public interface IPrintable { void Print(); }
public interface ILogger { void Print(); }

public class Printer : IPrintable, ILogger
{
    void IPrintable.Print() => Console.WriteLine("Document");
    void ILogger.Print()    => Console.WriteLine("Log");
}

var p = new Printer();
((IPrintable)p).Print();
((ILogger)p).Print();

4. 읽기/쓰기 분리: 속성에 적용

공개 API는 읽기 전용으로, 내부 사용은 쓰기 가능으로 만들 수 있습니다.

public interface ICounterInternal
{
    int Count { get; set; }
}

public interface ICounterView
{
    int Count { get; }
}

public class Counter : ICounterInternal, ICounterView
{
    private int _count;

    // 공개 API: 읽기 전용
    public int Count => _count;

    // 내부 제어: 인터페이스로만 쓰기 허용
    int ICounterInternal.Count
    {
        get => _count;
        set => _count = value;
    }
}

var c = new Counter();
Console.WriteLine(c.Count); // OK
// c.Count = 10; // 컴파일 에러
((ICounterInternal)c).Count = 10; // OK

5. 구조체와 박싱 주의

값 형식에서 인터페이스로 캐스팅하면 박싱이 발생할 수 있습니다. 제네릭 제약을 활용하면 JIT이 박싱 없는 호출을 생성합니다.

public interface IResettable { void Reset(); }

public struct Meter : IResettable
{
    private int _value;
    void IResettable.Reset() => _value = 0; // 명시적 구현
}

var m = new Meter();
((IResettable)m).Reset(); // 박싱 발생 가능

// 박싱 회피: 제네릭 제약(구조체 + 인터페이스)
static void ResetAll<T>(Span<T> items) where T : struct, IResettable
{
    for (int i = 0; i < items.Length; i++)
    {
        var t = items[i];
        t.Reset(); // 제약 기반 호출: 박싱 없이 호출됨
        items[i] = t;
    }
}

6. 확장성을 위한 패턴

명시적 구현을 외부로 숨기고, 내부에서는 가상 메서드로 확장 지점을 노출합니다.

public interface ITransactional { void Commit(); }

public class Repository : ITransactional
{
    void ITransactional.Commit() => CommitCore();

    protected virtual void CommitCore()
    {
        // 트랜잭션 커밋 핵심 로직
    }
}

public class CachedRepository : Repository
{
    protected override void CommitCore()
    {
        // 캐시 flush + base 호출
        base.CommitCore();
    }
}

7. C# 8+ 디폴트 인터페이스 멤버와의 관계

인터페이스에 기본 구현이 있어도, 클래스가 명시적으로 구현하면 해당 클래스의 구현이 우선합니다. 호출 측이 어떤 인터페이스로 보느냐에 따라 디스패치가 달라질 수 있으므로 일관된 호출 경로를 문서화하세요.

8. 테스트/디버깅 팁

- 캐스팅 헬퍼 준비: as 패턴으로 안전하게 호출합니다.
- Mocking: 테스트 더블은 인터페이스를 기준으로 작성하고, 명시적 멤버의 동작을 명확히 검증합니다.
- 리플렉션: InterfaceMapping으로 어떤 구현에 바인딩됐는지 확인할 수 있습니다.

if (obj is ILogger log) { log.Print(); }

9. 주의사항 체크리스트

- 발견성 저하: 명시적 멤버는 IntelliSense에 직접 보이지 않습니다. 사용법을 문서화하세요.
- 호환성: 기존에 public으로 노출했던 멤버를 명시적 구현으로 바꾸면 호출 코드가 깨질 수 있습니다(호환성 위험).
- 키워드 제약: 명시적 구현에는 접근 제한자, virtual/override 사용 불가입니다.
- 이벤트/인덱서도 동일 규칙: 이벤트/인덱서도 명시적으로 구현할 수 있습니다.
- 확장 메서드: 클래스 참조로는 확장 메서드가 보이지만, 명시적 멤버 자체는 클래스 표면에서 보이지 않습니다. 인터페이스 타입으로 다루면 관련 확장 메서드와 함께 사용성이 좋아집니다.

10. 요약

명시적 인터페이스 구현은 이름 충돌 해결과 API 표면 최소화에 매우 유용합니다. 다만 호출 경로가 인터페이스 캐스팅에 의존하고, 발견성이 낮아집니다. 다음 원칙을 권장합니다.

- 기본 사용 시나리오는 public 멤버로, 보조적/내부적 시나리오는 명시적 구현으로 분리합니다.
- 값 형식은 제네릭 제약을 활용해 박싱을 피합니다.
- 필요 시 보호 수준 가상 메서드와 조합해 확장 지점을 제공합니다.
- 변경 시 호환성 영향을 검토하고, 인터페이스 기반 사용법을 문서화합니다.