EveryDay.DevUp

[PART4.인터페이스(1/3)] interface — 계약으로서의 인터페이스 본문

C# 심화

[PART4.인터페이스(1/3)] interface — 계약으로서의 인터페이스

EveryDay.DevUp 2026. 4. 5. 17:02

interface — 계약으로서의 인터페이스

인터페이스는 "이 객체가 무엇을 할 수 있는가"를 선언하는 계약이다. Unity에서 총알이 적을 맞혔을 때 if (enemy != null), if (barrel != null) 분기를 줄줄이 쓰고 있다면 — 인터페이스 하나로 그 코드를 절반 이하로 줄일 수 있다.


문제 제기

Unity로 액션 게임을 만들고 있다. 총알이 무언가에 부딪혔을 때, 상대가 적이든, 플레이어든, 나무 상자든 데미지를 입혀야 한다.

C#
// 모든 타입을 하나하나 체크하는 코드
void OnCollisionEnter(Collision collision)
{
    var enemy = collision.GetComponent<Enemy>();
    if (enemy != null) enemy.TakeDamage(10);

    var player = collision.GetComponent<Player>();
    if (player != null) player.TakeDamage(10);

    var crate = collision.GetComponent<WoodenCrate>();
    if (crate != null) crate.TakeDamage(10);

    // 새로운 타입이 추가될 때마다 여기에 if문이 늘어난다
}

새로운 타입(폭발하는 드럼통, NPC, 파괴 가능한 벽)이 추가될 때마다 총알 스크립트를 열어서 if문을 추가해야 한다. 타입이 10개면 if문도 10개다.

여기에 "상호작용 가능한 오브젝트"도 만들고, "저장 가능한 오브젝트"도 만들어야 한다면? 모든 스크립트가 타입 체크 if문 지옥에 빠진다.

이 문제의 근본 원인은 호출하는 쪽이 상대방의 구체적인 타입을 알아야 한다는 것이다. 인터페이스는 이 의존성을 끊어낸다.


개념 정의

인터페이스 — "~할 수 있다(CAN-DO)"의 계약

interface IDamageable

인터페이스는 일종의 자격증이다. 운전면허를 가진 사람은 세단이든, SUV든, 트럭이든 운전할 수 있다. 면허 시험관은 운전자의 이름이나 나이를 알 필요 없다 — "운전면허가 있는가?"만 확인하면 된다.

프로그래밍에서도 동일하다. IDamageable 인터페이스를 구현한 객체는 타입이 무엇이든 TakeDamage()를 호출할 수 있다. 총알은 상대가 누구인지 모른 채, "데미지를 받을 수 있는 자격이 있는가?"만 확인하고 호출한다.

interface — 인터페이스 키워드 클래스나 구조체가 구현해야 할 멤버(메서드, 프로퍼티, 이벤트, 인덱서)의 시그니처만 정의하는 참조 타입이다. 구현(본문)은 포함하지 않으며, 인스턴스 필드도 가질 수 없다. 인터페이스를 구현하는 타입은 선언된 모든 멤버를 반드시 구현해야 한다.
예시: public interface IDamageable { void TakeDamage(int amount); }TakeDamage 메서드를 구현할 것을 요구하는 계약

클래스 상속이 "A B이다(IS-A)" 관계를 나타낸다면, 인터페이스는 "A ~할 수 있다(CAN-DO)" 능력을 선언한다. EnemyMonoBehaviour이다(IS-A). 동시에 Enemy는 데미지를 받을 수 있다(CAN-DO: IDamageable).

핵심은 다중 구현이다. C#의 클래스는 단 하나의 부모 클래스만 상속받을 수 있지만(단일 상속), 인터페이스는 제한 없이 여러 개를 구현할 수 있다.

C#
public interface IDamageable
{
    void TakeDamage(int amount);
}

public interface IInteractable
{
    void Interact();
}

public interface ISaveable
{
    void Save();
}

// Player는 세 가지 행위를 모두 수행 가능
public class Player : IDamageable, IInteractable, ISaveable
{
    private int hp = 100;

