| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 패스트캠퍼스
- 패스트캠퍼스후기
- ui
- job
- Custom Package
- 환급챌린지
- 프레임워크
- DotsTween
- Job 시스템
- base64
- adfit
- 가이드
- TextMeshPro
- sha
- 게임개발
- Tween
- Framework
- C#
- 직장인자기계발
- 암호화
- unity
- 2D Camera
- AES
- 최적화
- 오공완
- 직장인공부
- RSA
- Dots
- 샘플
- Unity Editor
- Today
- Total
EveryDay.DevUp
[PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가 본문
[PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가
EveryDay.DevUp 2026. 4. 5. 16:02abstract class vs interface — 언제 무엇을 선택하는가
추상 클래스와 인터페이스는 모두 다형성을 제공하지만, 설계 의도와 런타임 동작이 완전히 다르다. 무기 시스템을 만들 때 추상 클래스를 쓸지, 인터페이스를 쓸지 매번 고민된다면 — 이 글을 읽고 나면 즉시 판단할 수 있다.
문제 제기
Unity로 RPG를 만들고 있다. 플레이어, 적, 나무 상자, 폭발하는 드럼통 — 전부 "공격받으면 데미지를 입는" 기능이 필요하다. 어떻게 설계해야 할까?
처음 떠오르는 방법은 공통 부모 클래스를 만드는 것이다.
public abstract class DamageableObject : MonoBehaviour
{
protected int hp;
public abstract void TakeDamage(int amount);
}
플레이어도, 적도, 드럼통도 이 클래스를 상속받으면 된다 — 깔끔해 보인다. 하지만 문제가 생긴다.
플레이어에 "저장 가능" 기능을 추가하고 싶다. SaveableObject라는 추상 클래스를 하나 더 만들었는데 — C#은 단일 상속만 허용한다. DamageableObject를 이미 상속받은 플레이어가 SaveableObject까지 상속받을 수 없다.
드럼통은 MonoBehaviour를 상속받아야 하는데, DamageableObject가 이미 MonoBehaviour를 상속받고 있다. 드럼통에 물리 엔진 컴포넌트를 조합하고 싶어도 상속 계층이 뻣뻣하게 묶여 있어 유연하지 않다.
이것이 "추상 클래스만으로 모든 공통 행위를 설계하면 안 되는 이유"이고, 인터페이스가 왜 필요한지 보여주는 대표적 시나리오다.
개념 정의
추상 클래스 — "~이다(Is-A)" 관계

추상 클래스는 "이 타입의 공통 뼈대"를 정의한다. 일상에서 비유하면 자동차 설계 도면이다. 엔진, 바퀴, 핸들은 모든 자동차에 공통이지만(공통 구현), 연료 방식(가솔린인지, 전기인지)은 각 모델이 결정한다(추상 메서드).
abstract— 추상 키워드 클래스나 메서드 앞에 붙여 "직접 인스턴스화할 수 없고, 파생 클래스가 반드시 구현해야 함"을 표시한다. 추상 클래스는new로 직접 생성할 수 없다.
예시:public abstract class CharacterBase { }— 직접new CharacterBase()는 컴파일 에러
virtual/override— 가상 메서드와 재정의virtual은 파생 클래스가 재정의할 수 있는 메서드를 선언한다.override는 부모의virtual또는abstract메서드를 파생 클래스에서 재정의할 때 사용한다.abstract는virtual의 특수한 형태로, 반드시override해야 한다.
예시:public override void Attack() { ... }— 부모의 Attack()을 자식이 재정의
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 필드에 직접 접근
}
}
// 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에서 핵심은 두 가지다.
abstract virtual:CharacterBase.Attack()은 IL에서도 메서드 본문이 비어 있다. CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)은 이 메서드를 직접 호출하면 런타임 에러를 발생시킨다.callvirt:Main에서unit.Attack()을 호출하면 IL은callvirt를 사용한다. 변수 타입이CharacterBase여도 CLR은 실제 객체(Warrior)의 메서드 테이블(vtable)을 조회하여 오버라이드된Warrior.Attack()을 실행한다.
핵심: 추상 클래스는 필드(상태)와 구현 코드를 자식에게 물려줄 수 있다. hp 필드를 CharacterBase에 선언하면 Warrior가 별도 선언 없이 바로 사용한다.
인터페이스 — "~할 수 있다(Can-Do)" 관계

