EveryDay.DevUp

[PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가 본문

C# 심화

[PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가

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

abstract class vs interface — 언제 무엇을 선택하는가

추상 클래스와 인터페이스는 모두 다형성을 제공하지만, 설계 의도와 런타임 동작이 완전히 다르다. 무기 시스템을 만들 때 추상 클래스를 쓸지, 인터페이스를 쓸지 매번 고민된다면 — 이 글을 읽고 나면 즉시 판단할 수 있다.


문제 제기

Unity로 RPG를 만들고 있다. 플레이어, 적, 나무 상자, 폭발하는 드럼통 — 전부 "공격받으면 데미지를 입는" 기능이 필요하다. 어떻게 설계해야 할까?

처음 떠오르는 방법은 공통 부모 클래스를 만드는 것이다.

C#
public abstract class DamageableObject : MonoBehaviour
{
    protected int hp;
    public abstract void TakeDamage(int amount);
}

플레이어도, 적도, 드럼통도 이 클래스를 상속받으면 된다 — 깔끔해 보인다. 하지만 문제가 생긴다.

플레이어에 "저장 가능" 기능을 추가하고 싶다. SaveableObject라는 추상 클래스를 하나 더 만들었는데 — C#은 단일 상속만 허용한다. DamageableObject를 이미 상속받은 플레이어가 SaveableObject까지 상속받을 수 없다.

드럼통은 MonoBehaviour를 상속받아야 하는데, DamageableObject가 이미 MonoBehaviour를 상속받고 있다. 드럼통에 물리 엔진 컴포넌트를 조합하고 싶어도 상속 계층이 뻣뻣하게 묶여 있어 유연하지 않다.

이것이 "추상 클래스만으로 모든 공통 행위를 설계하면 안 되는 이유"이고, 인터페이스가 왜 필요한지 보여주는 대표적 시나리오다.


개념 정의

추상 클래스 — "~이다(Is-A)" 관계

abstract class

추상 클래스는 "이 타입의 공통 뼈대"를 정의한다. 일상에서 비유하면 자동차 설계 도면이다. 엔진, 바퀴, 핸들은 모든 자동차에 공통이지만(공통 구현), 연료 방식(가솔린인지, 전기인지)은 각 모델이 결정한다(추상 메서드).

abstract — 추상 키워드 클래스나 메서드 앞에 붙여 "직접 인스턴스화할 수 없고, 파생 클래스가 반드시 구현해야 함"을 표시한다. 추상 클래스는 new로 직접 생성할 수 없다.
예시: public abstract class CharacterBase { } — 직접 new CharacterBase()는 컴파일 에러
virtual / override — 가상 메서드와 재정의 virtual은 파생 클래스가 재정의할 수 있는 메서드를 선언한다. override는 부모의 virtual 또는 abstract 메서드를 파생 클래스에서 재정의할 때 사용한다. abstractvirtual의 특수한 형태로, 반드시 override해야 한다.
예시: public override void Attack() { ... } — 부모의 Attack()을 자식이 재정의
C#
public abstract class CharacterBase
{
    protected int hp;

    public CharacterBase(int initialHp)
    {
        hp = initialHp;
    }

    // 공통 구현 — 파생 클래스가 그대로 사용
    public void PrintStatus()
    {
        Console.WriteLine($"HP: {hp}");
    }

    // 추상 메서드 — 파생 클래스가 반드시 구현
    public abstract void Attack();
}

public class Warrior : CharacterBase
{
    public Warrior() : base(100) { }

    public override void Attack()
    {
        Console.WriteLine("Sword slash!");
        hp -= 5; // 부모의 protected 필드에 직접 접근
    }
}
IL
// CharacterBase — abstract 키워드가 IL에서도 그대로 반영된다
.class public auto ansi abstract beforefieldinit CharacterBase
    extends [System.Runtime]System.Object
{
    .field family int32 hp  // protected → family. 파생 클래스만 접근 가능

    // Attack()은 본문이 없다 — 구현은 파생 클래스에게 위임
    .method public hidebysig newslot abstract virtual
        instance void Attack () cil managed
    {
    }
}

// Warrior — CharacterBase를 상속
.class public auto ansi beforefieldinit Warrior
    extends CharacterBase
{
    .method public hidebysig virtual
        instance void Attack () cil managed
    {
        IL_0001: ldstr "Sword slash!"
        IL_0006: call void [System.Console]System.Console::WriteLine(string)
        IL_000c: ldarg.0
        IL_000d: ldarg.0
        IL_000e: ldfld int32 CharacterBase::hp  // 부모의 필드를 직접 읽는다
        IL_0013: ldc.i4.5
        IL_0014: sub
        IL_0015: stfld int32 CharacterBase::hp  // 부모의 필드에 직접 쓴다
        IL_001a: ret
    }
}

// Main에서 호출
IL_0001: newobj instance void Warrior::.ctor()       // Warrior 인스턴스 생성
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: callvirt instance void CharacterBase::Attack()  // 가상 디스패치로 호출

IL에서 핵심은 두 가지다.

  1. abstract virtual: CharacterBase.Attack()은 IL에서도 메서드 본문이 비어 있다. CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)은 이 메서드를 직접 호출하면 런타임 에러를 발생시킨다.
  2. callvirt: Main에서 unit.Attack()을 호출하면 IL은 callvirt를 사용한다. 변수 타입이 CharacterBase여도 CLR은 실제 객체(Warrior)의 메서드 테이블(vtable)을 조회하여 오버라이드된 Warrior.Attack()을 실행한다.

핵심: 추상 클래스는 필드(상태)와 구현 코드를 자식에게 물려줄 수 있다. hp 필드를 CharacterBase에 선언하면 Warrior가 별도 선언 없이 바로 사용한다.

인터페이스 — "~할 수 있다(Can-Do)" 관계

interface IAttackable

인터페이스는 "이 객체가 할 수 있는 행위"를 정의한다. 레스토랑 메뉴판에 비유하면, 메뉴판(인터페이스)은 "이 요리를 주문할 수 있다"고 약속하지만 요리사(구현 클래스)가 어떻게 만들지는 각자 결정한다.

interface — 인터페이스 키워드 메서드, 프로퍼티, 이벤트, 인덱서의 시그니처(서명)만 정의하는 계약. 클래스나 구조체가 이 계약을 구현하면 해당 행위를 "할 수 있다"고 보장한다. 하나의 클래스가 여러 인터페이스를 동시에 구현할 수 있다.
예시: public class Player : MonoBehaviour, IDamageable, ISaveable { } — Player는 데미지도 받고, 저장도 가능
C#
public interface IAttackable
{
    void TakeDamage(int amount);
}

// Player와 Barrel은 완전히 다른 객체지만
// 둘 다 "공격받을 수 있다"는 행위를 공유한다
public class Player : IAttackable
{
    private int hp = 100;

    public void TakeDamage(int amount)
    {
        hp -= amount;
        Console.WriteLine($"Player HP: {hp}");
    }
}

public class Barrel : IAttackable
{
    private int durability = 30;

    public void TakeDamage(int amount)
    {
        durability -= amount;
        if (durability <= 0)
            Console.WriteLine("Barrel destroyed!");
    }
}
IL
// 인터페이스 — IL에서 abstract + interface 키워드로 선언된다
.class interface public auto ansi abstract beforefieldinit IAttackable
{
    .method public hidebysig newslot abstract virtual
        instance void TakeDamage (int32 amount) cil managed
    {
    }
}

// Player — implements IAttackable
.class public auto ansi beforefieldinit Player
    extends [System.Runtime]System.Object
    implements IAttackable
{
    .method public final hidebysig newslot virtual  // final = sealed 구현
        instance void TakeDamage (int32 amount) cil managed
    {
        IL_0001: ldarg.0
        IL_0003: ldfld int32 Player::hp      // 자신의 필드 사용
        IL_0008: ldarg.1
        IL_0009: sub
        IL_000a: stfld int32 Player::hp
        ...
    }
}

// DealDamage — 인터페이스 타입으로 호출
.method public hidebysig static void DealDamage (
    class IAttackable target, int32 amount) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: callvirt instance void IAttackable::TakeDamage(int32)  // 인터페이스 디스패치
    IL_0008: nop
    IL_0009: ret
}