    public void TakeDamage(int amount) => hp -= amount;
    public void Interact() { }
    public void Save() { }
}

// 나무 상자는 데미지만 받을 수 있음
public class WoodenCrate : IDamageable
{
    private int durability = 50;
    public void TakeDamage(int amount) => durability -= amount;
}
IL
// Player 클래스 — IDamageable, IInteractable, ISaveable 세 인터페이스를 모두 구현
.class public auto ansi beforefieldinit Player
    extends [System.Runtime]System.Object
    implements IDamageable,        // ← 세 개의 인터페이스가 메타데이터에 기록된다
               IInteractable,
               ISaveable
{
    // TakeDamage — IDamageable 계약 이행
    .method public final hidebysig newslot virtual  // ← final virtual: 인터페이스 구현 메서드
        instance void TakeDamage (int32 amount) cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.0
        IL_0002: ldfld int32 Player::hp
        IL_0007: ldarg.1
        IL_0008: sub
        IL_0009: stfld int32 Player::hp   // hp -= amount
        IL_000e: ret
    }
}

// WoodenCrate 클래스 — IDamageable만 구현
.class public auto ansi beforefieldinit WoodenCrate
    extends [System.Runtime]System.Object
    implements IDamageable              // ← 인터페이스 하나만
{
    .method public final hidebysig newslot virtual
        instance void TakeDamage (int32 amount) cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.0
        IL_0002: ldfld int32 WoodenCrate::durability
        IL_0007: ldarg.1
        IL_0008: sub
        IL_0009: stfld int32 WoodenCrate::durability  // durability -= amount
        IL_000e: ret
    }
}

