반응형

[PART8.상속과 인터페이스 사용법(6/11)] 인터페이스 선언과 구현 — 계약으로 묶고, 구현으로 갈라놓기

"할 수 있다"의 약속만 담는 타입 / 다중 구현이 허용되는 이유 / callvirt와 박싱이 만드는 비용


1. 문제 제기 — 타입이 다른데 같은 동작이 필요할 때

Unity에서 총알 한 발이 트리거에 부딪혔다고 해봅니다. 부딪힌 대상은 Player일 수도, Enemy일 수도, 부서지는 나무 상자일 수도 있습니다. 셋 다 "데미지를 받는다"는 동작을 해야 하지만, 클래스 계층은 완전히 다릅니다.

C#
// ❌ 클래스 타입 분기 — 새 타입이 추가될 때마다 늘어나는 if
void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent<Player>(out var p))      p.TakeDamage(10);
    else if (other.TryGetComponent<Enemy>(out var e))  e.TakeDamage(10);
    else if (other.TryGetComponent<Crate>(out var c))  c.TakeDamage(10);
    // 새 타입이 생기면 또 추가...
}

이 코드의 문제는 두 가지입니다.

  1. 새 타입이 늘 때마다 분기를 추가해야 합니다 — 개방-폐쇄 원칙을 어깁니다.
  2. PlayerEnemy는 부모 클래스가 다릅니다MonoBehaviour라는 공통 부모를 만져도 데미지 동작은 거기 없습니다.

C#은 클래스의 다중 상속을 금지합니다. PlayerCharacter를 상속받고 있다면 Damageable 같은 다른 부모 클래스를 추가로 상속받을 수 없습니다. 그렇다면 "데미지를 받을 수 있다"는 약속만 따로 떼어내서, 타입에 상관없이 그 약속만 보고 동작할 수는 없을까요?

이 질문에 답하는 것이 인터페이스(interface)입니다.

interface — 인터페이스 선언 키워드 타입이 "할 수 있는 일"의 목록(메서드, 프로퍼티, 이벤트의 시그니처)만 담는 계약 단위. 본문(구현 코드)은 갖지 못하며, 클래스나 구조체가 이 계약을 구현(implement)한다.
예시: public interface IDamageable { void TakeDamage(int amount); } "데미지를 받을 수 있다"는 약속만 정의

2. 개념 정의 — 인터페이스는 "계약"이다

비유: 자판기 코인 슬롯

자판기는 세상의 모든 동전 종류를 알 필요가 없습니다. "지름 25mm, 무게 5g"이라는 규격만 만족하면 어떤 동전이든 받습니다. 인터페이스는 이런 규격입니다 — "어떤 모양이어야 한다"는 형태만 정의하고, 실제 동전은 누가 만들든 상관하지 않습니다.

«interface»

Player, Enemy, Crate는 부모 클래스도 다르고 책임도 다르지만, "TakeDamage(int)라는 메서드를 갖는다"는 한 가지 약속을 모두 만족시킵니다. 외부 코드는 이 약속만 보고 셋을 똑같이 다룰 수 있습니다.

기본 문법

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

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