IL에서 핵심 차이를 보자.

  1. implements IAttackable: 클래스 선언에 implements 절이 추가된다. 추상 클래스 상속은 extends를 사용하는 것과 다르다.
  2. final 키워드: 인터페이스를 구현한 메서드는 IL에서 final로 표시된다. 이 클래스를 더 상속받아 오버라이드하는 것을 기본적으로 막는다(C#에서 virtual을 명시하면 달라진다).
  3. 인터페이스 디스패치: callvirt instance void IAttackable::TakeDamage는 CLR이 인터페이스 맵(Interface Map)을 통해 실제 구현을 찾는다. 추상 클래스의 vtable(가상 메서드 테이블) 조회보다 한 단계 더 간접 참조가 발생한다.

핵심: 인터페이스는 필드(상태)를 가질 수 없다. PlayerBarrel은 각자 hp, durability라는 완전히 다른 필드를 갖고, 공통된 것은 "TakeDamage라는 행위를 할 수 있다"는 계약뿐이다.

한눈에 비교

구분 추상 클래스 인터페이스
관계 Is-A ("~이다") Can-Do ("~할 수 있다")
상속 단일 상속만 가능 다중 구현 가능
필드(상태) 가능 불가능
생성자 가능 불가능
접근 제한자 protected 등 사용 가능 기본 public (C# 8.0 이후 일부 변경)
공통 구현 일반 메서드로 제공 C# 8.0 DIM으로 제한적 제공
값 타입(struct) 상속 불가 구현 가능
IL 선언 extends implements

내부 동작

메서드 디스패치 — vtable vs Interface Map

추상 클래스 디스패치

CLR이 가상 메서드를 호출할 때 내부에서 일어나는 일을 살펴보자.

추상 클래스 호출 경로:

  1. 객체의 Type Object Pointer를 따라간다
  2. vtable(가상 메서드 테이블, 메서드 주소를 순서대로 저장한 배열)에서 슬롯 번호로 메서드 주소를 가져온다
  3. 해당 주소의 코드를 실행한다

인터페이스 호출 경로:

  1. 객체의 Type Object Pointer를 따라간다
  2. Interface Map에서 해당 인터페이스가 vtable의 어디에 매핑되는지 찾는다
  3. vtable에서 실제 메서드 주소를 가져온다
  4. 해당 주소의 코드를 실행한다

인터페이스 호출은 Interface Map 조회라는 추가 간접 참조가 한 단계 더 들어간다. 하지만 실전에서 이 차이는 나노초 단위다. 수백만 회 호출하는 핫패스가 아니라면 신경 쓸 필요 없다.

struct + 인터페이스 — 박싱의 함정

인터페이스의 진짜 성능 함정은 디스패치 비용이 아니라 값 타입(struct)을 인터페이스 변수에 대입할 때 발생하는 박싱(Boxing)이다.

박싱(Boxing) 값 타입(스택에 저장)을 참조 타입(힙에 저장)으로 변환하는 과정. 힙에 새 객체를 할당하고 값을 복사하므로 GC(Garbage Collector, 메모리를 자동 회수하는 런타임 구성요소) 부담이 발생한다.

Before — 인터페이스 변수로 받으면 박싱 발생:

C#
public interface IResettable
{
    void Reset();
}

public struct Timer : IResettable
{
    public float Elapsed;

    public void Reset()
    {
        Elapsed = 0f;
    }
}

// 인터페이스 매개변수 — struct를 넣으면 박싱
public static void ResetBad(IResettable obj)
{
    obj.Reset();
}
IL
// Main에서 ResetBad(timer) 호출 시
IL_0017: ldloc.0
IL_0018: box Timer              // 박싱 발생! Timer를 힙에 복사
IL_001d: call void Program::ResetBad(class IResettable)

box Timer — 이 한 줄이 핵심이다. Timer는 스택에 있는 값 타입인데, IResettable 매개변수(참조 타입)에 전달하려면 힙에 새 객체를 만들어 복사해야 한다. Unity의 Update 루프에서 매 프레임 호출하면 매 프레임 힙 할당이 발생한다.

After — 제네릭 제약으로 박싱 회피:

where T : IResettable — 제네릭 제약 조건 (Generic Constraint) 제네릭 타입 매개변수 T가 반드시 특정 인터페이스나 클래스를 구현/상속해야 한다고 제한한다. 컴파일러가 T의 멤버를 알 수 있으므로 안전하게 호출할 수 있고, 값 타입의 경우 박싱을 방지한다.
예시: void Process<T>(T item) where T : IComparable<T> — T는 반드시 비교 가능해야 함
C#
// 제네릭 + 인터페이스 제약 — 박싱 없이 호출
public static void ResetGood<T>(ref T obj) where T : IResettable
{
    obj.Reset();
}
IL
// ResetGood<T> 메서드 내부
.method public hidebysig static void ResetGood<(IResettable) T> (!!T& obj) cil managed
{
    IL_0001: ldarg.0
    IL_0002: constrained. !!T    // 박싱 없이 값 타입의 메서드를 직접 호출
    IL_0008: callvirt instance void IResettable::Reset()
}

// Main에서 ResetGood(ref timer) 호출 시
IL_0023: ldloca.s 0              // 스택 주소를 직접 전달
IL_0025: call void Program::ResetGood<valuetype Timer>(!!0&)  // 박싱 없음
constrained. — 제약 호출 접두사 IL에서 제네릭 타입의 메서드를 호출할 때 사용하는 접두사. 값 타입이면 박싱 없이 직접 호출하고, 참조 타입이면 일반 가상 호출을 수행한다. 제네릭 제약(where T : IResettable)이 있을 때 컴파일러가 자동으로 생성한다.

box 명령어가 완전히 사라졌다. constrained. !!T 접두사 덕분에 CLR은 Timer가 값 타입임을 인지하고 박싱 없이 직접 메서드를 호출한다. ref T로 전달하므로 값 복사도 없다.


실전 적용

판단 기준 — 언제 무엇을 선택하는가

공통 행위가 필요하다

정리하면 판단은 세 가지 질문으로 충분하다.

  1. 공통 필드(상태)를 공유해야 하는가? → 예: 추상 클래스
  2. 같은 계열(Is-A)인가? → 예: 추상 클래스
  3. 서로 다른 계열에 공통 행위만 부여하는가? → 인터페이스

실전에서는 둘을 함께 쓰는 경우가 가장 많다. 같은 계열 안에서의 공통 뼈대는 추상 클래스로, 계열을 넘나드는 행위는 인터페이스로 분리한다.

Unity 실전 — 추상 클래스 + 인터페이스 조합

무기 시스템을 예로 들어보자.

Before — 추상 클래스만 사용:

C#
// ❌ 모든 행위를 하나의 상속 트리에 밀어넣는다
public abstract class WeaponBase : MonoBehaviour
{
    protected int baseDamage;
    protected float lastFireTime;

    public void Fire()
    {
        if (Time.time - lastFireTime < GetCooldown()) return;
        lastFireTime = Time.time;
        OnFire();
    }

    protected abstract float GetCooldown();
    protected abstract void OnFire();

    // 나중에 "업그레이드 가능한 무기"를 추가하고 싶으면?
    // "저장 가능한 무기"도 필요하면?
    // → 추상 클래스에 기능을 계속 추가해야 한다
    // → 모든 자식 클래스가 불필요한 기능까지 상속받는다
}

이 구조의 문제: 업그레이드 기능이 필요한 무기와 필요 없는 무기가 있는데, WeaponBaseUpgrade() 메서드를 추가하면 모든 무기가 상속받게 된다. "바나나를 원했는데 정글 전체가 딸려오는" 문제다.

After — 추상 클래스 + 인터페이스 조합:

C#
// ✅ 공통 뼈대는 추상 클래스로
public abstract class WeaponBase : MonoBehaviour
{
    [SerializeField] protected int baseDamage;
    protected float lastFireTime;

    public void Fire()
    {
        if (Time.time - lastFireTime < GetCooldown()) return;
        lastFireTime = Time.time;
        OnFire();
    }

    protected abstract float GetCooldown();
    protected abstract void OnFire();
}

// ✅ 교차 행위는 인터페이스로 분리
public interface IUpgradeable
{
    int Level { get; }
    void Upgrade();
}

public interface ISaveable
{
    string Serialize();
    void Deserialize(string data);
}

// 업그레이드 가능한 검
public class Sword : WeaponBase, IUpgradeable
{
    public int Level { get; private set; } = 1;

    protected override float GetCooldown() => 0.5f;
    protected override void OnFire() { /* 근접 공격 */ }
    public void Upgrade() { Level++; baseDamage += 10; }
}

// 업그레이드 불가, 저장 가능한 총
public class Gun : WeaponBase, ISaveable
{
    protected override float GetCooldown() => 0.1f;
    protected override void OnFire() { /* 원거리 공격 */ }
    public string Serialize() => $"gun:{baseDamage}";
    public void Deserialize(string data) { /* 복원 */ }
}

Sword는 업그레이드가 가능하지만 저장은 불필요하다. Gun은 저장이 가능하지만 업그레이드는 불필요하다. 인터페이스로 행위를 분리했기 때문에 각 무기가 필요한 능력만 골라서 구현할 수 있다.

Unity GetComponent<T>와 인터페이스

Unity에서 인터페이스가 특히 강력한 이유는 GetComponent<T>가 인터페이스 타입을 지원하기 때문이다.

C#
// 총알이 충돌했을 때 — 대상이 Player인지 Barrel인지 몰라도 된다
public class Bullet : MonoBehaviour
{
    public int damage = 10;

    private void OnCollisionEnter(Collision collision)
    {
        // IAttackable 인터페이스로 검색 — 타입 무관
        var target = collision.gameObject.GetComponent<IAttackable>();
        if (target != null)
        {
            target.TakeDamage(damage);
        }
    }
}

GetComponent<IAttackable>()은 해당 게임 오브젝트에 IAttackable을 구현한 컴포넌트가 있는지 찾는다. 총알 코드는 대상이 플레이어인지, 적인지, 나무 상자인지 전혀 알 필요가 없다. 새로운 타입을 추가해도 총알 코드는 수정하지 않는다.


함정과 주의사항

함정 1 — 깨지기 쉬운 기반 클래스(Fragile Base Class)

추상 클래스 상속에서 가장 흔한 함정이다. 자식 클래스가 부모의 내부 구현 순서에 의존하면, 부모 코드를 변경할 때 자식이 예상치 못하게 깨진다.

C#
// ❌ 자식이 부모의 내부 구현 순서에 의존
public abstract class EnemyBase
{
    protected int hp;
    protected bool isDead;

    public EnemyBase(int maxHp)
    {
        hp = maxHp;
    }

    public virtual void TakeDamage(int amount)
    {
        hp -= amount;
        if (hp <= 0 && !isDead)
        {
            isDead = true;
            OnDeath(); // 내부에서 OnDeath를 호출한다
        }
    }

    protected virtual void OnDeath()
    {
        Console.WriteLine("Enemy died");
    }
}

public class BossEnemy : EnemyBase
{
    private int phase = 1;

    public BossEnemy() : base(200) { }

    public override void TakeDamage(int amount)
    {
        base.TakeDamage(amount); // 부모 먼저 호출
        // 문제: base.TakeDamage 안에서 hp가 이미 변경된 후에
        // 여기서 hp를 다시 확인한다 — 부모의 구현 순서에 의존
        if (hp <= 100 && phase == 1)
        {
            phase = 2;
            Console.WriteLine("Phase 2!");
        }
    }
}
IL
// BossEnemy.TakeDamage — 부모의 필드에 직접 의존
.method public hidebysig virtual
    instance void TakeDamage (int32 amount) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: call instance void EnemyBase::TakeDamage(int32)  // 부모 호출 (call, callvirt 아님)
    IL_0009: ldarg.0
    IL_000a: ldfld int32 EnemyBase::hp   // 부모의 hp 필드를 직접 읽는다
    IL_000f: ldc.i4.s 100
    IL_0011: bgt.s IL_001e               // hp > 100이면 건너뛰기
    IL_0013: ldarg.0
    IL_0014: ldfld int32 BossEnemy::phase
    ...
}

IL에서 보면 BossEnemy.TakeDamageEnemyBase::hp 필드를 직접 읽고 있다(ldfld int32 EnemyBase::hp). 부모가 나중에 hp 계산 방식을 바꾸거나, TakeDamage 안에서 hp를 다른 방식으로 처리하면 — 자식 코드가 조용히 잘못 동작할 수 있다.

또한 call instance void EnemyBase::TakeDamage에 주목하자. base.TakeDamage()callvirt가 아니라 call로 호출된다. 가상 디스패치 없이 부모의 구현을 정적으로 직접 호출한다.

개선 방향: 부모의 protected 필드에 직접 접근하지 말고, 부모가 제공하는 메서드나 이벤트를 통해 소통한다. 또는 인터페이스 기반 컴포지션으로 전환한다.

함정 2 — struct에 인터페이스를 쓸 때 의도치 않은 박싱

앞서 내부 동작에서 다룬 박싱 문제가 Unity에서 특히 치명적인 이유를 구체적으로 보자.

C#
// ❌ Unity Update에서 매 프레임 박싱 발생
public struct DamageInfo : IComparable<DamageInfo>
{
    public int Amount;
    public float Time;

    public int CompareTo(DamageInfo other) => Amount.CompareTo(other.Amount);
}

public class DamageManager : MonoBehaviour
{
    private List<DamageInfo> _damages = new();

    void Update()
    {
        // 문제: Sort 내부에서 IComparable<T>로 비교할 때
        // 일부 경로에서 박싱이 발생할 수 있다
        // 대신 명시적 Comparer를 전달하는 것이 안전
        _damages.Sort(); // 매 프레임 정렬 — GC 스파이크 위험
    }
}
C#
// ✅ Comparer를 캐싱하여 사용
public class DamageManager : MonoBehaviour
{
    private List<DamageInfo> _damages = new();
    private static readonly Comparer<DamageInfo> _comparer =
        Comparer<DamageInfo>.Create((a, b) => a.Amount.CompareTo(b.Amount));

    void Update()
    {
        if (_damages.Count > 1)
            _damages.Sort(_comparer); // Comparer 재사용 — 추가 할당 없음
    }
}

핵심: struct가 인터페이스를 구현하는 것 자체는 문제가 없다. 인터페이스 타입 변수에 struct를 대입하는 순간 박싱이 발생한다. 제네릭 제약(where T : IResettable)이나 명시적 Comparer로 이 문제를 우회할 수 있다.

함정 3 — Default Interface Method의 호출 제한

C# 8.0의 DIM(Default Interface Method, 인터페이스에 기본 구현을 제공하는 기능)은 강력하지만, 인터페이스 타입으로 캐스팅해야만 호출할 수 있다는 제한이 있다.

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

    // 기본 구현
    void LogWarning(string message)
    {
        Log($"[WARNING] {message}");
    }
}

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