IL에서 주목할 부분은 implements 키워드다. CLR(Common Language Runtime, C# 코드를 실행하는 런타임 엔진)은 클래스가 로드될 때 이 implements 목록을 읽어서, 해당 인터페이스의 모든 메서드가 실제로 구현되었는지 검증한다. 하나라도 빠뜨리면 런타임 에러가 발생한다.

또 하나 눈여겨볼 것은 final virtual이다. 인터페이스를 암묵적으로 구현한 메서드는 자동으로 virtual(가상 메서드)이 되며, 동시에 final(파생 클래스에서 다시 override 불가)이 붙는다. 이는 인터페이스 디스패치(어떤 구현을 호출할지 결정하는 과정)에 필요한 메커니즘이다.

핵심: 인터페이스는 "무엇을 할 수 있는가"만 정의하고, "어떻게 하는가"는 구현 클래스에게 맡긴다. 이 분리 덕분에 호출자는 구체 타입을 모른 채 기능을 사용할 수 있다.

내부 동작

인터페이스 디스패치 — CLR이 올바른 메서드를 찾는 방법

IDamageable target = new Enemy();

인터페이스 타입 변수로 메서드를 호출하면, CLR은 다음 과정을 거친다:

  1. 객체 헤더에서 타입 포인터를 읽어 MethodTable(메서드 테이블, 타입별로 CLR이 생성하는 메서드 주소 목록)을 찾는다.
  2. MethodTable 안의 Interface Map(인터페이스 맵)에서 IDamageable에 대한 항목을 탐색한다.
  3. 해당 인터페이스 메서드가 매핑된 실제 구현 메서드의 주소로 점프하여 실행한다.

클래스 타입으로 직접 호출하면 2번 단계(Interface Map 조회)가 생략되므로 약간 더 빠르다. 하지만 .NET의 VSD(Virtual Stub Dispatch) 캐싱 덕분에, 같은 타입으로 반복 호출하면 캐시된 주소로 직접 점프하여 오버헤드가 거의 사라진다.

이 차이를 IL로 확인해보자.

C#
public interface IDamageable
{
    void TakeDamage(int amount);
}

public class Enemy : IDamageable
{
    private int hp = 100;
    public void TakeDamage(int amount) => hp -= amount;
}

public class Program
{
    public static void Main()
    {
        var enemy = new Enemy();

        // 1) 클래스 타입으로 직접 호출
        enemy.TakeDamage(10);

        // 2) 인터페이스 타입으로 호출
        IDamageable target = enemy;
        target.TakeDamage(10);
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    IL_0000: newobj instance void Enemy::.ctor()
    IL_0005: dup

    // 1) 클래스 타입으로 호출 — callvirt Enemy::TakeDamage
    IL_0006: ldc.i4.s 10
    IL_0008: callvirt instance void Enemy::TakeDamage(int32)

    // 2) 인터페이스 타입으로 호출 — callvirt IDamageable::TakeDamage
    IL_000d: ldc.i4.s 10
    IL_000f: callvirt instance void IDamageable::TakeDamage(int32)

    IL_0014: ret
}

두 호출 모두 IL에서는 callvirt를 사용한다. 차이는 호출 대상이다:

  • callvirt instance void Enemy::TakeDamage — CLR이 MethodTable의 가상 메서드 슬롯으로 바로 점프
  • callvirt instance void IDamageable::TakeDamage — CLR이 Interface Map을 거쳐 실제 구현 주소를 찾은 뒤 점프

런타임에서 한 단계가 더 추가되지만, 대부분의 애플리케이션에서 이 차이는 무시할 수준이다. Unity 게임에서도 매 프레임 수천 번 호출하지 않는 한 문제가 되지 않는다.

핵심: 인터페이스 호출은 클래스 직접 호출보다 Interface Map 조회가 한 단계 더 있지만, VSD 캐싱 덕분에 실질적 성능 차이는 미미하다.

명시적 인터페이스 구현 — 이름 충돌 해결

두 인터페이스가 동일한 이름의 메서드를 요구할 때가 있다.

명시적 인터페이스 구현 (Explicit Interface Implementation) 인터페이스 이름을 메서드 앞에 붙여 InterfaceName.MethodName() 형태로 구현하는 방식이다. 이렇게 구현한 메서드는 해당 인터페이스 타입으로 캐스팅해야만 호출할 수 있다.
예시: string IJsonExportable.Export() => "...";IJsonExportable로 캐스팅해야 호출 가능
C#
public interface IJsonExportable
{
    string Export();
}

public interface IBinaryExportable
{
    string Export();
}

public class GameData : IJsonExportable, IBinaryExportable
{
    // 명시적 구현 — 각 인터페이스별로 다른 동작
    string IJsonExportable.Export() => "{\"hp\":100}";
    string IBinaryExportable.Export() => "0x64";
}

public class Program
{
    public static void Main()
    {
        var data = new GameData();
        string json = ((IJsonExportable)data).Export();
        string binary = ((IBinaryExportable)data).Export();
    }
}
IL
// 명시적 구현된 메서드 — private으로 표시되고 .override로 인터페이스 메서드에 매핑
.method private final hidebysig newslot virtual   // ← private: 직접 호출 불가
    instance string IJsonExportable.Export () cil managed
{
    .override method instance string IJsonExportable::Export()  // ← 이 메서드가 IJsonExportable.Export를 구현
    IL_0000: ldstr "{\"hp\":100}"
    IL_0005: ret
}

.method private final hidebysig newslot virtual   // ← 역시 private
    instance string IBinaryExportable.Export () cil managed
{
    .override method instance string IBinaryExportable::Export()  // ← IBinaryExportable.Export를 구현
    IL_0000: ldstr "0x64"
    IL_0005: ret
}

// Main에서의 호출
.method public hidebysig static void Main () cil managed
{
    IL_0000: newobj instance void GameData::.ctor()
    IL_0005: dup
    IL_0006: callvirt instance string IJsonExportable::Export()      // ← IJsonExportable로 디스패치
    IL_000b: pop
    IL_000c: callvirt instance string IBinaryExportable::Export()    // ← IBinaryExportable로 디스패치
    IL_0011: pop
    IL_0012: ret
}

IL에서 핵심은 두 가지다:

  1. 명시적 구현 메서드는 private으로 표시된다 — data.Export() 같은 직접 호출은 컴파일 에러.
  2. .override 지시어가 어떤 인터페이스 메서드를 구현하는지 CLR에게 알려준다. 이름이 같아도 CLR은 .override로 정확한 매핑을 구분한다.
핵심: 명시적 구현은 이름 충돌을 해결하고, 의도적으로 기능을 숨기고 싶을 때 사용한다. IL에서 private + .override로 구현된다.

실전 적용

Before/After — 타입 체크 지옥에서 인터페이스로

❌ Before: 구체 타입에 의존하는 총알 스크립트

C#
// 타입이 추가될 때마다 이 스크립트를 수정해야 한다
public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        var enemy = collision.gameObject.GetComponent<Enemy>();
        if (enemy != null) enemy.TakeDamage(10);

        var crate = collision.gameObject.GetComponent<WoodenCrate>();
        if (crate != null) crate.TakeDamage(10);
    }
}

