반응형

[PART8.상속과 인터페이스 사용법(5/11)] abstract 클래스와 abstract 메서드 — 미완성을 강제로 완성시키는 설계 도구

인스턴스를 만들 수 없는 클래스가 왜 필요한가 / 자식이 반드시 채워야 하는 메서드를 어떻게 강제하는가 / IL이 보여주는 본문 없는 메서드의 정체


1. [문제 제기] "각자 알아서 구현하세요"는 약속이 지켜지지 않습니다

Unity 모바일 RPG에서 적 캐릭터를 다섯 종류 만든다고 상상해 보겠습니다. 슬라임, 고블린, 박쥐, 골렘, 보스. 모두 체력이 있고, 데미지를 받으면 피격 모션이 있고, 죽으면 사라집니다. 여기까지는 똑같습니다. 그런데 공격 방식이 다릅니다 — 슬라임은 몸통 박치기, 고블린은 단검 휘두르기, 박쥐는 발사체, 골렘은 광역 내리찍기, 보스는 패턴 공격. 공통 코드는 한 곳에 모으되, 공격 방식만 각자 다르게 작성하고 싶습니다.

가장 단순한 발상은 베이스 클래스 Enemy를 만들고 모든 적이 상속하게 하는 것입니다. 그런데 막상 작성하다 보니 곤란한 상황이 생깁니다. Enemy.Attack() 메서드의 본문에 무엇을 적어야 할까요? "기본 공격"이라는 게 사실 존재하지 않습니다. 그렇다고 빈 메서드 { }로 두면 신입 개발자가 새 적 클래스를 만들 때 Attack()을 깜빡 잊어도 컴파일러가 아무 경고를 주지 않습니다. 게임을 실행해 보면 적이 가만히 서 있고, QA가 나중에야 발견합니다. 동료에게 "Attack 꼭 구현해야 합니다"라고 말로 약속받는 것도 지켜진다는 보장이 없습니다.

같은 문제가 .NET 표준 라이브러리에도 있습니다. Stream 클래스는 모든 입출력 스트림의 베이스인데, "데이터를 읽는다"는 행위 자체에 디폴트 구현이 존재할 수 없습니다. 파일에서 읽는 방법, 메모리에서 읽는 방법, 네트워크에서 읽는 방법은 전부 다릅니다. 그래서 Stream은 추상 클래스고 Read는 추상 메서드입니다. 누가 new Stream()을 호출하면 컴파일 에러가 납니다.

abstract 키워드는 이 두 가지 강제력을 한 번에 제공합니다. 첫째, "이 클래스는 미완성이니 직접 인스턴스 만들지 마세요"를 컴파일러가 강제합니다. 둘째, "이 메서드는 자식이 반드시 채워야 합니다"를 컴파일러가 강제합니다. 약속은 컴파일러가 받아야 지켜집니다. 이 글은 abstract가 정확히 무엇을 강제하는지, IL 레벨에서 어떻게 구현되는지, Unity의 ScriptableObject 패턴까지 끝까지 따라갑니다.


2. [개념 정의] abstract는 "여기는 자식이 채워야 한다"는 빈칸 표시입니다

abstract — 추상 키워드 (Abstract modifier) 클래스 앞에 붙이면 그 클래스를 직접 인스턴스화할 수 없게 만들고, 메서드·프로퍼티 앞에 붙이면 본문 없이 선언만 두고 자식 클래스가 반드시 override로 구현하도록 강제한다. 미완성 부분을 명시적으로 표시하는 빈칸이다.
예시: public abstract class Enemy { public abstract void Attack(); } Enemy는 직접 인스턴스화 불가, 자식은 Attack을 반드시 override

2.1 비유 — 인쇄된 시험지의 빈칸

학원에서 나눠주는 모의고사 시험지에는 답이 적힌 칸과 비어 있는 칸이 있습니다. 답이 적힌 칸(채점 기준 같은 것)은 시험지를 만든 사람이 미리 채워둔 부분이고, 비어 있는 칸은 학생이 반드시 채워야 하는 부분입니다. 시험지 자체를 그대로 제출할 수는 없습니다(미완성이니까). 학생이 빈칸을 채워서 제출해야 비로소 답안지가 됩니다.