인터페이스는 "이 객체가 할 수 있는 행위"를 정의한다. 레스토랑 메뉴판에 비유하면, 메뉴판(인터페이스)은 "이 요리를 주문할 수 있다"고 약속하지만 요리사(구현 클래스)가 어떻게 만들지는 각자 결정한다.
interface— 인터페이스 키워드 메서드, 프로퍼티, 이벤트, 인덱서의 시그니처(서명)만 정의하는 계약. 클래스나 구조체가 이 계약을 구현하면 해당 행위를 "할 수 있다"고 보장한다. 하나의 클래스가 여러 인터페이스를 동시에 구현할 수 있다.
예시:public class Player : MonoBehaviour, IDamageable, ISaveable { }— Player는 데미지도 받고, 저장도 가능
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에서 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에서 핵심 차이를 보자.
implements IAttackable: 클래스 선언에implements절이 추가된다. 추상 클래스 상속은extends를 사용하는 것과 다르다.final키워드: 인터페이스를 구현한 메서드는 IL에서final로 표시된다. 이 클래스를 더 상속받아 오버라이드하는 것을 기본적으로 막는다(C#에서virtual을 명시하면 달라진다).- 인터페이스 디스패치:
callvirt instance void IAttackable::TakeDamage는 CLR이 인터페이스 맵(Interface Map)을 통해 실제 구현을 찾는다. 추상 클래스의 vtable(가상 메서드 테이블) 조회보다 한 단계 더 간접 참조가 발생한다.
핵심: 인터페이스는 필드(상태)를 가질 수 없다. Player와 Barrel은 각자 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이 가상 메서드를 호출할 때 내부에서 일어나는 일을 살펴보자.
추상 클래스 호출 경로:
- 객체의 Type Object Pointer를 따라간다
- vtable(가상 메서드 테이블, 메서드 주소를 순서대로 저장한 배열)에서 슬롯 번호로 메서드 주소를 가져온다
- 해당 주소의 코드를 실행한다
인터페이스 호출 경로:
- 객체의 Type Object Pointer를 따라간다
- Interface Map에서 해당 인터페이스가 vtable의 어디에 매핑되는지 찾는다
- vtable에서 실제 메서드 주소를 가져온다
- 해당 주소의 코드를 실행한다
인터페이스 호출은 Interface Map 조회라는 추가 간접 참조가 한 단계 더 들어간다. 하지만 실전에서 이 차이는 나노초 단위다. 수백만 회 호출하는 핫패스가 아니라면 신경 쓸 필요 없다.
struct + 인터페이스 — 박싱의 함정
인터페이스의 진짜 성능 함정은 디스패치 비용이 아니라 값 타입(struct)을 인터페이스 변수에 대입할 때 발생하는 박싱(Boxing)이다.
박싱(Boxing) 값 타입(스택에 저장)을 참조 타입(힙에 저장)으로 변환하는 과정. 힙에 새 객체를 할당하고 값을 복사하므로 GC(Garbage Collector, 메모리를 자동 회수하는 런타임 구성요소) 부담이 발생한다.
Before — 인터페이스 변수로 받으면 박싱 발생:
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();
}
// 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는 반드시 비교 가능해야 함
// 제네릭 + 인터페이스 제약 — 박싱 없이 호출
public static void ResetGood<T>(ref T obj) where T : IResettable
{
obj.Reset();
}
// 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로 전달하므로 값 복사도 없다.
실전 적용
판단 기준 — 언제 무엇을 선택하는가