선언 규칙은 단순합니다.

  • interface 키워드로 선언, 이름은 관례상 I로 시작 (IDamageable, IDisposable, IEnumerable<T>)
  • 멤버는 모두 public + abstract (C# 8 미만 원칙) — 접근 제한자를 명시하면 컴파일 에러
  • 본문 없음 — 메서드는 시그니처만 적고 세미콜론으로 끝남
  • 필드 불가 — 메서드, 프로퍼티, 이벤트, 인덱서만 선언 가능

C# 8.0부터는 인터페이스에 본문 있는 메서드(default interface methods)도 쓸 수 있지만, 이 글에서는 "본문 없는 계약"이 인터페이스의 본질임에 집중합니다. 기본 구현은 다음 글(07_인터페이스 기본 구현)에서 다룹니다.

IL로 보는 인터페이스 선언

위 코드를 컴파일하면 IL은 이렇게 생성됩니다.

IL
.class interface public auto ansi abstract beforefieldinit IDamageable
{
    .method public hidebysig newslot abstract virtual
        instance void TakeDamage (int32 amount) cil managed
    {
    } // 본문 없음
}

.class public auto ansi beforefieldinit Enemy
    extends [System.Runtime]System.Object
    implements IDamageable     // ← InterfaceImpl 메타데이터
{
    .field public int32 Health

    .method public final hidebysig newslot virtual
        instance void TakeDamage (int32 amount) cil managed
    {
        // Health -= amount
    }
}

핵심 단서는 두 줄입니다.

  • .class interface ... abstract — 인터페이스 선언에 붙는 interface 플래그가 abstract class와 결정적으로 구분짓는 표식입니다. CLR(Common Language Runtime, .NET 코드를 실행하는 런타임)은 이 플래그를 보고 "이건 구현 클래스가 매핑해야 할 계약"이라고 인식합니다.
  • implements IDamageableEnemy 클래스 IL에 추가된 한 줄이 InterfaceImpl 메타데이터입니다. "이 클래스는 IDamageable 계약을 이행한다"고 메타데이터 테이블에 기록됩니다.

Enemy.TakeDamage에 붙은 final hidebysig newslot virtual도 의미가 있습니다. virtual + newslot은 vtable에 새 슬롯을 만들겠다는 뜻이고, final은 더 이상 오버라이드할 수 없다는 뜻입니다.


3. 내부 동작 — callvirt와 인터페이스 매핑 테이블

객체의 메모리 레이아웃

CLR이 클래스를 로드할 때 객체와 별도로 메서드 테이블(Method Table)이 타입당 하나 만들어집니다. 인터페이스를 구현하는 클래스의 메서드 테이블에는 인터페이스 매핑 테이블(Interface Map)이 추가됩니다.

Enemy 인스턴스 (힙)

객체 자체에는 필드만 있고, 모든 메서드 정보는 별도의 메서드 테이블에 모여 있습니다. 객체의 두 번째 워드인 Type Object Pointer가 메서드 테이블을 가리킵니다.

callvirt — 가상 호출 명령어 객체의 실제 타입을 런타임에 확인해 메서드를 호출하는 IL(Intermediate Language, C# 컴파일러가 만들어내는 중간 언어) 명령어. 호출 전에 null 체크를 하고, 메서드 테이블을 따라가 실제 구현 메서드의 주소로 점프한다. 인터페이스 메서드 호출은 항상 callvirt로 컴파일된다.
예시: callvirt instance void IDamageable::TakeDamage(int32) 객체가 어떤 클래스든 IDamageable 매핑을 따라 알맞은 구현으로 점프

IL로 보는 인터페이스 호출

C#
public class Program
{
    public static void Main()
    {
        IDamageable d = new Enemy();
        d.TakeDamage(10);
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    .entrypoint
    IL_0000: newobj   instance void Enemy::.ctor()           // Enemy 인스턴스 생성
    IL_0005: ldc.i4.s 10                                     // 10을 스택에 push
    IL_0007: callvirt instance void IDamageable::TakeDamage(int32) // 인터페이스로 호출
    IL_000c: ret
}

핵심은 IL_0007의 callvirt입니다. 컴파일러는 호출 시점에 객체가 어떤 클래스인지 모르고 알 필요도 없습니다. 그저 "IDamageableTakeDamage를 호출하라"고만 적습니다. 실제로 어떤 구현이 호출되는지는 런타임이 객체의 메서드 테이블 → 인터페이스 매핑 테이블 → 실제 메서드 주소 순서로 따라가 결정합니다.

callvirt는 일반 정적 호출 call과 다음 두 가지가 다릅니다.

  1. null 체크 — 객체 참조가 null이면 NullReferenceException을 발생시킵니다.
  2. 동적 디스패치 — 객체의 실제 타입을 보고 메서드를 결정합니다.

이 두 동작 때문에 callvirtcall보다 약간 느립니다. 인터페이스 호출은 한 번 더 간접 참조(인터페이스 매핑 → 실제 메서드)가 들어가기 때문에 일반 가상 호출보다도 미세하게 느립니다. CLR은 Virtual Stub Dispatch(VSD)라는 캐싱 기법으로 이 비용을 완화합니다.

다중 구현이 가능한 이유

C#은 클래스의 다중 상속을 금지합니다. 두 부모에 같은 시그니처의 메서드가 있으면 어느 쪽을 호출해야 할지 모호해지는 다이아몬드 문제(Diamond Problem) 때문입니다.

❌ 클래스 다중 상속 (C# 금지)

인터페이스에는 구현이 없습니다. IA.Speak()IB.Speak()도 본문이 없는 시그니처일 뿐입니다. 클래스 CSpeak()를 한 번만 구현하면 두 인터페이스의 계약을 동시에 만족시킵니다 — 충돌의 여지가 없습니다.

C#
public interface IDamageable    { void TakeDamage(int amount); }
public interface IHealable      { void Heal(int amount); }
public interface IInteractable  { void Interact(); }

public class Player : MonoBehaviour, IDamageable, IHealable, IInteractable
{
    public void TakeDamage(int amount) { /* ... */ }
    public void Heal(int amount)       { /* ... */ }
    public void Interact()             { /* ... */ }
}

Player는 클래스(MonoBehaviour) 하나를 상속받으면서 인터페이스 3개를 동시에 구현했습니다. Unity 코드에서 매우 자주 보이는 패턴입니다.


4. 실전 적용 — Unity의 인터페이스 패턴

Before: 클래스 타입 분기

C#
// ❌ 새 타입이 늘 때마다 분기 추가
public class Bullet : MonoBehaviour
{
    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent<Player>(out var p))      p.TakeDamage(10);
        else if (other.TryGetComponent<Enemy>(out var e))  e.TakeDamage(10);
        else if (other.TryGetComponent<Crate>(out var c))  c.TakeDamage(10);
    }
}

새 데미지 대상(DestructibleWall, BossPart)이 추가될 때마다 Bullet을 수정해야 합니다. Bullet은 모든 데미지 대상의 클래스를 알아야 합니다 — 결합도가 최악입니다.

After: 인터페이스로 분리

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

public class Player : MonoBehaviour, IDamageable
{
    public int health = 100;
    public void TakeDamage(int amount) => health -= amount;
}

public class Crate : MonoBehaviour, IDamageable
{
    public void TakeDamage(int amount) => Destroy(gameObject);
}

public class Bullet : MonoBehaviour
{
    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent<IDamageable>(out var d))
            d.TakeDamage(10);
    }
}