abstract 클래스가 시험지, abstract 메서드가 빈칸, 자식 클래스가 학생, override가 빈칸을 채우는 행위입니다. 시험지(abstract class)를 그대로 시험 답안으로 낼 수 없는 것처럼(new 컴파일 에러), abstract 클래스는 직접 인스턴스화할 수 없습니다.

2.2 abstract class와 abstract 메서드의 관계

abstract class Animal

abstract 클래스는 채워진 부분(필드·일반 메서드·virtual 메서드)과 빈칸(abstract 메서드)을 모두 가질 수 있습니다. 자식 클래스는 빈칸을 반드시 override로 채워야 인스턴스화될 수 있습니다.

2.3 기본 코드 예시

C#
// Unity의 적 캐릭터 베이스를 abstract로 정의
public abstract class Animal
{
    public string Name;

    // 부모는 직접 인스턴스 못 만들지만, 필드 초기화는 필요하므로 protected 생성자
    protected Animal(string name) { Name = name; }

    // 빈칸 — 자식이 반드시 채워야 함
    public abstract void MakeSound();

    // 기본 구현 — 자식이 선택적으로 override 가능
    public virtual void Sleep() { /* 기본 동작 */ }

    // 일반 메서드 — 모든 자식이 그대로 사용
    public void Eat() { /* 공통 로직 */ }
}

public class Dog : Animal
{
    public Dog(string name) : base(name) { }

    // abstract 메서드는 자식에서 override 필수 — 누락 시 CS0534
    public override void MakeSound() { /* 멍멍 */ }
}

// 사용 측
// var a = new Animal("???");        // CS0144 — abstract 타입은 인스턴스 불가
Animal a = new Dog("바둑이");         // OK — 자식을 통해서만 생성 가능
a.MakeSound();                       // Dog.MakeSound() 실행 (다형성)
IL
.class public auto ansi abstract beforefieldinit Animal
    extends [System.Runtime]System.Object
{
    .field public string Name

    .method family hidebysig specialname rtspecialname        // family = protected 생성자
        instance void .ctor(string name) cil managed
    {
        // ... 필드 초기화 ...
    }

    .method public hidebysig newslot abstract virtual         // ← abstract + virtual + newslot
        instance void MakeSound () cil managed
    {
        // 본문 0줄
    }

    .method public hidebysig newslot virtual                  // ← virtual만
        instance void Sleep () cil managed { ... }

    .method public hidebysig                                  // ← 일반 메서드
        instance void Eat () cil managed { ... }
}

핵심 IL 단서:

  • 클래스 헤더에 abstract 플래그가 박힙니다. CLR은 이 플래그가 있는 타입에 대해 newobj 명령을 거부합니다 — 컴파일러뿐 아니라 런타임에도 안전망이 있습니다.
  • MakeSoundabstract virtual 두 플래그를 동시에 가지며 메서드 본문이 비어 있습니다. abstract는 본질적으로 virtual이라는 점이 IL에서 그대로 드러납니다.
  • 생성자의 family 플래그는 IL에서 protected를 의미합니다. 외부 접근은 막되 자식 생성자가 base(...)로 호출하기 위한 통로입니다.

3. [내부 동작] vtable 슬롯은 있지만 실행 코드가 없는 메서드

3.1 vtable과 abstract 슬롯

abstract 메서드는 본문이 없습니다. 그런데도 메서드 테이블(vtable)에는 슬롯이 할당됩니다. 슬롯이 있어야 자식이 그 슬롯에 자기 구현을 채워 넣을 수 있고, 부모 타입 변수로 호출해도 실제 자식 구현이 실행되는 다형성이 작동합니다.

Animal vtable (베이스)

이 그림이 abstract의 본질을 가장 잘 보여줍니다. abstract 메서드는 vtable 슬롯을 차지하되, 함수 포인터가 비어 있는 상태입니다. CLR이 abstract 클래스의 인스턴스화를 막는 진짜 이유는 "철학적 미완성"이 아니라 "실제로 호출하면 비어 있는 함수 포인터로 점프하는 사고가 난다"는 매우 물리적인 이유 때문입니다.

