반응형

[PART8.상속과 인터페이스 사용법(7/11)] 인터페이스 기본 구현 — 계약을 깨지 않고 진화시키는 도구

인터페이스에 본문이 들어오기 시작한 이유 / abstract class와의 진짜 경계 / 다이아몬드 충돌과 IL 디스패치


1. 문제 제기 — 한 번 공개한 인터페이스에 메서드 하나만 더 넣고 싶다

Unity 프로젝트에 데이터 저장 추상화를 만들어 사내 라이브러리로 배포했다고 해봅니다. 인터페이스 하나입니다.

C#
// v1.0 — 사내 NuGet 패키지로 배포 완료, 게임 5개가 사용 중
public interface IRepository
{
    void Save(string data);
}

석 달 뒤, 저장이 실패할 수 있다는 사실을 깨닫고 "예외 없이 성공/실패만 알려주는 버전"이 필요해졌습니다. 가장 자연스러운 추가는 이렇습니다.

C#
// v2.0 — TrySave를 추가하고 싶음
public interface IRepository
{
    void Save(string data);
    bool TrySave(string data);   // 새 메서드
}

배포하는 순간 게임 5개가 모두 컴파일 오류를 냅니다. IRepository를 구현한 모든 클래스가 TrySave를 추가하지 않아서 계약 위반이 됩니다. 인터페이스는 한 번 공개되면 깨선 안 되는 약속이라 새 멤버를 더하는 일 자체가 파괴적 변경(breaking change)이 됩니다.

이 문제는 마이크로소프트 자체의 BCL(Base Class Library)에서도 똑같이 발생합니다. IEnumerable<T> 같은 핵심 인터페이스에 메서드를 하나 추가하려면 전 세계 .NET 프로젝트가 한꺼번에 깨집니다. 그래서 새 기능은 보통 확장 메서드로 우회하지만, 확장 메서드는 다형성이 없습니다 — 구현 클래스가 자기 방식대로 override 할 수 없습니다.

C# 8.0이 도입한 인터페이스 기본 구현(Default Interface Methods, DIM)이 바로 이 딜레마를 푸는 도구입니다.

DIM (Default Interface Methods) — 인터페이스 멤버에 본문(구현 코드)을 작성할 수 있게 해주는 C# 8.0 기능. 구현 클래스가 따로 구현하지 않아도 인터페이스가 제공하는 기본 동작을 사용하므로, 기존 인터페이스에 새 멤버를 추가해도 기존 구현 클래스가 깨지지 않는다.
예시: interface ILogger { void Log(string msg) { Console.WriteLine(msg); } } 본문이 있는 메서드는 구현 강제 대상에서 빠진다.

2. 개념 정의 — 인터페이스 안에 "기본 동작"이 들어왔다

비유: 회사 입사 시 받는 매뉴얼

신입이 들어오면 회사가 "이 회사에 다니려면 이 일을 할 줄 알아야 한다"는 매뉴얼을 줍니다. 기존엔 매뉴얼에 "할 일 목록"만 있었습니다 — 어떻게 하는지는 각자 알아서 익혀야 했습니다.

DIM은 매뉴얼에 "기본 절차도 같이 적어둔" 상태입니다. 신입이 자기만의 절차를 만들어도 되지만, 굳이 안 해도 "기본 절차대로 하면 된다"는 안전망이 생깁니다.

«interface» IRepository

FileRepositorySave만 구현하고 TrySave는 인터페이스가 준 기본 구현을 그대로 쓰고, CloudRepository는 클라우드 SDK의 재시도 로직을 활용하기 위해 자기 방식대로 override합니다. 둘 다 IRepository 계약을 만족시킵니다.

기본 문법

C#
public interface IRepository
{
    void Save(string data);

    // C# 8.0: 기본 구현
    bool TrySave(string data)
    {
        try { Save(data); return true; }
        catch { return false; }
    }
}

public class FileRepository : IRepository
{
    public void Save(string data) => Console.WriteLine($"Saved: {data}");
    // TrySave는 구현 안 해도 됨
}

핵심 규칙은 단순합니다.

  1. 본문이 있는 멤버는 구현 강제 대상에서 빠진다.
  2. DIM이 호출하는 다른 인터페이스 멤버(Save)는 구현 클래스의 실제 구현으로 디스패치된다 (thisFileRepository 인스턴스).
  3. DIM은 인터페이스 타입을 통해서만 호출할 수 있다 (자세한 내용은 4장).