Bullet은 이제 IDamageable만 압니다. 새 데미지 대상이 추가돼도 Bullet 코드는 한 줄도 안 바뀝니다. PlayerCrate는 클래스 계층이 완전히 달라도(MonoBehaviour 외엔 공통 부모가 없음) 같은 약속만 만족시키면 됩니다.

참고: Unity의 TryGetComponent<T>는 인터페이스 타입에 대해서도 동작합니다. 내부적으로는 GetComponents를 돌며 인터페이스 매핑을 검사합니다.

그 외 자주 쓰는 인터페이스 패턴

C#
// 입력-반응 분리
public interface IInteractable
{
    string GetPrompt();
    void Interact(GameObject interactor);
}

public class Door : MonoBehaviour, IInteractable
{
    public string GetPrompt() => "[E] 열기";
    public void Interact(GameObject who) { /* 문 열기 */ }
}

public class NPC : MonoBehaviour, IInteractable
{
    public string GetPrompt() => "[E] 대화";
    public void Interact(GameObject who) { /* 대화 시작 */ }
}

// 오브젝트 풀 표준화
public interface IPoolable
{
    void OnSpawn();
    void OnDespawn();
}

플레이어 컨트롤러는 시야에 들어온 객체가 IInteractable이면 GetPrompt()로 UI 문구를 띄우고, E 키를 누르면 Interact()를 실행합니다. 문이든 NPC든 보물 상자든 같은 코드로 처리됩니다.

표준 라이브러리에서 인터페이스의 위력

IDisposable, IEnumerable<T>, IComparable<T> 같은 .NET 표준 인터페이스가 곧 라이브러리의 확장점입니다. using 블록은 IDisposable만 보고 동작하고, foreachIEnumerable<T>만 보고 동작합니다. 우리가 만든 클래스가 이 인터페이스를 구현하면 곧바로 언어 기능과 어우러집니다.

C#
public class Pool<T> : IDisposable where T : Component
{
    public void Dispose() { /* 풀 해제 */ }
}

using (var pool = new Pool<Bullet>())   // IDisposable 만 보면 됨
{
    // ...
}