자식이 override하면 그 슬롯에 자식 구현의 함수 포인터가 채워집니다. 모든 슬롯이 채워진 시점에 비로소 안전하게 인스턴스화할 수 있습니다.

3.2 callvirt가 abstract 메서드를 처리하는 방식

C#
// 호출 측 코드
public static class Caller
{
    public static void CallSound(Animal a)
    {
        a.MakeSound();   // 변수 타입은 Animal, 실제 객체는 Dog일 수도 Cat일 수도
    }
}
IL
.method public hidebysig static
    void CallSound(class Animal a) cil managed
{
    IL_0000: nop
    IL_0001: ldarg.0                                          // a를 스택에 푸시
    IL_0002: callvirt instance void Animal::MakeSound()       // ← 동적 디스패치
    IL_0007: nop
    IL_0008: ret
}
callvirt — 가상 호출 명령어 (Call virtual) CLR이 객체의 메서드 테이블(vtable)을 런타임에 조회해 가장 파생된 구현을 호출하는 IL 명령어. 일반 call과 달리 동적 디스패치를 수행하며, 호출 전에 대상이 null인지 자동으로 확인한다.
차이: call Animal::MakeSound는 정적으로 Animal의 메서드를 직접 호출하지만, callvirt는 객체의 실제 타입(Dog·Cat 등)을 따라 호출 대상을 결정한다.

C# 컴파일러는 abstract·virtual 메서드뿐 아니라 일반 인스턴스 메서드 호출에도 callvirt를 자주 사용합니다. 이유는 두 가지입니다. 첫째, 호출 전 null 체크가 무료로 따라옵니다. 둘째, 나중에 메서드가 virtual로 바뀌어도 호출 측 IL을 수정하지 않아도 됩니다. 즉, abstract 메서드 호출은 일반 가상 메서드 호출과 IL 명령어 자체는 동일하고, 차이는 vtable 슬롯이 자식에서만 채워진다는 점에 있습니다.

3.3 인스턴스화 시점의 안전성 검증

abstract 클래스를 직접 new하면 컴파일 타임에 막힙니다.

C#
public abstract class Stream2
{
    public abstract int Read(byte[] buffer);
}

// var s = new Stream2();   // CS0144 — 추상 형식 또는 인터페이스의 인스턴스를 만들 수 없습니다

리플렉션으로 우회하려고 해도 런타임에 막힙니다.

C#
var t = typeof(Stream2);
var s = Activator.CreateInstance(t);
// MissingMethodException — 추상 형식의 인스턴스를 만들 수 없습니다

CLR은 newobj 명령을 처리할 때 대상 타입의 abstract 플래그를 확인합니다. 컴파일러가 막지 못한 경로(리플렉션·다른 언어에서 호출 등)도 런타임에서 한 번 더 차단합니다. abstract 클래스의 안전성은 컴파일러와 CLR이 이중으로 보장합니다.


4. [실전 적용] 템플릿 메서드 패턴과 Unity ScriptableObject

abstract의 진가는 단순히 "구현을 강제한다"가 아니라 알고리즘의 뼈대는 부모가 잡고 단계별 세부만 자식에게 위임하는 설계입니다. 이를 객체 지향 설계 패턴에서는 템플릿 메서드 패턴(Template Method Pattern)이라 부릅니다. Unity의 ScriptableObject 활용이 대표적인 사례입니다.

4.1 Before — abstract 없이 신입이 자주 빠지는 패턴

C#
// ❌ 신입이 처음 적 시스템을 만들 때 자주 쓰는 패턴
public class Enemy : MonoBehaviour
{
    public string enemyType;
    public int hp = 100;

    public void TakeDamage(int dmg)
    {
        hp -= dmg;
        if (hp <= 0) Die();
    }

    public void Die() { Destroy(gameObject); }

