[PART8.상속과 인터페이스 사용법(1/11)] 상속 문법 — :
class Dog : Animal 한 줄이 IL과 메모리 레이아웃에 무엇을 새기는가 / 단일 상속 원칙 / 생성자 호출 순서와 : base()
목차
1. 문제 제기 — Unity에서 같은 코드가 여러 캐릭터에 반복될 때
Unity에서 적 캐릭터(Slime, Goblin, Dragon)를 만들고 있습니다. 셋 다 체력이 있고, 데미지를 받으면 체력이 깎이고, 0이 되면 죽습니다. 처음에는 각 클래스마다 같은 필드와 메서드를 복붙해 넣었습니다.
public class Slime : MonoBehaviour
{
public int hp;
public void TakeDamage(int dmg) { hp -= dmg; if (hp <= 0) Die(); }
void Die() { Destroy(gameObject); }
}
public class Goblin : MonoBehaviour
{
public int hp; // 똑같음
public void TakeDamage(int dmg) { hp -= dmg; if (hp <= 0) Die(); } // 똑같음
void Die() { Destroy(gameObject); } // 똑같음
}
곧 문제가 터집니다. 데미지에 방어력을 적용해야 한다는 요구가 생기면 세 군데를 모두 수정해야 합니다. 한 군데 빠뜨리면 슬라임만 방어력 무시 데미지를 받습니다. 더 근본적으로는, "이 셋은 모두 적이다"라는 개념이 코드에는 어디에도 없습니다. 컴파일러도, 다른 시스템도 셋을 묶을 방법이 없습니다.
C#의 상속 문법 :가 이 두 문제를 한 번에 해결합니다. 공통 상태와 동작을 부모 클래스로 뽑고, 자식 클래스는 콜론 한 번으로 그것을 그대로 물려받습니다. 이 글은 class Dog : Animal 한 줄이 IL 메타데이터, 메모리 레이아웃, 생성자 호출 순서에 무엇을 새기는지를 따라갑니다.
2. 개념 정의 — :는 "is-a" 관계의 선언
비유: 회사의 직급 체계
회사에 "직원(Employee)"이라는 공통 직급이 있습니다. 모든 직원은 사번과 이름이 있고, 출근과 퇴근을 합니다. "개발자(Developer)"는 직원이면서 추가로 코딩을 합니다. "디자이너(Designer)"도 직원이면서 추가로 디자인을 합니다.
개발자에게 "출근하세요"라고 말할 때, 개발자가 직원의 자식이라는 사실을 굳이 의식하지 않습니다. 개발자는 이미 직원이기 때문입니다. C#의 :는 이 "이미 ~이다(is-a)"라는 관계를 코드에 박아 넣는 문법입니다.
:— 상속 선언 연산자 클래스 선언에서 클래스 이름 뒤에 콜론을 쓰면, 그 뒤에 오는 타입을 부모 클래스(또는 인터페이스)로 지정한다. C# 컴파일러는 이 정보를 IL 메타데이터의extends항목으로 기록하며, 자식은 부모의 모든public·protected멤버를 자기 것처럼 사용할 수 있게 된다.
예시:public class Dog : Animal { ... }Dog는Animal을 상속하고,Animal의 모든 멤버(필드·메서드·프로퍼티)를 그대로 가진다.
시각화: :로 만들어지는 상속 트리