이 코드의 문제는 개방-폐쇄 원칙(OCP) 위반이다. 새 타입이 추가되면 총알 스크립트를 열어서 수정해야 한다.

✅ After: 인터페이스로 결합도를 끊어낸 코드

C#
public interface IDamageable
{
    void TakeDamage(int amount);
}

public class Enemy : MonoBehaviour, IDamageable
{
    private int hp = 100;
    public void TakeDamage(int amount)
    {
        hp -= amount;
    }
}

public class WoodenCrate : MonoBehaviour, IDamageable
{
    private int durability = 50;
    public void TakeDamage(int amount)
    {
        durability -= amount;
    }
}

// 총알은 IDamageable만 안다 — 구체 타입을 모른다
public class Bullet : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        IDamageable target = collision.gameObject.GetComponent<IDamageable>();
        if (target != null)
        {
            target.TakeDamage(10);
        }
    }
}
IL
// Main에서 인터페이스를 통한 다형적 호출
.method public hidebysig static void Main () cil managed
{
    // Player 생성 → IDamageable로 호출
    IL_0000: newobj instance void Player::.ctor()
    IL_0005: ldc.i4.s 10
    IL_0007: callvirt instance void IDamageable::TakeDamage(int32)  // ← Player.TakeDamage 실행

    // WoodenCrate 생성 → 같은 IDamageable로 호출
    IL_000c: newobj instance void WoodenCrate::.ctor()
    IL_0011: ldc.i4.5
    IL_0012: callvirt instance void IDamageable::TakeDamage(int32)  // ← WoodenCrate.TakeDamage 실행

    IL_0017: ret
}

IL에서 두 호출 모두 callvirt instance void IDamageable::TakeDamage로 동일하다. CLR이 런타임에 객체의 실제 타입(Player 또는 WoodenCrate)을 확인하고 올바른 구현을 찾아 실행한다. 새로운 타입(DrumBarrel, Fence 등)을 추가해도 이 IL 코드는 변하지 않는다 — 새 타입이 IDamageable을 구현하기만 하면 된다.

Unity 실전 패턴 — 다중 인터페이스 조합

Unity에서 게임 오브젝트는 다양한 행위를 가진다. 인터페이스를 조합하면 거대한 부모 클래스 없이 필요한 행위만 골라서 부여할 수 있다.

C#
// 행위 인터페이스 정의
public interface IDamageable { void TakeDamage(int amount); }
public interface IInteractable { void Interact(); }
public interface ISaveable { void Save(); }

// 플레이어 — 세 가지 행위 모두
public class Player : MonoBehaviour, IDamageable, IInteractable, ISaveable
{
    public void TakeDamage(int amount) { /* ... */ }
    public void Interact() { /* 대화, 아이템 줍기 등 */ }
    public void Save() { /* 세이브 데이터 기록 */ }
}

// NPC — 상호작용과 저장만
public class NPC : MonoBehaviour, IInteractable, ISaveable
{
    public void Interact() { /* 대화 시작 */ }
    public void Save() { /* NPC 상태 저장 */ }
}

// 나무 상자 — 데미지만
public class WoodenCrate : MonoBehaviour, IDamageable
{
    public void TakeDamage(int amount) { /* 파괴 처리 */ }
}