    // 모든 적 종류를 if-else로 분기
    public void Attack(GameObject target)
    {
        if (enemyType == "slime")  { /* 박치기 */ }
        else if (enemyType == "goblin") { /* 단검 */ }
        else if (enemyType == "bat")    { /* 발사체 */ }
        else if (enemyType == "golem")  { /* 내리찍기 */ }
        // 새 적이 추가될 때마다 여기 한 줄 추가 — 잊으면 침묵의 버그
    }
}
IL
.method public hidebysig
    instance void Attack(class [UnityEngine]UnityEngine.GameObject target) cil managed
{
    // ldfld + ldstr + call op_Equality + brfalse 의 반복
    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldfld string Enemy::enemyType
    IL_0007: ldstr "slime"
    IL_000c: call bool [System.Runtime]System.String::op_Equality(string, string)
    IL_0011: brfalse.s IL_001b
    // ... goblin, bat, golem 각각 동일 패턴 반복 ...
}

문제 세 가지:

  • 컴파일러가 누락을 감지하지 못함 — 새 적 종류 boss를 추가하고 if 분기를 빠뜨려도 컴파일은 성공합니다. 게임을 돌려야 침묵하는 보스를 발견합니다.
  • 확장이 곧 기존 코드 수정 — 새 적이 추가될 때마다 Enemy.cs가 수정됩니다. SOLID의 OCP(Open-Closed Principle)를 위반합니다.
  • JIT 최적화 불가 — 매 호출마다 문자열 비교가 일어납니다. enemyType 값을 조회하기 위해 반복적인 필드 접근이 발생합니다.

4.2 After — abstract로 컴파일러에게 강제력을 위임

C#
// ✅ abstract로 새 적 추가 시 컴파일러가 누락을 잡아주는 구조
public abstract class Enemy : MonoBehaviour
{
    public int hp = 100;

    // 알고리즘 뼈대 (템플릿 메서드) — 모든 자식이 그대로 사용
    public void TakeDamage(int dmg)
    {
        hp -= dmg;
        if (hp <= 0) Die();
    }

    public void Die() { Destroy(gameObject); }

    // 빈칸 — 새 적 클래스를 만들면 반드시 채워야 한다
    public abstract void Attack(GameObject target);
}

public class Slime : Enemy
{
    public override void Attack(GameObject target) { /* 박치기 */ }
}

public class Goblin : Enemy
{
    public override void Attack(GameObject target) { /* 단검 */ }
}

// 새 적 추가 — Attack 누락 시 CS0534
public class Boss : Enemy
{
    public override void Attack(GameObject target) { /* 패턴 공격 */ }
}
IL
// 호출 측은 단순한 callvirt 한 번
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: callvirt instance void Enemy::Attack(class [UnityEngine]UnityEngine.GameObject)
IL_0008: ret

개선 세 가지:

  • 컴파일러가 누락을 잡아냄Boss 클래스에서 Attack override를 빠뜨리면 즉시 CS0534. 게임을 실행할 필요 없이 빨간 줄이 뜹니다.
  • OCP 준수 — 새 적은 새 파일에서 새 클래스로만 추가됩니다. Enemy.cs는 손대지 않습니다.
  • 단일 callvirt — 문자열 비교 없이 vtable 한 번 조회로 끝납니다. 적이 100마리 모여 있어도 분기 비용이 없습니다.

4.3 Unity 핵심 패턴 — abstract ScriptableObject로 데이터 + 행동 슬롯 만들기

ScriptableObject는 씬에 종속되지 않는 에셋 단위 데이터 컨테이너입니다. abstract와 결합하면 에디터에서 끌어다 놓는 것만으로 행동을 교체할 수 있는 강력한 시스템이 됩니다. 모바일 게임의 스킬·아이템·이펙트 시스템에 자주 쓰입니다.

C#
// ✅ 추상 ScriptableObject 베이스 — [CreateAssetMenu] 일부러 붙이지 않음
public abstract class Skill : ScriptableObject
{
    public string skillName;
    public float cooldown;
    public Sprite icon;

    // 빈칸 — 자식 스킬마다 다르게 구현
    public abstract void Activate(GameObject user);
}

// 구체 스킬 1 — 에디터에서 .asset 파일로 생성 가능
[CreateAssetMenu(fileName = "FireballSkill", menuName = "Skills/Fireball")]
public class FireballSkill : Skill
{
    public float damage;
    public GameObject fireballPrefab;