5. 함정과 주의사항

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

값 타입(struct)도 인터페이스를 구현할 수 있습니다. 그러나 struct를 인터페이스 변수에 담는 순간 박싱(boxing)이 일어납니다.

박싱 (boxing) — 값 타입을 힙으로 옮기는 변환 값 타입을 참조 타입(object나 인터페이스) 변수에 담을 때, CLR이 힙에 새 객체를 할당하고 값을 복사한다. GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 압박과 값 변경이 원본에 반영되지 않는 두 가지 문제를 일으킨다.
예시: IDamageable d = (IDamageable)structValue; 힙에 박스가 만들어지고 structValue의 복사본이 들어감
C#
// ❌ 박싱 발생
public struct DamageData : IDamageable
{
    public int Hp;
    public void TakeDamage(int amount) { Hp -= amount; }
}

public static void BoxedCall(IDamageable d, int a) { d.TakeDamage(a); }

void Use()
{
    var s = new DamageData { Hp = 100 };
    BoxedCall(s, 10);   // 여기서 box 명령어가 발생
    // s.Hp 는 여전히 100 — 박스에 복사된 사본만 변경됨
}

IL을 보면 박싱이 명시적으로 잡힙니다.

IL
.method public hidebysig static void Main () cil managed
{
    .entrypoint
    .locals init ([0] valuetype DamageData)

    IL_0000: ldloca.s   0
    IL_0002: initobj    DamageData
    IL_0008: ldloca.s   0
    IL_000a: ldc.i4.s   100
    IL_000c: stfld      int32 DamageData::Hp
    IL_0011: ldloc.0
    IL_0012: dup
    IL_0013: box        DamageData             // ← 박싱! 힙에 새 객체 할당
    IL_0018: ldc.i4.s   10
    IL_001a: call       void Program::BoxedCall(class IDamageable, int32)
    ...
}

box DamageData 한 줄이 힙 할당을 만듭니다. Unity에서 Update() 안에서 매 프레임 호출되면 GC 스파이크가 즉시 따라옵니다.

✅ 해법: 제네릭 제약으로 박싱 회피

C#
public static void Generic<T>(T t, int a) where T : IDamageable
{
    t.TakeDamage(a);
}

void Use()
{
    var s = new DamageData { Hp = 100 };
    Generic(s, 10);   // 박싱 없음 — DamageData 전용 메서드로 JIT 컴파일됨
}

IL을 다시 보면 box가 사라지고 constrained. 접두어가 붙은 callvirt가 나타납니다.

IL
.method public hidebysig static
    void Generic<(IDamageable) T> (!!T t, int32 a) cil managed
{
    IL_0000: ldarga.s     t
    IL_0002: ldarg.1
    IL_0003: constrained. !!T                                 // ← 박싱 회피 지시
    IL_0009: callvirt     instance void IDamageable::TakeDamage(int32)
    IL_000e: ret
}
constrained. 접두어 — 제네릭 제약 호출 제네릭 타입 매개변수에 대한 가상 호출 직전에 붙는 IL 접두어. 값 타입이면 박싱 없이 메서드 테이블을 직접 조회하고, 참조 타입이면 일반 callvirt처럼 동작한다.
예시: constrained. !!T → callvirt T가 struct여도 box 명령이 생성되지 않음

JIT(Just-In-Time, 실행 직전에 IL을 기계어로 변환하는 컴파일러)은 Generic<DamageData>DamageData 전용 코드로 컴파일합니다(reified generics). 핫 패스 코드에 인터페이스를 쓸 때 이 패턴은 거의 필수입니다.

함정 2: 인터페이스 변수가 null인데 메서드를 호출

C#
// ❌ NullReferenceException
IDamageable d = obj.GetComponent<IDamageable>();
d.TakeDamage(10);   // obj가 IDamageable 을 구현 안 했으면 d == null

callvirt는 호출 전 null 체크를 하므로 NullReferenceException이 즉시 던져집니다. 실수가 즉시 드러나는 점은 좋지만, Unity에서는 OnTriggerEnter처럼 매 프레임 일어날 수 있는 호출에서 예외를 던지면 프레임이 통째로 깨질 수 있습니다.

✅ 해법: TryGetComponent 또는 null 체크