// ❌ 구체 타입으로는 기본 구현 메서드를 호출할 수 없다
var logger = new ConsoleLogger();
// logger.LogWarning("test");  // 컴파일 에러!

// ✅ 인터페이스 타입으로 캐스팅해야 호출 가능
ILoggable iLogger = logger;
iLogger.LogWarning("test"); // 정상 동작
IL
// ConsoleLogger에는 LogWarning 메서드가 없다 — ILoggable에만 존재
.class public auto ansi beforefieldinit ConsoleLogger
    extends [System.Runtime]System.Object
    implements ILoggable
{
    // Log만 구현되어 있고 LogWarning은 없다
    .method public final hidebysig newslot virtual
        instance void Log (string message) cil managed
    { ... }
}

// ILoggable 인터페이스에 기본 구현이 존재
.class interface public auto ansi abstract beforefieldinit ILoggable
{
    .method public hidebysig newslot virtual   // abstract 없음 = 기본 구현 있음
        instance void LogWarning (string message) cil managed
    {
        IL_0001: ldarg.0
        IL_0002: ldstr "[WARNING] "
        IL_0007: ldarg.1
        IL_0008: call string [System.Runtime]System.String::Concat(string, string)
        IL_000d: callvirt instance void ILoggable::Log(string)  // 자신의 Log를 호출
    }
}