정리하면 판단은 세 가지 질문으로 충분하다.
- 공통 필드(상태)를 공유해야 하는가? → 예: 추상 클래스
- 같은 계열(Is-A)인가? → 예: 추상 클래스
- 서로 다른 계열에 공통 행위만 부여하는가? → 인터페이스
실전에서는 둘을 함께 쓰는 경우가 가장 많다. 같은 계열 안에서의 공통 뼈대는 추상 클래스로, 계열을 넘나드는 행위는 인터페이스로 분리한다.
Unity 실전 — 추상 클래스 + 인터페이스 조합
무기 시스템을 예로 들어보자.
Before — 추상 클래스만 사용:
// ❌ 모든 행위를 하나의 상속 트리에 밀어넣는다
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();
// 나중에 "업그레이드 가능한 무기"를 추가하고 싶으면?
// "저장 가능한 무기"도 필요하면?
// → 추상 클래스에 기능을 계속 추가해야 한다
// → 모든 자식 클래스가 불필요한 기능까지 상속받는다
}
이 구조의 문제: 업그레이드 기능이 필요한 무기와 필요 없는 무기가 있는데, WeaponBase에 Upgrade() 메서드를 추가하면 모든 무기가 상속받게 된다. "바나나를 원했는데 정글 전체가 딸려오는" 문제다.
After — 추상 클래스 + 인터페이스 조합:
// ✅ 공통 뼈대는 추상 클래스로
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>가 인터페이스 타입을 지원하기 때문이다.
// 총알이 충돌했을 때 — 대상이 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)
추상 클래스 상속에서 가장 흔한 함정이다. 자식 클래스가 부모의 내부 구현 순서에 의존하면, 부모 코드를 변경할 때 자식이 예상치 못하게 깨진다.
// ❌ 자식이 부모의 내부 구현 순서에 의존
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!");
}
}
}
// 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.TakeDamage는 EnemyBase::hp 필드를 직접 읽고 있다(ldfld int32 EnemyBase::hp). 부모가 나중에 hp 계산 방식을 바꾸거나, TakeDamage 안에서 hp를 다른 방식으로 처리하면 — 자식 코드가 조용히 잘못 동작할 수 있다.
또한 call instance void EnemyBase::TakeDamage에 주목하자. base.TakeDamage()는 callvirt가 아니라 call로 호출된다. 가상 디스패치 없이 부모의 구현을 정적으로 직접 호출한다.
개선 방향: 부모의 protected 필드에 직접 접근하지 말고, 부모가 제공하는 메서드나 이벤트를 통해 소통한다. 또는 인터페이스 기반 컴포지션으로 전환한다.
함정 2 — struct에 인터페이스를 쓸 때 의도치 않은 박싱
앞서 내부 동작에서 다룬 박싱 문제가 Unity에서 특히 치명적인 이유를 구체적으로 보자.
// ❌ 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 스파이크 위험
}
}
// ✅ 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, 인터페이스에 기본 구현을 제공하는 기능)은 강력하지만, 인터페이스 타입으로 캐스팅해야만 호출할 수 있다는 제한이 있다.
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"); // 정상 동작
// 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# 7.x — 인터페이스에 메서드를 추가하면
public interface ILogger
{
void Log(string message);
// void LogWarning(string message); // 이걸 추가하면
// → ILogger를 구현한 모든 클래스가 컴파일 에러
}
After (C# 8.0) — 기본 구현으로 안전하게 확장:
// C# 8.0 — 기본 구현으로 기존 코드를 깨뜨리지 않는다
public interface ILogger
{
void Log(string message);
// 기본 구현 — 기존 구현 클래스는 수정 불필요
void LogWarning(string message)
{
Log($"[WARNING] {message}");
}
}
// 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로 선언되지만, 기본 구현이 있는 LogWarning은 virtual만 붙는다. CLR은 구현 클래스에 오버라이드가 없으면 인터페이스의 기본 구현을 사용한다.
C# 11.0 — Static Abstract/Virtual Members in Interfaces
C# 11.0에서는 인터페이스에 정적 추상 멤버를 선언할 수 있게 되었다. 이를 통해 제네릭 수학 연산이 가능해졌다.
// 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 버전 주의
'C# 심화' 카테고리의 다른 글
| [PART4.인터페이스(1/3)] interface — 계약으로서의 인터페이스 (0) | 2026.04.05 |
|---|---|
| [PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법 (0) | 2026.04.05 |
| [PART3.상속과 다형성(2/4)] virtual / override / new — 다형성의 실제 동작 (0) | 2026.04.05 |
| [PART3.상속과 다형성(1/4)] 상속 vs 컴포지션 — 언제 상속을 쓰고 언제 쓰지 않는가 (0) | 2026.04.05 |
| [PART2.클래스와 객체(7/7)] 연산자 오버로딩 — 사용자 정의 타입에 연산자를 부여하는 방법 (1) | 2026.04.05 |