IL
.class interface public auto ansi abstract beforefieldinit IRepository
{
    .method public hidebysig newslot abstract virtual 
        instance void Save (string data) cil managed 
    {
        // 본문 없음 — 추상 메서드
    }

    .method public hidebysig newslot virtual           // newslot + virtual: 가상 슬롯
        instance bool TrySave (string data) cil managed 
    {
        // 본문 있음 — DIM
        IL_0000: ...                                    // try { Save(data); return true; }
        IL_000c: callvirt instance void IRepository::Save(string)
        ...
    }
}

IL 레벨에서 두 메서드 모두 virtual newslot이지만, Saveabstract 플래그가 추가되어 있습니다. TrySave는 본문을 가진 가상 메서드입니다 — 즉 인터페이스가 일종의 vtable 슬롯에 기본 구현을 박아둔 셈입니다.


3. 내부 동작 — 인스턴스 필드는 못 가지지만 vtable엔 들어간다

메모리와 vtable 관점

DIM이 abstract class와 결정적으로 다른 지점은 인스턴스 필드를 못 가진다는 사실입니다. 인터페이스는 객체 메모리 레이아웃에 영향을 주지 않습니다 — 객체에 추가 바이트가 붙지 않고, 오직 타입 시스템에 "이 인터페이스를 구현한다"는 메타데이터만 추가됩니다.

abstract class — 객체에 필드 추가 가능

이 제약 때문에 DIM 본문에서 사용할 수 있는 "상태"는 다음 셋뿐입니다.

  • this로 접근하는 구현 클래스의 멤버 (인터페이스를 통해서)
  • 인터페이스 자체의 다른 멤버
  • 인터페이스의 static 필드 (모든 인스턴스가 공유하는 전역 상태)
C#
public interface ICounter
{
    int Value { get; set; }              // 추상 — 저장소는 구현 클래스가
    void Increment() => Value = Value + 1;  // DIM은 인터페이스 멤버만 호출
}

public class Counter : ICounter
{
    public int Value { get; set; }       // 실제 저장소 (필드 → 자동 프로퍼티)
}

ICounter.Increment는 자기 안에 int counter 같은 필드를 들고 있을 수 없습니다. Value 프로퍼티의 setter/getter를 통해 구현 클래스가 가진 저장소를 빌립니다.

IL 호출 흐름

본문 1장의 IRepository.TrySave가 호출될 때 IL은 이렇게 흘러갑니다.

IL
.method public hidebysig static void Main () cil managed 
{
    .entrypoint
    IL_0000: newobj instance void FileRepository::.ctor()        // FileRepository 생성
    IL_0005: ldstr "hello"
    IL_000a: callvirt instance bool IRepository::TrySave(string) // 인터페이스 슬롯 호출
    IL_000f: pop
    IL_0010: ret
}

callvirt instance bool IRepository::TrySave(string)이 핵심입니다. 호출 대상이 인터페이스 슬롯으로 박혀 있고, 런타임이 객체의 MethodTable을 따라가 디스패치합니다.

callvirt

이 동작은 단순히 컴파일러가 코드를 베껴 넣는 트릭이 아니라 CLR이 직접 지원하는 기능입니다. 그래서 .NET Core 3.0 / .NET Standard 2.1 이상의 런타임에서만 작동하고, 그 이전 .NET Framework 4.8 같은 환경에서는 IL이 로드조차 되지 않고 TypeLoadException이 납니다.


4. 실전 적용 — 캐스팅 규칙과 호출 패턴

Before/After: 인터페이스에 새 메서드 안전하게 추가하기

Unity 사내 라이브러리에서 IRepository를 v2.0으로 진화시킨다고 해봅니다.

C#
// ❌ Before — 깨지는 변경
public interface IRepository
{
    void Save(string data);
    bool TrySave(string data);   // 추가하면 모든 기존 구현체 컴파일 에러
}

// 게임 5개의 FileRepository, MemoryRepository, CloudRepository... 전부 깨짐
C#
// ✅ After — DIM으로 안전하게 추가
public interface IRepository
{
    void Save(string data);
    bool TrySave(string data)            // 기본 구현 제공
    {
        try { Save(data); return true; }
        catch { return false; }
    }
}