// 상호작용 시스템 — IInteractable만 알면 된다
public class InteractionSystem : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.E))
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray, out RaycastHit hit, 3f))
            {
                IInteractable interactable = hit.collider.GetComponent<IInteractable>();
                if (interactable != null)
                {
                    interactable.Interact();
                }
            }
        }
    }
}

InteractionSystem은 상대가 Player인지, NPC인지, 미래에 추가될 Vending Machine인지 전혀 모른다. IInteractable 자격만 있으면 Interact()를 호출한다. 새로운 상호작용 오브젝트가 추가되어도 InteractionSystem 코드는 수정할 필요가 없다.

핵심: 인터페이스를 행위 단위로 분리하면, 각 시스템(데미지, 상호작용, 저장)이 해당 인터페이스에만 의존하므로 결합도가 극도로 낮아진다.

함정과 주의사항

함정 1: struct + 인터페이스 = 박싱

값 타입(struct)이 인터페이스를 구현할 때, 인터페이스 타입 변수에 대입하면 박싱(boxing, 값 타입을 힙에 복사하는 과정)이 발생한다. Unity에서 이것은 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 압박으로 직결된다.

스택 (Stack)

❌ Before: 인터페이스 매개변수로 struct를 전달 — 매 호출마다 박싱

C#
public interface IMovable
{
    void Move(float x, float y);
}

public struct Position : IMovable
{
    public float X;
    public float Y;
    public void Move(float x, float y)
    {
        X += x;
        Y += y;
    }
}

// 이 메서드를 호출할 때마다 Position이 박싱된다
static void MoveTarget(IMovable target)
{
    target.Move(1.0f, 2.0f);
}
IL
// Main에서 MoveTarget 호출 시
IL_0022: box Position                                           // ← 힙에 24+ bytes 할당!
IL_0027: call void Program::MoveTarget(class IMovable)          // ← IMovable 매개변수라서 박싱 필요

// MoveTarget 내부
.method private hidebysig static void MoveTarget (class IMovable target) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldc.r4 1
    IL_0006: ldc.r4 2
    IL_000b: callvirt instance void IMovable::Move(float32, float32)  // 인터페이스 디스패치
    IL_0010: ret
}

box Position이 보인다. Position은 8바이트(float 2개)짜리 값 타입인데, 인터페이스 타입 매개변수에 전달하려면 힙에 복사해야 한다. 이 메서드가 Unity의 Update()에서 매 프레임 호출되면, 매 프레임마다 GC 가비지가 쌓인다.

✅ After: 제네릭 제약 조건으로 박싱 회피

where T : IInterface — 제네릭 제약 조건 (Generic Constraint) 제네릭 타입 매개변수 T가 특정 인터페이스를 구현해야 한다는 조건을 건다. 컴파일러가 T의 구체 타입을 알 수 있으므로, 값 타입을 인터페이스로 박싱하지 않고 직접 메서드를 호출하는 최적화된 코드를 생성한다.
예시: void Move<T>(T target) where T : IMovable — T는 IMovable을 구현한 타입만 허용
C#
// 제네릭 + 인터페이스 제약 — 박싱 없이 인터페이스 메서드 호출
static void MoveTargetGeneric<T>(T target) where T : IMovable
{
    target.Move(1.0f, 2.0f);
}
IL
// 제네릭 메서드 — constrained. 접두어로 박싱 회피
.method private hidebysig static void MoveTargetGeneric<(IMovable) T> (!!T target) cil managed
{
    IL_0000: ldarga.s target
    IL_0002: ldc.r4 1
    IL_0007: ldc.r4 2
    IL_000c: constrained. !!T                                       // ← JIT에게 "T가 값 타입이면 직접 호출해라" 지시
    IL_0012: callvirt instance void IMovable::Move(float32, float32)
    IL_0017: ret
}

// Main에서 호출 시
IL_002c: call void Program::MoveTargetGeneric<valuetype Position>(!!0)  // ← box 없음!

constrained. !!T 접두어가 핵심이다. 이 접두어는 JIT(Just-In-Time, 실행 시점에 IL을 네이티브 코드로 변환하는 컴파일러) 컴파일러에게 "T가 값 타입이면 박싱하지 말고 직접 메서드를 호출하라"고 지시한다. Main에서 호출 시 box 명령어가 완전히 사라진 것을 확인할 수 있다.