    public override void Activate(GameObject user)
    {
        // 파이어볼 발사 로직
    }
}

// 구체 스킬 2
[CreateAssetMenu(fileName = "HealSkill", menuName = "Skills/Heal")]
public class HealSkill : Skill
{
    public float healAmount;

    public override void Activate(GameObject user)
    {
        // 체력 회복 로직
    }
}

// 사용 측 — 플레이어는 Skill 타입만 알면 됨
public class Player : MonoBehaviour
{
    public Skill currentSkill;   // 인스펙터에서 .asset 끌어다 놓기

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            currentSkill?.Activate(gameObject);   // 다형성으로 실제 스킬 실행
    }
}

이 패턴의 모바일 실전 가치:

  • 디자이너가 코드 없이 새 스킬 추가 — 새 스킬 .asset 파일 하나 만들고 인스펙터 값만 조정하면 됩니다.
  • abstract 베이스에 [CreateAssetMenu] 미부착 — Unity 에디터의 Create 메뉴에 추상 베이스가 노출되지 않아 디자이너가 실수로 미완성 인스턴스를 만들 수 없습니다(추상 클래스는 ScriptableObject의 CreateInstance도 거부합니다).
  • IL2CPP 빌드 호환 — abstract와 callvirt는 IL2CPP가 C++로 트랜스파일할 때 가상 함수 테이블로 그대로 변환됩니다. 모바일 빌드에서 추가 비용 없습니다.

추상 ScriptableObject + abstract 메서드는 Unity 모바일 클라이언트에서 가장 자주 쓰이는 abstract 활용 패턴입니다. 처음 일주일은 익숙하지 않을 수 있지만, 한 번 익히면 if-else 분기로 돌아갈 수 없습니다.


5. [함정과 주의사항] abstract를 잘못 쓰면 생기는 일들

5.1 ❌ 생성자 안에서 abstract 메서드 호출

C#
// ❌ 절대 하면 안 되는 패턴
public abstract class Weapon
{
    public int damage;

    public Weapon()
    {
        Initialize();   // ← 생성자에서 abstract 호출
    }

    public abstract void Initialize();
}

public class Sword : Weapon
{
    private string blade;

    public Sword()
    {
        blade = "강철";   // 부모 생성자가 끝난 뒤에 실행됨
    }

    public override void Initialize()
    {
        // blade가 아직 null일 때 호출됨!
        UnityEngine.Debug.Log(blade.Length);   // NullReferenceException
    }
}
IL
.method family hidebysig specialname rtspecialname
    instance void .ctor() cil managed
{
    IL_0000: ldarg.0
    IL_0001: call instance void [System.Runtime]System.Object::.ctor()
    IL_0006: ldarg.0
    IL_0007: callvirt instance void Weapon::Initialize()      // ← 부모 생성자 안의 callvirt
    IL_000c: ret
}

문제: new Sword() 실행 흐름은 다음과 같습니다.

  1. Object 생성자 호출
  2. Weapon 생성자 시작 → Initialize() callvirt
  3. callvirt가 vtable을 조회해 Sword.Initialize를 호출 — 그런데 Sword의 필드 blade는 아직 null (자식 생성자 아직 실행 안 됨)
  4. Sword.Initializeblade.Length에서 NullReferenceException

이 함정은 .NET FXCop의 룰 CA2214로 정적 분석 단계에서 경고됩니다. 부모 생성자 안에서는 자기 필드 초기화만 하고, abstract·virtual 메서드를 호출하지 마세요.

C#
// ✅ 초기화는 인스턴스 생성 후 별도 호출
public abstract class Weapon
{
    public int damage;
    public abstract void Initialize();
}

public class Sword : Weapon { ... }

// 사용 측
var w = new Sword();
w.Initialize();   // 자식 필드가 모두 초기화된 후 호출

5.2 ❌ 모든 멤버를 abstract로 만들기