// 게임 5개는 코드 수정 없이 자동으로 TrySave가 생긴다
// 클라우드처럼 자체 재시도 로직이 있는 곳만 override
public class CloudRepository : IRepository
{
    public void Save(string data) => /* 클라우드 SDK 호출 */;
    public bool TrySave(string data)     // 자체 구현으로 override
    {
        // 클라우드 SDK의 재시도 정책 활용
        return /* ... */;
    }
}
IL
// IL: TrySave 호출 시 IRepository 슬롯이 박혀 있다
IL_000a: callvirt instance bool IRepository::TrySave(string)

런타임이 CloudRepository인지 FileRepository인지 보고, 클래스 자체에 TrySave override가 있으면 그것을, 없으면 인터페이스의 DIM 본문을 호출합니다. Unity 게임 측 코드는 한 줄도 바뀌지 않습니다.

호출 규칙: 클래스 참조로는 못 부른다

DIM의 가장 헷갈리는 규칙이 호출 가능 시점입니다. 클래스가 override 하지 않은 DIM 멤버는 클래스 참조로는 보이지 않습니다.

C#
public class FileRepository : IRepository
{
    public void Save(string data) => Console.WriteLine($"Saved: {data}");
    // TrySave 구현 안 함
}

var repo = new FileRepository();
// repo.TrySave("hi");           // ❌ 컴파일 오류 — FileRepository에 TrySave 없음
((IRepository)repo).TrySave("hi"); // ✅ OK
IRepository i = repo;
i.TrySave("hi");                   // ✅ OK

이유는 단순합니다. DIM은 인터페이스의 멤버이지 구현 클래스의 멤버가 아닙니다. FileRepository의 메서드 시그니처에 TrySave가 추가되지 않습니다. 클래스 참조 repoFileRepository.TrySave를 찾지만 그런 멤버는 없습니다.

IL
// 클래스 참조가 인터페이스 슬롯을 호출하려면 캐스팅 필요
IL_0000: newobj instance void FileRepository::.ctor()
IL_0005: ldstr "hi"
IL_000a: callvirt instance bool IRepository::TrySave(string)  // 인터페이스 슬롯 사용

Unity 활용 패턴: IDamageable 옵션 메서드

C#
// ❌ Before — 모든 구현체가 OnDamageReceived를 직접 만들어야 함
public interface IDamageable
{
    int Health { get; set; }
    void TakeDamage(int amount);
    void OnDamageReceived(int amount, Vector3 hitPoint);  // 새 콜백 추가하면 다 깨짐
}

// ✅ After — 옵션 콜백으로 DIM 활용
public interface IDamageable
{
    int Health { get; set; }
    void TakeDamage(int amount);

    // 시각/사운드 효과는 원하는 구현체만 override
    void OnDamageReceived(int amount, Vector3 hitPoint)
    {
        // 기본 동작: 아무것도 안 함
    }
}

public class Enemy : MonoBehaviour, IDamageable
{
    public int Health { get; set; } = 100;
    public void TakeDamage(int amount) => Health -= amount;
    // OnDamageReceived 안 만들어도 OK
}

public class Player : MonoBehaviour, IDamageable
{
    public int Health { get; set; } = 200;
    public void TakeDamage(int amount) => Health -= amount;
    public void OnDamageReceived(int amount, Vector3 hitPoint)
    {
        CameraShake(amount);   // 화면 흔들기 — 플레이어만
    }
    void CameraShake(int amount) { /* ... */ }
}

// 호출 코드
void OnHit(IDamageable target, int dmg, Vector3 point)
{
    target.TakeDamage(dmg);
    target.OnDamageReceived(dmg, point);  // 인터페이스 참조니 OK
}

이 패턴의 장점은 인터페이스 사용자가 옵션 메서드를 안 만들어도 된다는 점입니다. 신규 적 클래스를 추가할 때마다 빈 메서드를 일일이 채워야 하는 보일러플레이트가 사라집니다.


5. 함정과 주의사항

함정 1: 다이아몬드 충돌 — 두 인터페이스가 같은 시그니처 DIM을 가질 때

C#
// ❌ 충돌 발생 가능
public interface IA { void M() => Console.WriteLine("A"); }
public interface IB { void M() => Console.WriteLine("B"); }