기본 코드: Dog : Animal
using System;
public class Animal
{
protected string Name; // 자식만 접근 가능
public int Age; // 누구나 접근 가능
public Animal(string name, int age)
{
Name = name;
Age = age;
Console.WriteLine("Animal 생성자");
}
public void Eat()
{
Console.WriteLine($"{Name}이 먹는다");
}
}
public class Dog : Animal
{
public string Breed;
public Dog(string name, int age, string breed) : base(name, age)
{
Breed = breed;
Console.WriteLine("Dog 생성자");
}
public void Bark()
{
Console.WriteLine($"{Name}({Breed})이 짖는다"); // 부모의 protected 필드 사용
}
}
public class Program
{
public static void Main()
{
Dog d = new Dog("바둑이", 3, "진돗개");
d.Eat(); // Animal에 정의된 메서드 호출
d.Bark(); // Dog에 정의된 메서드 호출
}
}
Dog는 자기 클래스 안에Eat()을 적지 않았는데도d.Eat()이 동작합니다.Animal의 멤버를 그대로 가졌기 때문입니다.Bark()안에서Name을 직접 씁니다.Name은Animal의protected필드인데, 자식 클래스 내부에서는 자기 필드처럼 접근됩니다.: base(name, age)는 부모 생성자에 인수를 넘겨 호출합니다. 자세한 동작은 [4. 실전 적용]에서 다룹니다.
이 코드를 컴파일해 IL 메타데이터를 들여다보겠습니다.
.class public auto ansi beforefieldinit Animal
extends [System.Runtime]System.Object // Animal은 암묵적으로 Object를 상속
{
.field family string Name // protected → IL에서는 family
.field public int32 Age
// ... 메서드 ...
}
.class public auto ansi beforefieldinit Dog
extends Animal // : Animal 이 IL에서는 extends Animal
{
.field public string Breed
// ... 메서드 ...
}
IL 분석 포인트
extends Animal—:의 정체extends [System.Runtime]System.Object— 암묵적 부모family—protected의 IL 표기
요약::는 IL의extends메타데이터다. 한 글자가 부모 타입의 모든 멤버를 자식의 타입 정의에 합쳐 넣고, 접근 권한 검사 규칙까지 만든다.
3. 내부 동작 — 메모리 레이아웃과 객체 초기화 순서
:로 자식이 부모의 필드를 그대로 쓸 수 있는 이유는, 자식 객체의 메모리 안에 부모 필드가 통째로 들어 있기 때문입니다. 이 절은 객체가 힙에 어떻게 깔리고, 생성자가 어떤 순서로 실행되는지를 따라갑니다.
메모리 레이아웃: 부모 필드가 먼저 깔린다
new Dog("바둑이", 3, "진돗개")가 실행되면 CLR은 관리되는 힙(Managed Heap, GC(Garbage Collector, .NET 런타임이 더 이상 참조되지 않는 객체의 메모리를 자동으로 회수해 주는 구성요소)가 관리하는 객체용 메모리 영역)에 Dog의 모든 인스턴스 필드 + Animal의 모든 인스턴스 필드 + System.Object의 헤더를 합한 크기만큼의 메모리 블록을 할당합니다.

이 구조 덕분에 자식 클래스 안에서 Name을 읽는 코드는 자기 메모리 블록의 특정 오프셋을 읽는 단순한 동작으로 컴파일됩니다. 부모 객체를 따로 들고 있다가 위임하는 방식이 아닙니다.
객체 초기화 순서
생성자 호출은 다음 단계로 진행됩니다(new Dog(...) 기준).
- CLR이 힙에 메모리 블록을 할당하고, 모든 필드를
0/null로 0-초기화한다. - 자식의 인스턴스 필드 초기화자가 실행된다 (
public string Breed = "default";같은 선언 시 초기화). - 부모 생성자가 호출된다. 부모 안에서 부모의 필드 초기화자 → 부모 생성자 본문 순서로 실행된다.
- 자식 생성자 본문이 실행된다.
그림으로 보면 다음과 같습니다.