C#
// ❌ 모든 멤버가 abstract인 추상 클래스
public abstract class IDamageable
{
    public abstract int Hp { get; set; }
    public abstract void TakeDamage(int dmg);
    public abstract void Heal(int amount);
}
IL
.class public auto ansi abstract beforefieldinit IDamageable
    extends [System.Runtime]System.Object
{
    .method public hidebysig newslot specialname abstract virtual
        instance int32 get_Hp() cil managed { }
    .method public hidebysig newslot abstract virtual
        instance void TakeDamage(int32 dmg) cil managed { }
    // ... 모두 abstract virtual ...
}

문제: 클래스의 모든 멤버가 abstract라면 더 이상 클래스로 둘 이유가 없습니다. C#은 클래스 단일 상속이라 Player : IDamageable, MonoBehaviour처럼 두 클래스를 상속할 수 없습니다. 행동 규약만 정의하는 용도라면 interface가 정답입니다.

C#
// ✅ 행동 규약은 interface로
public interface IDamageable
{
    int Hp { get; set; }
    void TakeDamage(int dmg);
    void Heal(int amount);
}

// 다중 상속 가능
public class Player : MonoBehaviour, IDamageable, IHealable, IInteractable { ... }

판단 기준:

  • 상태(필드)나 공통 구현(일반·virtual 메서드)이 있다abstract class
  • 순수 행동 규약뿐interface

5.3 ❌ abstract 메서드에 본문 작성

C#
// ❌ 본문이 있으면 abstract가 아님
public abstract class Vehicle
{
    public abstract void Start() { }   // CS0500
}

abstract 메서드는 반드시 세미콜론(;)으로 끝나야 합니다. 본문 { }이 있다면 abstract가 아니라 일반 메서드 또는 virtual 메서드여야 합니다. 컴파일러가 CS0500으로 즉시 잡아냅니다.

기본 동작이 있고 자식이 선택적으로 override하면 되는 경우라면 virtual을 쓰세요.

C#
// ✅ 기본 동작이 있으면 virtual
public abstract class Vehicle
{
    public virtual void Start() { /* 기본 시동 */ }
}

5.4 ❌ abstract 클래스에 public 생성자

C#
// ❌ public이지만 외부에서 호출 불가 — 의도가 모호
public abstract class Animal
{
    public Animal(string name) { Name = name; }
    public string Name;
}

추상 클래스의 생성자는 protected로 명시하는 것이 베스트 프랙티스입니다. public으로 써도 컴파일은 통과하지만 외부에서 new Animal("...")은 CS0144로 막힙니다. IDE 자동완성에 노출되어 사용자에게 혼란을 줄 뿐입니다.

C#
// ✅ protected 명시 — 자식 생성자만 호출 가능하다는 의도가 명확
public abstract class Animal
{
    protected Animal(string name) { Name = name; }
    public string Name;
}

IL에서는 protectedfamily 플래그로 표현됩니다. 클래스 헤더의 abstract 플래그와 함께, 외부 인스턴스화 차단의 의도가 IL 레벨에서도 명확해집니다.