public class C : IA, IB { }
// 컴파일은 되지만 c.M()은 못 부르고
// ((IA)c).M() 과 ((IB)c).M() 만 따로 가능 — 헷갈림
IL
// IL — 두 호출이 다른 인터페이스 슬롯을 사용
IL_0000: newobj instance void C::.ctor()
IL_0005: dup
IL_0006: callvirt instance void IA::M()   // "A"
IL_000b: callvirt instance void IB::M()   // "B"
C#
// ✅ 클래스가 직접 구현해서 모호성 해소
public class C : IA, IB
{
    public void M() => Console.WriteLine("C");   // 둘 다 이걸로 디스패치
}
IL
// 클래스가 override하면 IA.M, IB.M 둘 다 C.M으로 폴백
.method public final hidebysig newslot virtual 
    instance void M () cil managed { ... "C" ... }

이 경우 ((IA)c).M()도, ((IB)c).M()도, c.M()도 모두 "C"를 출력합니다. 두 인터페이스가 같은 메서드 이름을 쓸 가능성이 있다면 사용자가 클래스에서 직접 구현하도록 강제됩니다.

함정 2: 조용한 override (Silent Override)

C#
// 라이브러리 v1.0
public interface ILogger
{
    void Log(string msg);
}

// 사용자가 만든 클래스
public class MyLogger : ILogger
{
    public void Log(string msg) => Console.WriteLine(msg);

    // 사용자가 우연히 만든 헬퍼
    public bool TryLog(string msg)
    {
        try { Log(msg); return true; }
        catch { return false; }
    }
}

// 라이브러리 v2.0이 DIM으로 TryLog 추가
public interface ILogger
{
    void Log(string msg);
    bool TryLog(string msg) => /* 라이브러리 표준 구현 */;  // 새 DIM
}

MyLogger.TryLog가 우연히 시그니처가 같았기 때문에 컴파일러는 이것을 ILogger.TryLog 구현으로 받아들입니다 — override 키워드 없이도. 사용자가 의도하지 않은 코드가 인터페이스 계약의 일부가 되는 셈입니다. 클래스 상속의 override 키워드처럼 의도를 명시할 장치가 인터페이스 구현엔 없습니다.

이 위험을 줄이는 방법은 명시적 인터페이스 구현입니다.

C#
// ✅ 명시적 구현으로 의도 분리
public class MyLogger : ILogger
{
    public void Log(string msg) => Console.WriteLine(msg);
    public bool TryLog(string msg) { /* 내 헬퍼 — 인터페이스와 무관 */ }
    bool ILogger.TryLog(string msg) => /* 인터페이스용 별도 구현 */;
}

함정 3: Unity 구버전 + IL2CPP

C#
// ❌ Unity 2020 LTS 이하에서 위험
public interface IDamageable
{
    void TakeDamage(int amount);
    void OnDamageReceived(int amount) { /* DIM */ }
}

Unity 2020 LTS 이하의 IL2CPP는 DIM이 포함된 IL을 만나면 빌드/런타임에서 실패할 수 있습니다. Unity 2021.2 이상에서 안정적으로 작동하는 것이 일반적인 보고이며, TypeLoadException 같은 에러는 보통 런타임 미지원이 원인입니다.

C#
// ✅ 보수적 대안 — 확장 메서드 또는 abstract base class
public interface IDamageable
{
    void TakeDamage(int amount);
}

public static class DamageableExtensions
{
    public static void TakeDamageWithEffect(this IDamageable d, int amount)
    {
        d.TakeDamage(amount);
        // 기본 효과 처리
    }
}

확장 메서드는 다형성이 없다는 단점이 있지만 모든 Unity 버전에서 동작합니다. 타겟 플랫폼이 Unity 2020 LTS 이하라면 DIM 사용 전에 빌드 테스트가 필수입니다.

함정 4: 인스턴스 필드 착각

C#
// ❌ 인터페이스 안에 인스턴스 필드는 못 만든다
public interface ICounter
{
    int count = 0;        // 컴파일 오류 (인스턴스 필드)
    void Increment() => count++;
}

C# 컴파일러가 즉시 거절합니다. 그러나 static 필드는 가능하기 때문에 다음과 같은 함정이 있습니다.

C#
// ⚠️ 동작은 하지만 위험 — 모든 인스턴스가 공유
public interface ICounter
{
    static int sharedCount;          // 정적 — 모든 구현체 공유 전역 변수
    void Increment() => sharedCount++;
}

public class A : ICounter { }
public class B : ICounter { }