이 순서를 IL로 직접 확인해보겠습니다. Dog::.ctor의 IL은 다음과 같습니다.
.method public hidebysig specialname rtspecialname
instance void .ctor (
string name,
int32 age,
string breed
) cil managed
{
.maxstack 8
IL_0000: ldarg.0 // this 푸시
IL_0001: ldarg.1 // name 푸시
IL_0002: ldarg.2 // age 푸시
IL_0003: call instance void Animal::.ctor(string, int32) // ← 부모 생성자 먼저 호출
IL_0008: nop
IL_0009: nop
IL_000a: ldarg.0
IL_000b: ldarg.3 // breed 푸시
IL_000c: stfld string Dog::Breed // 자식 필드에 저장
IL_0011: ldstr "Dog 생성자"
IL_0016: call void [System.Console]System.Console::WriteLine(string)
IL_001b: nop
IL_001c: ret
}
IL 분석 포인트
- 부모 생성자 호출이 자식 생성자의 첫 동작
call이지callvirt가 아니다- 자식이 부모 필드에 직접
stfld한다
요약: 자식 객체는 메모리에 부모 필드를 통째로 안고 있다. 생성자는 항상 부모 → 자식 순서로 호출되며, IL의 call Animal::.ctor이 자식 생성자의 첫 명령으로 박힌다.
4. 실전 적용 — : base(args)로 부모 생성자에 인수 넘기기
부모 클래스가 매개변수 있는 생성자만 가지고 있으면, 자식은 : base(args)로 어느 부모 생성자를 쓸지 명시해야 합니다. 안 적으면 컴파일러가 : base()를 자동으로 시도하다가 매개변수 없는 생성자를 찾지 못해 컴파일 에러가 납니다.
Before — 부모에 기본 생성자가 없는데 자식이 : base(...) 누락
public class Vehicle
{
public string Model;
public Vehicle(string model) // 매개변수 있는 생성자만 정의
{
Model = model;
}
}
public class Car : Vehicle
{
public int Doors;
public Car(int doors) // ❌ : base("...") 가 없음
{
Doors = doors;
}
}
이 코드는 다음 에러로 컴파일 자체가 실패합니다.
error CS7036: 'Vehicle.Vehicle(string)'의 필수 형식 매개 변수 'model'에 해당하는 지정된 인수가 없습니다.
이유는 컴파일러가 자식 생성자에 자동으로 : base()를 끼우려는데, Vehicle에 매개변수 없는 생성자가 없기 때문입니다. 한 번 매개변수 있는 생성자를 정의하면 컴파일러가 만들어주던 묵시적 기본 생성자가 사라진다는 점이 함정입니다.
After — : base("...")로 명시적 호출
public class Vehicle
{
public string Model;
public Vehicle(string model)
{
Model = model;
}
}
public class Car : Vehicle
{
public int Doors;
public Car(string model, int doors) : base(model) // ✅ 부모 생성자에 모델명 전달
{
Doors = doors;
}
}
: base(model) 한 줄로 컴파일이 통과합니다. IL을 보면 컴파일러가 무엇을 했는지 명확합니다.
.method public hidebysig specialname rtspecialname
instance void .ctor (
string model,
int32 doors
) cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.1 // model 푸시
IL_0002: call instance void Vehicle::.ctor(string) // ← : base(model) 의 결과
IL_0007: nop
IL_0008: nop
IL_0009: ldarg.0
IL_000a: ldarg.2
IL_000b: stfld int32 Car::Doors
IL_0010: ret
}
IL 분석 포인트
: base(model)→call Vehicle::.ctor(string): base()생략 시에도 IL은 같은 형태
Unity 실전 — MonoBehaviour 베이스 패턴
Unity에서 적 종류가 여러 개일 때 공통 동작을 부모 클래스에 모으는 패턴은 가장 흔한 상속 활용입니다.
using UnityEngine;
public class Enemy : MonoBehaviour
{
public int maxHp = 100;
protected int hp;
protected int defense = 0;
protected virtual void Awake()
{
hp = maxHp;
}
public void TakeDamage(int dmg)
{
int real = Mathf.Max(1, dmg - defense);
hp -= real;
if (hp <= 0) Die();
}
protected virtual void Die()
{
Destroy(gameObject);
}
}
public class Goblin : Enemy
{
protected override void Awake()
{
// 부모 초기화를 반드시 먼저 호출 — 안 그러면 hp가 0인 상태로 시작
base.Awake();
defense = 5;
}
}
public class Dragon : Enemy
{
public GameObject deathEffect;
protected override void Awake()
{
base.Awake();
maxHp = 1000;
hp = maxHp; // maxHp를 늘렸으니 hp도 다시 맞춤
defense = 50;
}
protected override void Die()
{
Instantiate(deathEffect, transform.position, Quaternion.identity);
base.Die();
}
}
이 구조의 이점은 두 가지입니다.
- 데미지 계산 로직이
Enemy에 한 번만 있으므로 방어력 공식을 바꿀 때 한 곳만 수정하면 됩니다. List<Enemy>로 모든 적을 한 컬렉션에 담아 일괄 처리할 수 있습니다(다형성, [PART 8.10. is·as·타입 패턴]에서 자세히 다룹니다).
Enemy는 MonoBehaviour를 상속하고 Goblin은 Enemy를 상속하므로, IL에는 Goblin extends Enemy, Enemy extends UnityEngine.MonoBehaviour, MonoBehaviour extends ... extends Object라는 체인이 박힙니다. Unity 엔진이 Goblin 컴포넌트의 Awake()를 호출할 때 이 체인을 타고 올라가며 메서드 테이블을 검색합니다.
요약:: base(args)는 IL에서 부모 생성자 정적 호출 한 줄이다. 부모에 매개변수 있는 생성자만 있으면 자식은 반드시: base(...)로 어떤 생성자를 쓸지 명시해야 한다. Unity에서는 적·아이템·UI 컴포넌트의 공통 동작을 부모 클래스로 뽑는 패턴이 표준이다.
5. 함정과 주의사항
함정 1: C# 단일 상속 원칙
C#은 클래스 다중 상속을 허용하지 않습니다. 대신 인터페이스의 다중 구현으로 비슷한 효과를 냅니다. 다중 상속이 허용되면 "다이아몬드 문제"가 생기기 때문입니다.