IL에서 보면 ConsoleLogger 클래스에는 LogWarning 메서드가 존재하지 않는다. 기본 구현은 ILoggable 인터페이스 자체에 있다. 따라서 ConsoleLogger 타입 변수로는 접근할 수 없고, ILoggable 타입 변수를 통해서만 호출할 수 있다.

또한 Unity 주의사항: Unity의 Mono 런타임과 IL2CPP(Unity가 C#을 C++로 변환하는 AOT(Ahead-Of-Time) 컴파일러)에서 DIM 지원은 Unity 2021.2 이상, .NET Standard 2.1 이상에서만 동작한다. 이전 버전을 대상으로 한다면 DIM을 사용하지 않는 것이 안전하다.


C# 버전별 변화

C# 1.0~7.x — 추상 클래스와 인터페이스의 명확한 경계

C# 1.0부터 7.x까지 추상 클래스와 인터페이스의 역할은 명확하게 구분되었다.

  • 추상 클래스: 구현(메서드 본문, 필드, 생성자) 제공 가능
  • 인터페이스: 시그니처만 정의 — 구현 불가

이 시절의 규칙은 단순했다: "구현을 공유하려면 추상 클래스, 계약만 정의하려면 인터페이스."

C# 8.0 — Default Interface Methods

C# 8.0에서 인터페이스에 기본 구현을 제공할 수 있게 되면서 경계가 흐려지기 시작했다.

Before (C# 7.x) — 인터페이스에 새 메서드를 추가하면 모든 구현 클래스가 깨진다:

C#
// C# 7.x — 인터페이스에 메서드를 추가하면
public interface ILogger
{
    void Log(string message);
    // void LogWarning(string message); // 이걸 추가하면
    // → ILogger를 구현한 모든 클래스가 컴파일 에러
}

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

C#
// C# 8.0 — 기본 구현으로 기존 코드를 깨뜨리지 않는다
public interface ILogger
{
    void Log(string message);

    // 기본 구현 — 기존 구현 클래스는 수정 불필요
    void LogWarning(string message)
    {
        Log($"[WARNING] {message}");
    }
}
IL
// ILogger.LogWarning — 인터페이스 안에 구현이 존재한다
.method public hidebysig newslot virtual   // abstract 키워드가 없다!
    instance void LogWarning (string message) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldstr "[WARNING] "
    IL_0007: ldarg.1
    IL_0008: call string [System.Runtime]System.String::Concat(string, string)
    IL_000d: callvirt instance void ILoggable::Log(string)
}

IL에서 abstract 키워드가 없는 것에 주목하자. 기존 추상 메서드(Log)는 abstract virtual로 선언되지만, 기본 구현이 있는 LogWarningvirtual만 붙는다. CLR은 구현 클래스에 오버라이드가 없으면 인터페이스의 기본 구현을 사용한다.

C# 11.0 — Static Abstract/Virtual Members in Interfaces

C# 11.0에서는 인터페이스에 정적 추상 멤버를 선언할 수 있게 되었다. 이를 통해 제네릭 수학 연산이 가능해졌다.

C#
// C# 11.0 — 인터페이스에 정적 추상 멤버
public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T left, T right);
    static abstract T Zero { get; }
}

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

    public static Damage operator +(Damage left, Damage right)
        => new Damage { Amount = left.Amount + right.Amount };

    public static Damage Zero => new Damage { Amount = 0 };
}