new A() as ICounter ... .Increment();
new B() as ICounter ... .Increment();
// sharedCount == 2 — A와 B가 같은 카운터를 공유
C#
// ✅ 인스턴스 상태가 필요하면 추상 프로퍼티로 위임
public interface ICounter
{
    int Value { get; set; }          // 추상 — 구현 클래스가 저장소 제공
    void Increment() => Value++;
}

public class Counter : ICounter
{
    public int Value { get; set; }   // 인스턴스 필드 위치
}

6. C# 버전별 변화

C# 7 이전 — 인터페이스는 오직 추상 멤버만

C#
// C# 7.x
public interface IRepository
{
    void Save(string data);
    // 본문 작성 시 컴파일 오류
    // void TrySave(string data) { ... }   // ❌
}

이 시기엔 새 메서드를 인터페이스에 추가하려면 모든 구현체를 같이 수정해야 했습니다. 우회책은 확장 메서드뿐이었으나 다형성이 없어 override가 안 됩니다.

C# 8.0 — DIM 도입

C#
// C# 8.0
public interface IRepository
{
    void Save(string data);
    bool TrySave(string data)
    {
        try { Save(data); return true; }
        catch { return false; }
    }
}
  • 인터페이스 멤버에 본문 작성 가능
  • private, protected, internal, static 접근 한정자 허용
  • 정적 멤버 (필드/메서드/연산자) 허용
  • CLR 4.7 / .NET Core 3.0 / .NET Standard 2.1 이상 필요
IL
// C# 8 이상의 인터페이스 IL — newslot virtual에 본문이 붙는다
.method public hidebysig newslot virtual 
    instance bool TrySave (string data) cil managed 
{
    // 본문 코드
}

C# 11 — static abstract 인터페이스 멤버 (DIM의 진화)

C#
// C# 11
public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T left, T right);   // 정적 추상 멤버
    static virtual T Zero => default!;                // 정적 DIM도 가능
}

public struct Vec : IAddable<Vec>
{
    public int X;
    public static Vec operator +(Vec a, Vec b) => new() { X = a.X + b.X };
}

C# 11의 static abstract는 DIM 인프라를 그대로 활용해 정적 메서드도 인터페이스 계약이 될 수 있게 했습니다. INumber<T> 같은 BCL의 제네릭 수학(Generic Math) 인터페이스가 이 위에서 만들어졌습니다.


7. 정리

이번 글의 핵심을 다음 체크리스트로 압축합니다.

  • [ ] DIM은 인터페이스 멤버에 본문을 쓸 수 있게 해주는 C# 8.0 기능이다 — 라이브러리 진화를 위한 도구.
  • [ ] 본문이 있는 멤버는 구현 강제 대상에서 빠진다 — 기존 구현 클래스를 깨지 않고 새 메서드를 추가할 수 있다.
  • [ ] 인스턴스 필드는 못 가진다 — 인스턴스 상태가 필요하면 추상 프로퍼티로 구현 클래스에 위임한다.
  • [ ] 클래스 참조로는 호출 불가 — 인터페이스 타입으로 캐스팅해야 DIM 멤버가 보인다.
  • [ ] CLR이 직접 지원 — .NET Core 3.0 / .NET Standard 2.1 이상에서만 동작.
  • [ ] 다이아몬드 문제 — 같은 시그니처 DIM을 가진 두 인터페이스를 동시에 구현하면 클래스에서 직접 override 해야 한다.
  • [ ] Silent override 위험 — 클래스 메서드가 우연히 인터페이스 DIM과 시그니처가 같으면 알림 없이 구현으로 받아들인다.
  • [ ] Unity는 2021.2 이상 — 그 이전 버전 IL2CPP에선 동작 미보장.
  • [ ] DIM vs abstract class: 상태 공유가 필요하면 abstract class, 다중 상속·옵션 메서드·라이브러리 진화가 필요하면 DIM.
  • [ ] DIM이 정적 멤버 허용의 초석이 되어 C# 11의 static abstract (제네릭 수학)으로 확장되었다.

DIM은 인터페이스의 본질("순수 계약")을 약간 흐리는 대신 라이브러리를 안전하게 진화시킬 수 있는 강력한 escape hatch입니다. 새 인터페이스를 설계할 때 적극 활용하기보다, 이미 공개된 인터페이스에 새 멤버를 추가해야 할 때 안전망으로 사용하는 것이 일반적인 모범 사례입니다.

반응형

+ Recent posts