C#
// ✅ TryGetComponent로 한 번에 검사
if (other.TryGetComponent<IDamageable>(out var d))
    d.TakeDamage(10);

// 또는 null 체크
var d = other.GetComponent<IDamageable>();
if (d != null) d.TakeDamage(10);

함정 3: 인터페이스 멤버에 접근 제한자 명시

C#
// ❌ 컴파일 에러 — C# 8 미만에서는 접근 제한자 명시 불가
public interface IDamageable
{
    public void TakeDamage(int amount);   // CS8703
}

C# 8 미만에서는 인터페이스 멤버는 암시적으로 public abstract이며, 명시적으로 public을 붙이면 컴파일 에러였습니다. C# 8.0부터는 default interface methods 도입과 함께 접근 제한자 명시가 가능해졌지만, 여전히 "본문 없는 추상 멤버"는 암시적으로 public입니다.

함정 4: 명시적 구현된 메서드를 클래스 변수로 호출

C#
public class Crate : IDamageable
{
    void IDamageable.TakeDamage(int amount) { /* ... */ }   // 명시적 구현
}

var c = new Crate();
c.TakeDamage(10);                     // ❌ 컴파일 에러
((IDamageable)c).TakeDamage(10);      // ✅ 인터페이스 타입으로 캐스팅 필요

명시적 구현은 IL에서 private final로 컴파일됩니다. 클래스의 일반 API에서는 보이지 않고, 반드시 인터페이스 타입을 거쳐야만 호출됩니다.

IL
.method private final hidebysig newslot virtual
    instance void IDestroyable.Action () cil managed
{
    .override method instance void IDestroyable::Action()
    IL_0000: ret
}

.override 절이 어떤 인터페이스 메서드를 구현하는지 지정합니다. 명시적 구현은 private이라 클래스 외부에서 직접 호출할 수 없지만, 인터페이스 매핑 테이블을 통해서는 정상적으로 디스패치됩니다.


6. C# 버전별 변화

버전 변화 의미
C# 1.0 기본 인터페이스 도입 본문 없는 멤버 + 다중 구현 + 명시적 구현
C# 8.0 Default Interface Methods 인터페이스에 본문 있는 메서드, static 멤버, private 헬퍼 허용
C# 11 Static abstract members 인터페이스에 static abstract 멤버 — 제네릭 수학 연산자 추상화

이 글의 주제인 "인터페이스 선언과 구현"의 본질(계약 + 다중 구현 + 동적 디스패치)은 C# 1.0부터 변하지 않았습니다. C# 8 이후의 확장은 별도 글에서 다룹니다.

  • 07_인터페이스 기본 구현 (C# 8+) — default interface methods
  • 08_정적 추상 인터페이스 멤버 (C# 11) — static abstract members
  • 09_명시적 인터페이스 구현 — explicit implementation 심화

7. 정리

핵심 체크리스트입니다.

  • 인터페이스는 "할 수 있다"의 계약 — 본문 없이 시그니처만 담으며, 클래스나 struct가 이행한다
  • 이름은 I로 시작IDamageable, IInteractable, IPoolable
  • 멤버는 암시적 public + abstract (C# 8 미만 원칙)
  • 다중 구현 허용 — 클래스는 다중 상속 못 하지만 인터페이스는 무제한 구현 가능. 본문이 없어 다이아몬드 문제가 없기 때문
  • IL에서 interface 플래그 + InterfaceImpl 메타데이터로 매핑 — 컴파일러가 자동 생성
  • 호출은 항상 callvirt — null 체크 + 동적 디스패치. 인터페이스 매핑 테이블을 거쳐 실제 메서드로 점프
  • struct + 인터페이스 변수 = 박싱where T : IInterface 제네릭 제약으로 회피
  • Unity에서 IDamageable, IInteractable, IPoolable 같은 작은 인터페이스로 결합도를 낮추는 것이 정석 패턴
  • 명시적 구현은 시그니처 충돌 해소나 API 숨기기에만 — 일반적으로는 암시적 구현 사용

다음 글에서는 C# 8.0이 도입한 인터페이스 기본 구현(default interface methods)이 이 "본문 없는 계약" 원칙을 어떻게 확장했는지 살펴봅니다.

반응형

+ Recent posts