Unity 핫패스 규칙: Update(), FixedUpdate(), LateUpdate()에서 struct를 인터페이스 매개변수로 전달하면 매 프레임 박싱이 발생한다. 반드시 제네릭 제약 조건(where T : IInterface)을 사용해야 한다.

함정 2: 기본 인터페이스 메서드의 접근 제한

C# 8.0에서 도입된 기본 인터페이스 메서드(Default Interface Method)에는 직관적이지 않은 함정이 있다.

C#
public interface ILogger
{
    void Log(string message);

    // 기본 구현이 있는 메서드
    void LogWarning(string message)
    {
        Log("[WARNING] " + message);
    }
}

public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        System.Console.WriteLine(message);
    }
}

// ❌ 컴파일 에러!
ConsoleLogger logger = new ConsoleLogger();
logger.LogWarning("test");  // 에러: ConsoleLogger에 LogWarning이 없음

// ✅ 인터페이스 타입으로 캐스팅해야 호출 가능
ILogger iLogger = new ConsoleLogger();
iLogger.LogWarning("test");  // 정상 동작
IL
// ILogger 인터페이스 내의 기본 구현 — 인터페이스 자체에 IL 코드가 존재
.class interface public auto ansi abstract beforefieldinit ILogger
{
    // Log — 추상 메서드 (구현 없음)
    .method public hidebysig newslot abstract virtual
        instance void Log (string message) cil managed
    { }

    // LogWarning — 기본 구현이 있는 가상 메서드 (abstract 아님!)
    .method public hidebysig newslot virtual       // ← abstract가 빠졌다
        instance void LogWarning (string message) cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldstr "[WARNING] "
        IL_0006: ldarg.1
        IL_0007: call string [System.Runtime]System.String::Concat(string, string)
        IL_000c: callvirt instance void ILogger::Log(string)  // ← 자기 자신의 Log() 호출
        IL_0011: ret
    }
}

// Main에서의 호출
IL_0000: newobj instance void ConsoleLogger::.ctor()
IL_0005: dup
IL_0006: ldstr "Hello"
IL_000b: callvirt instance void ILogger::Log(string)          // ← 추상 메서드 호출
IL_0010: ldstr "Careful!"
IL_0015: callvirt instance void ILogger::LogWarning(string)   // ← 기본 구현 호출

IL을 보면 LogWarning의 구현 코드가 ConsoleLogger 클래스가 아닌 ILogger 인터페이스 자체에 들어 있다. ConsoleLogger의 MethodTable에는 LogWarning 슬롯이 없기 때문에, ConsoleLogger 타입 변수로는 호출할 수 없고 반드시 ILogger 타입으로 캐스팅해야 한다.

핵심: 기본 인터페이스 메서드는 인터페이스 타입 참조를 통해서만 호출할 수 있다. 구현 클래스 타입 변수로는 접근 불가능하다.

함정 3: Unity에서 인터페이스 null 체크

Unity의 GetComponent<T>()는 인터페이스 타입에도 동작하지만, null 체크에서 주의가 필요하다.

is — 패턴 매칭 연산자 (Pattern Matching) 객체가 특정 타입인지 검사하고, 참이면 해당 타입의 변수에 바로 대입한다. as + null 체크를 한 줄로 줄여준다.
예시: if (obj is MonoBehaviour mb) — obj가 MonoBehaviour이면 mb에 대입
C#
// ❌ 잘못된 패턴 — Unity의 == 연산자 함정
IInteractable interactable = GetComponent<IInteractable>();
if (interactable == null)  // ← Unity의 오버로딩된 == 가 아닌 System.Object의 == 사용
{
    // 파괴된 오브젝트를 감지하지 못할 수 있다
}

// ✅ 올바른 패턴 — Unity 패턴 매칭 또는 명시적 캐스트
IInteractable interactable = GetComponent<IInteractable>();
if (interactable is MonoBehaviour mb && mb != null)
{
    interactable.Interact();
}