// 제네릭으로 어떤 타입이든 합산 가능 — 박싱 없음
public static T Sum<T>(T[] values) where T : IAddable<T>
{
    T result = T.Zero;  // 정적 추상 멤버 호출
    foreach (var v in values)
        result = result + v;
    return result;
}

이 기능은 특히 Unity에서 커스텀 수학 타입(Damage, Score, ResourceAmount 등)을 만들 때 유용하다. 제네릭 제약 덕분에 값 타입도 박싱 없이 인터페이스의 정적 멤버를 호출할 수 있다.


정리

  • "공통 상태(필드)와 구현을 자식에게 물려주고 싶다" → 추상 클래스
  • "서로 다른 계열의 클래스에 공통 행위를 부여하고 싶다" → 인터페이스
  • "같은 계열의 공통 뼈대 + 교차 행위" → 추상 클래스 + 인터페이스 조합
  • Unity에서 MonoBehaviour를 이미 상속 중 → 추가 공통 행위는 반드시 인터페이스
  • struct + 인터페이스 → 인터페이스 타입 변수에 대입하면 박싱 발생, 제네릭 제약으로 회피
  • 인터페이스 디스패치 비용 → 실전에서 무시해도 되는 수준 (핫패스 수백만 회 아닌 이상)
  • C# 8.0 DIM → 인터페이스에 기본 구현 가능하지만, 인터페이스 타입 변수로만 호출 가능 + Unity 버전 주의