// ❌ C#에서 컴파일 에러 — 다중 클래스 상속 불가
public class Scanner { public virtual void Activate() { } }
public class Printer { public virtual void Activate() { } }
public class Copier : Scanner, Printer { } // CS1721: 한 번에 둘 이상의 기본 클래스를 가질 수 없음
// ✅ 인터페이스로 다중 "구현" — 모호함이 없음
public interface IScanner { void Scan(); }
public interface IPrinter { void Print(); }
public class Copier : IScanner, IPrinter
{
public void Scan() { /* ... */ } // Copier가 직접 구현 → 모호하지 않음
public void Print() { /* ... */ }
}
인터페이스에는 본문이 없으므로(C# 8 이전 기준 — [6. C# 버전별 변화] 참조) "어느 부모의 구현을 쓸지" 고민할 일이 없습니다. 자식이 직접 모두 구현하기 때문입니다.
함정 2: 자식 필드 초기화자가 부모 생성자보다 먼저 실행되는데, 부모 생성자에서 자식 메서드를 호출하면?
부모 생성자 안에서 가상 메서드를 호출하면, 자식의 오버라이드된 버전이 실행됩니다 — 그 시점에 자식 생성자 본문은 아직 실행되지 않았는데도 말입니다.
// ❌ 위험한 패턴
public class Base
{
public Base()
{
Init(); // 가상 메서드 호출 — 자식이 오버라이드했으면 자식 버전이 실행됨
}
public virtual void Init() { }
}
public class Derived : Base
{
private string config = "default";
public override void Init()
{
Console.WriteLine(config.Length); // 💥 NullReferenceException 가능
}
}
// new Derived() 호출 흐름:
// 1. CLR이 메모리 할당 + 모든 필드 0/null
// 2. Derived의 필드 초기화자 실행 → config = "default"
// 3. Base 생성자 진입 → Init() 호출 → Derived.Init() 실행
// config가 이 시점에 초기화돼 있느냐는 코드 작성 방식에 따라 달라지므로
// 부모 생성자가 자식 가상 메서드를 호출하는 패턴 자체가 위험으로 분류된다
위 코드는 운 좋게 동작할 수도 있지만, 이런 패턴은 피해야 합니다. 부모 생성자 안에서는 가상 메서드를 호출하지 마세요. 초기화에 자식이 끼어들 자리는 자식의 자기 생성자입니다.
// ✅ 올바른 패턴 — 자식 생성자에서 자기 초기화 마무리
public class Base
{
protected virtual void Init() { }
}
public class Derived : Base
{
private string config = "default";
public Derived()
{
Init(); // 부모 생성자 끝난 후, 자식 생성자에서 호출 → 안전
}
protected override void Init()
{
Console.WriteLine(config.Length); // OK
}
}
함정 3: Unity의 MonoBehaviour는 new로 만들 수 없다
// ❌ Unity에서 절대 하지 말 것
public class Player : MonoBehaviour
{
public int hp;
}
// 아무 곳에서나
Player p = new Player(); // 컴파일은 통과, 런타임에 경고/오류
p.hp = 100;
MonoBehaviour는 Unity 엔진이 게임 오브젝트(GameObject)에 부착해 관리하는 컴포넌트입니다. 직접 new로 만들면 엔진이 모르는 객체가 되고, transform·gameObject·라이프사이클 메서드(Awake/Start/Update) 어느 것도 동작하지 않습니다.
// ✅ 항상 AddComponent<T>() 사용
GameObject go = new GameObject("Enemy");
Enemy enemy = go.AddComponent<Enemy>();
이 함정은 상속 자체의 문제는 아니지만, "부모 클래스의 약속을 자식이 깨면 안 된다"는 상속의 원칙(LSP, Liskov Substitution Principle — 자식은 부모를 대체할 수 있어야 한다는 원칙)을 보여주는 사례입니다. MonoBehaviour는 자식이 절대 직접 인스턴스화되지 말아야 한다는 약속을 가지고 있습니다.
요약: 다중 상속은 다이아몬드 문제 때문에 금지. 부모 생성자 안에서 가상 메서드 호출 금지. Unity의MonoBehaviour자식은new금지 — 반드시AddComponent.
6. C# 버전별 변화
C# 1.0~7.x — 클래스·인터페이스 상속의 기본형
: 한 글자로 클래스를 상속하고, 매개변수 인터페이스를 구현하는 골격은 C# 1.0부터 변하지 않았습니다. 이 절의 1~5장은 모두 C# 1.0 시절부터 동작하는 코드입니다.
C# 8.0 (2019) — 인터페이스 기본 구현
C# 8 이전에는 인터페이스에 본문이 없었지만, C# 8부터는 메서드의 기본 구현을 인터페이스에 넣을 수 있게 됐습니다.
public interface ILogger
{
void Log(string msg);
void LogError(string msg) => Log("[ERROR] " + msg); // C# 8+ 기본 구현
}
public class ConsoleLogger : ILogger
{
public void Log(string msg) => Console.WriteLine(msg);
// LogError는 구현 안 해도 됨 — 인터페이스의 기본 구현이 동작
}
이로써 인터페이스에도 일종의 "구현 상속"이 생겼지만, 클래스 다중 상속과는 다릅니다(상태/필드는 여전히 인터페이스에 못 들어감). 자세한 내용은 [PART 8.7. 인터페이스 기본 구현]에서 다룹니다.
C# 9.0 (2020) — record 상속
record 타입도 일반 클래스처럼 :로 상속할 수 있습니다. record의 자동 생성된 Equals/GetHashCode/ToString이 부모 멤버까지 함께 비교·출력하도록 합성됩니다.
// Before C# 9 — 일반 클래스로 데이터 객체 만들기
public class Person
{
public string Name { get; }
public Person(string name) { Name = name; }
}
public class Student : Person
{
public int StudentId { get; }
public Student(string name, int id) : base(name) { StudentId = id; }
}
// After C# 9 — record + 위치 매개변수 + 상속
public abstract record Person(string Name);
public record Student(string Name, int StudentId) : Person(Name);
// └ : base(Name) 과 같은 의미
record struct는 값 타입이므로 상속 불가입니다. record class만 상속 가능합니다.
C# 11.0 (2022) — file 한정자
C# 11의 file 한정자를 붙인 타입은 같은 파일 안에서만 보이며, 더 넓은 가시성(public 등)의 타입이 file 타입을 부모로 삼을 수 없습니다 — 부모가 자식보다 가시성이 낮을 수 없다는 일반 규칙의 자연스러운 확장입니다.
// File1.cs
file class InternalHelper { } // 이 파일 안에서만 보임
public class Pub : InternalHelper { } // ❌ 컴파일 에러 — public이 file 타입을 상속할 수 없음
요약: 상속 문법 : 자체는 C# 1.0부터 안정적이다. C# 8의 인터페이스 기본 구현, C# 9의 record 상속, C# 11의 file 가시성 규칙처럼 주변부가 확장돼 왔다.
7. 정리
상속 문법 :은 한 글자지만 IL 메타데이터·메모리 레이아웃·런타임 동작 전체를 결정합니다. Unity 모바일 환경에서 적·아이템·UI 컴포넌트의 공통 동작을 정리할 때 가장 먼저 만나는 도구이기도 합니다.
핵심 체크리스트
- [ ]
class Dog : Animal의:는 IL의extends Animal메타데이터다. - [ ] 어떤
:도 안 적은 클래스는 컴파일러가 자동으로System.Object를 부모로 삼는다. - [ ] 자식 객체의 메모리에는 부모 필드가 통째로 깔린다 — 부모 멤버 접근은 자기 메모리의 오프셋 읽기다.
- [ ] 생성자는 항상 부모 → 자식 순서로 호출된다. 자식 생성자의 첫 IL 명령은
call <부모>::.ctor다. - [ ] 부모에 매개변수 있는 생성자만 있으면 자식은
: base(args)로 명시 호출해야 한다. 안 그러면 CS7036. - [ ] C#은 클래스 단일 상속만 허용한다. 다중 상속은 인터페이스로 표현한다(다이아몬드 문제 회피).
- [ ] 부모 생성자 안에서 가상 메서드를 호출하지 않는다. 자식이 자기 초기화를 끝내기 전 실행될 수 있다.
- [ ] Unity의
MonoBehaviour자식은new로 만들지 않는다.AddComponent<T>()만 사용한다.
다음 글 예고
다음 글([PART 8.2. virtual · override · base])에서는 :로 부모를 정해 놓고도 자식이 부모의 동작을 어떻게 갈아끼우는지 — virtual/override/base 키워드와 vtable 디스패치를 IL로 따라갑니다.