// ✅ 더 간단한 패턴 — TryGetComponent 사용 (Unity 2019.2+)
if (TryGetComponent(out IInteractable interactable))
{
    interactable.Interact();
}

Unity의 UnityEngine.Object== 연산자를 오버로딩하여 파괴된 오브젝트를 null처럼 취급한다. 하지만 인터페이스 타입 변수에 대해 == null을 사용하면 C#의 기본 System.Object.ReferenceEquals가 호출되어, Destroy()로 파괴된 오브젝트가 null이 아닌 것으로 판정될 수 있다.


C# 버전별 변화

C# 1.0~7.x — 순수 계약만 가능

초기 C#에서 인터페이스는 순수하게 시그니처만 정의할 수 있었다. 인터페이스에 새 멤버를 추가하면 모든 구현 클래스에서 컴파일 에러가 발생하는 파괴적 변경(Breaking Change)이었다.

C#
// C# 7.x까지 — 인터페이스는 오직 시그니처만
public interface IDamageable
{
    void TakeDamage(int amount);
    // void TakeDamageWithType(int amount, DamageType type);  ← 추가하면 모든 구현자 컴파일 에러
}

C# 8.0 — 기본 인터페이스 메서드

Before (C# 7.x): 새 메서드 추가 시 모든 구현 클래스 수정 필요

C#
// 인터페이스에 새 메서드를 추가하면...
public interface ILogger
{
    void Log(string message);
    void LogWarning(string message);  // 새로 추가 → 모든 구현자가 이것도 구현해야 함
}

// 기존 클래스에서 컴파일 에러 발생
public class FileLogger : ILogger
{
    public void Log(string message) { /* ... */ }
    // 에러: LogWarning을 구현하지 않았음
}

After (C# 8.0): 기본 구현으로 안전한 확장

C#
public interface ILogger
{
    void Log(string message);

    // 기본 구현 제공 — 기존 구현자를 깨뜨리지 않음
    void LogWarning(string message)
    {
        Log("[WARNING] " + message);
    }
}

// FileLogger는 LogWarning을 구현하지 않아도 컴파일 성공
public class FileLogger : ILogger
{
    public void Log(string message) { /* ... */ }
    // LogWarning은 기본 구현이 자동 적용
}
IL
// C# 8.0 기본 인터페이스 메서드의 IL
// LogWarning — abstract가 아닌 virtual 메서드, 인터페이스 안에 구현 코드가 있다
.method public hidebysig newslot virtual              // ← abstract 없음 = 구현 존재
    instance void LogWarning (string message) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldstr "[WARNING] "
    IL_0006: ldarg.1
    IL_0007: call string [System.Runtime]System.String::Concat(string, string)
    IL_000c: callvirt instance void ILogger::Log(string)
    IL_0011: ret
}

기존 인터페이스 메서드는 abstract virtual이었지만, 기본 구현이 있는 메서드는 abstract 없이 virtual만 붙는다. 인터페이스 자체에 IL 코드가 존재하는 것이 핵심 변화다.

Unity 주의사항: Unity의 Mono 런타임은 기본 인터페이스 메서드 지원이 제한적이다. IL2CPP 백엔드(Unity 2021.2+)에서는 정상 동작하지만, 이전 버전에서는 런타임 에러가 발생할 수 있다. 프로젝트의 Scripting Backend를 확인해야 한다.

C# 11 — 정적 추상 멤버

C# 11에서는 인터페이스에 static abstract 멤버를 선언할 수 있게 되었다. 이전에는 인터페이스로 연산자나 팩토리 메서드 같은 정적 메서드를 강제할 수 없었다.

static abstract — 정적 추상 멤버 인터페이스에서 선언하면, 구현 타입이 반드시 해당 정적 멤버를 제공해야 한다. 제네릭 제약과 결합하여 타입 자체에 대한 연산을 추상화할 수 있다.
예시: static abstract TSelf Parse(string s); — 구현 타입이 정적 Parse 메서드를 반드시 제공

Before (C# 10 이하): 정적 메서드를 인터페이스로 강제 불가

C#
// 파싱 기능을 인터페이스로 강제하고 싶지만 방법이 없었다
// static 메서드는 인터페이스에 선언할 수 없었음
public interface IParsable
{
    // static Damage Parse(string s);  ← 불가능했음
}

After (C# 11): static abstract로 타입 수준 계약

C#
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>
{
    static abstract TSelf Parse(string s);
}

public struct Damage : IParsable<Damage>
{
    public int Amount;

    public static Damage Parse(string s)
    {
        return new Damage { Amount = int.Parse(s) };
    }
}

public class Program
{
    // 제네릭으로 어떤 타입이든 파싱 가능
    static T ParseValue<T>(string input) where T : IParsable<T>
    {
        return T.Parse(input);
    }

    public static void Main()
    {
        var dmg = ParseValue<Damage>("42");
    }
}
IL
// static abstract 메서드 선언 — 인터페이스에 static virtual로 표시
.class interface public auto ansi abstract beforefieldinit IParsable`1<(class IParsable`1<!TSelf>) TSelf>
{
    .method public hidebysig abstract virtual static  // ← static + abstract + virtual
        !TSelf Parse (string s) cil managed
    { }
}

// Damage.Parse — .override로 인터페이스 정적 메서드를 구현
.method public hidebysig static valuetype Damage Parse (string s) cil managed
{
    .override method !0 class IParsable`1<valuetype Damage>::Parse(string)  // ← 정적 메서드도 .override 매핑
    IL_0000: ldloca.s 0
    IL_0002: initobj Damage
    IL_0008: ldloca.s 0
    IL_000a: ldarg.0
    IL_000b: call int32 [System.Runtime]System.Int32::Parse(string)
    IL_0010: stfld int32 Damage::Amount
    IL_0015: ldloc.0
    IL_0016: ret
}

// ParseValue<T> — constrained. call로 정적 메서드 디스패치
.method private hidebysig static !!T ParseValue<(class IParsable`1<!!T>) T> (string input) cil managed
{
    IL_0000: ldarg.0
    IL_0001: constrained. !!T                                       // ← 타입 T에 대해 정적 메서드 직접 호출
    IL_0007: call !0 class IParsable`1<!!T>::Parse(string)
    IL_000c: ret
}

constrained. !!T가 다시 등장한다. 이번에는 인스턴스 메서드가 아닌 정적 메서드 디스패치에 사용된다. JIT는 TDamage일 때 Damage.Parse()를 직접 호출하는 코드를 생성한다.

실전 의미: .NET의 Generic Math(INumber<T>, IAdditionOperators<T,T,T> 등)는 이 기능 위에 구축되었다. 게임에서 int, float, double 어떤 숫자 타입이든 동일한 수학 로직을 작성할 수 있다.

정리

  • 인터페이스는 "무엇을 할 수 있는가(CAN-DO)"의 계약이다. 클래스 상속(IS-A)과 달리 다중 구현이 가능하여 유연한 설계를 제공한다.
  • 호출자는 구체 타입을 모른 채 기능을 사용할 수 있다. GetComponent<IInterface>()로 결합도를 끊어내면, 새 타입이 추가되어도 호출자 코드를 수정할 필요가 없다.
  • 인터페이스 호출은 callvirt + Interface Map 조회로 동작한다. 클래스 직접 호출보다 한 단계 더 거치지만, VSD 캐싱 덕분에 실질적 성능 차이는 미미하다.
  • struct + 인터페이스 = 박싱 주의. 인터페이스 타입 변수에 struct를 대입하면 힙 할당이 발생한다. where T : IInterface 제네릭 제약으로 회피해야 한다.
  • 명시적 구현은 이름 충돌 해결과 의도적 숨김에 사용한다. IL에서 private + .override로 구현된다.
  • 기본 인터페이스 메서드(C# 8.0)는 인터페이스 타입 참조로만 호출 가능하다. 구현 클래스 변수로는 접근할 수 없는 함정이 있다.
  • static abstract(C# 11)로 타입 수준 계약이 가능해졌다. 연산자, 팩토리 메서드를 인터페이스로 강제할 수 있다.