6. [C# 버전별 변화] abstract의 점진적 확장

abstract 키워드 자체는 C# 1.0부터 있었지만, 주변 문법이 발전하면서 활용 범위가 넓어졌습니다.

6.1 C# 9 — record에도 abstract 적용 가능

C#
// C# 9 이후 — 데이터 중심 추상 베이스
public abstract record Animal(string Name);

public record Dog(string Name, string Breed) : Animal(Name);
public record Cat(string Name, bool IsIndoor) : Animal(Name);

// var a = new Animal("???");        // CS0144 — abstract record도 인스턴스 불가
Animal a = new Dog("바둑이", "진돗개");
IL
.class public auto ansi abstract beforefieldinit Animal
    extends [System.Runtime]System.Object
    implements class [System.Runtime]System.IEquatable`1<class Animal>
{
    // 컴파일러가 자동 생성한 EqualityContract, Equals, GetHashCode 등
    // 그래도 클래스 자체는 abstract 플래그 그대로
}

record는 컴파일러가 Equals·GetHashCode·PrintMembers를 자동 생성하지만, abstract record로 선언하면 클래스 헤더에 abstract 플래그가 박힙니다. 데이터 모델의 공통 베이스를 표현할 때 자연스럽게 선택할 수 있습니다.

6.2 sealed override — abstract와 짝지어 다형성 종료점 만들기

abstract 메서드를 자식이 override한 뒤 sealed로 마무리하면, 손자 클래스가 더 이상 재정의할 수 없습니다. C# 1.0부터 가능했지만, .NET 6+ JIT의 devirtualization 최적화가 강화되면서 그 가치가 다시 조명되었습니다.

C#
public abstract class Shape2
{
    public abstract double Area();
}

// abstract를 채우면서 동시에 봉인
public class Rectangle : Shape2
{
    public double W, H;
    public sealed override double Area() { return W * H; }
}

// 손자가 다시 override 시도 → CS0239
// public class Square : Rectangle
// {
//     public override double Area() { return W * W; }  // CS0239
// }
IL
.class public auto ansi beforefieldinit Rectangle
    extends Shape2
{
    .method public final hidebysig virtual                    // ← final = sealed
        instance float64 Area () cil managed
    {
        IL_0000: nop
        IL_0001: ldarg.0
        IL_0002: ldfld float64 Rectangle::W
        IL_0007: ldarg.0
        IL_0008: ldfld float64 Rectangle::H
        IL_000d: mul
        // ...
    }
}

IL의 final 플래그가 sealed의 정체입니다. JIT은 sealed로 닫힌 메서드는 더 이상 재정의되지 않을 것을 알기 때문에, 호출 측을 callvirt 대신 직접 호출(call)로 최적화할 수 있습니다. abstract가 강제하는 다형성과 sealed가 닫는 다형성은 좋은 짝입니다.

6.3 C# 11 — interface의 static abstract로 일부 역할 이전

C# 11에서 인터페이스에 static abstract 멤버가 도입되었습니다. 정적 다형성(제네릭 수학 연산자 등)이 필요할 때 abstract class 대신 interface를 쓸 수 있게 되었습니다. abstract class가 받던 일부 역할이 interface로 이전된 사례지만, 인스턴스 상태(필드)가 필요한 경우는 여전히 abstract class의 영역입니다.

C#
// C# 11 — 인터페이스에 static abstract 가능
public interface INumber<T>
{
    static abstract T Zero { get; }
    static abstract T operator +(T a, T b);
}

// 일반 abstract class와는 사용 영역이 다름 — interface는 다중 구현 가능

자세한 내용은 다음 글(인터페이스 시리즈)에서 다룹니다. 여기서는 "abstract class와 interface의 경계는 C# 버전이 올라갈수록 좁혀지고 있다"는 흐름만 기억하면 충분합니다.


7. [정리]

abstract 클래스와 abstract 메서드는 "미완성을 컴파일러가 강제로 완성시키게 만드는" 도구입니다. 핵심을 일곱 줄로 정리합니다.

  • abstract class는 직접 인스턴스화 불가new BaseClass()는 CS0144. 컴파일러와 CLR이 이중으로 막는다.
  • abstract 메서드는 본문 없음 + 자식이 반드시 override — 누락 시 CS0534. vtable 슬롯은 차지하지만 함수 포인터가 비어 있다.
  • callvirt는 vtable을 조회해 자식 구현으로 점프 — abstract 호출도 일반 가상 메서드 호출과 IL 명령어가 같다.
  • 추상 클래스 생성자는 protected로 명시 — 외부 인스턴스화 차단의 의도를 표현. 자식 생성자에서 : base(...) 로 호출.
  • 생성자 안에서 abstract 메서드 호출 금지 — 자식 필드가 아직 null일 수 있다 (CA2214 경고).
  • 순수 행동 규약은 interface, 상태·공통 구현이 있으면 abstract class — 모든 멤버가 abstract면 interface를 고려한다.
  • Unity의 abstract ScriptableObject + abstract 메서드 — 디자이너가 코드 없이 새 행동을 .asset으로 추가할 수 있게 만드는 모바일 클라이언트 핵심 패턴.

다음 글에서는 이 abstract class와 짝을 이루는 또 다른 추상화 도구, 인터페이스(interface)를 다룹니다. 클래스 단일 상속의 한계를 어떻게 풀어내는지, 행동 규약을 어떻게 다중으로 부여하는지를 같은 깊이로 따라갑니다.

반응형

+ Recent posts