| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 패스트캠퍼스후기
- Tween
- 암호화
- 최적화
- AES
- 프레임워크
- 샘플
- Job 시스템
- 패스트캠퍼스
- unity
- 직장인자기계발
- 가이드
- C#
- base64
- 2D Camera
- DotsTween
- Framework
- Unity Editor
- job
- ui
- RSA
- TextMeshPro
- 오공완
- Dots
- adfit
- Custom Package
- sha
- 게임개발
- 환급챌린지
- 직장인공부
- Today
- Total
EveryDay.DevUp
[PART3.상속과 다형성(1/4)] 상속 vs 컴포지션 — 언제 상속을 쓰고 언제 쓰지 않는가 본문
상속 vs 컴포지션 — 언제 상속을 쓰고 언제 쓰지 않는가
Unity 게임에서 "바나나 하나가 필요한데 정글 전체를 얻게 되는" 상황, 겪어본 적 있는가? 상속과 컴포지션의 차이를 알면 이런 설계 함정을 피할 수 있다.
문제 제기
적(Enemy)과 플레이어(Player)가 모두 체력을 갖고 피해를 입는 게임을 만든다고 하자. "둘 다 체력이 있으니까 부모 클래스를 만들자"고 생각하는 것은 자연스럽다. Unit이라는 기반 클래스에 Hp와 TakeDamage를 넣고, Player와 Enemy가 이를 상속받는다.
처음에는 깔끔해 보인다. 그런데 요구사항이 늘어난다. 날아다니는 적, 방패를 든 적, 무적 상태의 플레이어, 피해를 입는 환경 오브젝트(폭발 상자)... 상속 트리는 점점 깊어지고, 한 클래스를 수정하면 예상치 못한 곳에서 버그가 터진다.
이 글에서는 상속과 컴포지션이 각각 어떻게 동작하는지 IL 수준까지 들여다보고, 어떤 상황에서 어떤 방식을 선택해야 하는지 판단 기준을 세운다.
개념 정의
상속 — "나는 ~의 한 종류다" (is-a)
레고 블록에 비유하면, 상속은 기존 블록 위에 새 블록을 쌓아 올리는 것이다. 아래 블록(부모)을 떼어낼 수 없고, 아래 블록의 구조가 바뀌면 위 블록(자식)도 흔들린다.
virtual— 가상 메서드 (Virtual method) 파생 클래스가override로 재정의할 수 있도록 기반 클래스에서 선언하는 메서드다. 런타임에 실제 객체 타입에 따라 호출될 메서드가 결정된다.
예시:public virtual void TakeDamage(int amount) { ... }파생 클래스에서override하면 해당 구현이 실행됨
override— 메서드 재정의 (Method override) 기반 클래스의virtual메서드를 파생 클래스에서 새로운 구현으로 대체하는 키워드다.
예시:public override void TakeDamage(int amount) { ... }부모 타입 변수로 호출해도 자식의 구현이 실행됨
public class Unit
{
public int Hp;
public virtual void TakeDamage(int amount)
{
Hp -= amount;
if (Hp <= 0) Die();
}
protected virtual void Die()
{
Console.WriteLine("Unit destroyed");
}
}
public class Player : Unit
{
public override void TakeDamage(int amount)
{
amount = Math.Max(1, amount - 5); // 방어력 적용
base.TakeDamage(amount);
}
protected override void Die()
{
Console.WriteLine("Game Over");
}
}
public class FlyingEnemy : Unit
{
public bool IsFlying = true;
public override void TakeDamage(int amount)
{
if (IsFlying) amount /= 2;
base.TakeDamage(amount);
}
protected override void Die()
{
IsFlying = false;
Console.WriteLine("Enemy crashed");
}
}
Player는 Unit의 한 종류(is-a)이고, FlyingEnemy도 Unit의 한 종류다. 부모 타입인 Unit 변수에 자식 인스턴스를 담아도 각자의 TakeDamage가 호출된다 — 이것이 다형성(Polymorphism, 같은 호출이 객체의 실제 타입에 따라 다르게 동작하는 특성)이다.
// Main에서 Unit 타입 변수로 호출
IL_0001: newobj instance void Player::.ctor() // Player 생성
IL_0006: stloc.0 // Unit 타입 변수에 저장
IL_0007: ldloc.0
IL_0008: ldc.i4.s 10
IL_000a: callvirt instance void Unit::TakeDamage(int32) // vtable 조회 → Player.TakeDamage 실행
IL_0010: newobj instance void FlyingEnemy::.ctor() // FlyingEnemy 생성
IL_0015: stloc.1 // Unit 타입 변수에 저장
IL_0016: ldloc.1
IL_0017: ldc.i4.s 20
IL_0019: callvirt instance void Unit::TakeDamage(int32) // vtable 조회 → FlyingEnemy.TakeDamage 실행
핵심은 callvirt다. IL 코드에는 Unit::TakeDamage로 적혀 있지만, 런타임에 vtable(가상 메서드 테이블, 객체 타입별로 실제 호출할 메서드 주소를 기록한 테이블)을 조회하여 실제 타입의 메서드를 호출한다. 이 vtable 조회가 상속 기반 다형성의 핵심 메커니즘이다.
컴포지션 — "나는 ~을 가지고 있다" (has-a)
레고에 비유하면, 컴포지션은 독립적인 블록들을 옆으로 조립하는 것이다. 필요 없는 블록은 떼어내고, 다른 블록으로 교체할 수 있다.
sealed— 봉인 (Sealed) 이 클래스는 더 이상 상속할 수 없음을 선언하는 키워드다. JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환하는 컴파일러) 컴파일러가 가상 디스패치를 제거하고 메서드를 인라인할 수 있어 성능이 향상된다.
예시:public sealed class HealthSystem { ... }이 클래스를 상속하려 하면 컴파일 에러 발생
public interface IDamageable
{
void TakeDamage(int amount);
}
public sealed class HealthSystem
{
public int Hp { get; private set; }
private readonly Action _onDeath;
public HealthSystem(int maxHp, Action onDeath)
{
Hp = maxHp;
_onDeath = onDeath;
}
public void ApplyDamage(int amount)
{
Hp -= amount;
if (Hp <= 0) _onDeath();
}
}
public class Player : IDamageable
{
private readonly HealthSystem _health;
public Player()
{
_health = new HealthSystem(100, () => Console.WriteLine("Game Over"));
}
public void TakeDamage(int amount)
{
amount = Math.Max(1, amount - 5);
_health.ApplyDamage(amount);
}
}
public class FlyingEnemy : IDamageable
{
private readonly HealthSystem _health;
public bool IsFlying = true;
public FlyingEnemy()
{
_health = new HealthSystem(50, () =>
{
IsFlying = false;
Console.WriteLine("Enemy crashed");
});
}
public void TakeDamage(int amount)
{
if (IsFlying) amount /= 2;
_health.ApplyDamage(amount);
}
}
Player와 FlyingEnemy는 더 이상 Unit을 상속하지 않는다. 대신 HealthSystem을 부품으로 가지고(has-a) 있으며, 피해 처리를 이 부품에 위임한다. 폭발 상자가 체력이 필요하면? HealthSystem을 필드로 넣으면 끝이다. 상속 트리를 건드릴 필요가 없다.
// Player.TakeDamage — 컴포지션 방식
IL_000c: ldarg.0
IL_000d: ldfld class HealthSystem Player::_health // _health 부품 필드를 로드
IL_0012: ldarg.1
IL_0013: callvirt instance void HealthSystem::ApplyDamage(int32) // 부품에 위임
상속 방식에서는 call instance void Unit::TakeDamage로 부모의 구현을 직접 호출했지만, 컴포지션에서는 ldfld로 부품 객체를 꺼낸 뒤 그 객체의 메서드를 호출한다. 부모의 내부 구현에 의존하지 않으므로 HealthSystem을 자유롭게 수정할 수 있다.
내부 동작
상속의 메모리 레이아웃 — 하나의 객체, 하나의 힙 할당

상속에서는 new FlyingEnemy()를 호출하면 부모(Unit)의 필드와 자식(FlyingEnemy)의 필드가 하나의 힙 객체 안에 연속으로 배치된다. 힙 할당은 딱 1회다. 이것이 상속의 성능 이점이다 — 별도의 부품 객체를 만들 필요 없이 하나의 메모리 블록에 모든 데이터가 들어간다.
하지만 이 구조가 곧 강한 결합(Tight coupling, 한쪽을 변경하면 다른 쪽에 영향이 전파되는 관계)의 원인이 된다. Unit에 필드를 추가하면 모든 파생 클래스의 메모리 레이아웃이 변경되고, Unit.TakeDamage의 내부 로직을 바꾸면 base.TakeDamage를 호출하는 모든 자식이 영향을 받는다.
컴포지션의 메모리 레이아웃 — 독립된 객체, 참조로 연결

컴포지션에서는 FlyingEnemy와 HealthSystem이 별도의 힙 객체로 존재한다. FlyingEnemy는 HealthSystem에 대한 참조(주소)만 가지고 있을 뿐, HealthSystem의 내부 필드나 구현에 대해 아무것도 모른다.
이 구조의 대가는 힙 할당이 1회 더 발생한다는 점이다. 하지만 Unity 게임에서 객체 생성은 보통 씬 로딩이나 스폰 시점에 일어나므로, Update에서 매 프레임 생성하지 않는 한 이 비용은 무시할 수 있다.
callvirt vs call — 가상 디스패치의 비용
상속의 virtual/override 메서드는 IL에서 callvirt로 호출된다. 이 명령어는 두 가지 작업을 수행한다:
- null 체크 — 객체가 null이면
NullReferenceException발생 - vtable 조회 — 객체의 Method Table에서 실제 호출할 메서드 주소를 찾음
// 상속 — Unit.TakeDamage 내부에서 Die 호출
IL_001f: ldarg.0
IL_0020: callvirt instance void Unit::Die() // vtable 조회 → 실제 타입의 Die 실행
반면 컴포지션에서 sealed 클래스의 비가상 메서드는 call로 호출될 수 있다. vtable 조회가 필요 없으므로 JIT 컴파일러가 메서드를 인라인(Inline, 함수 호출을 함수 본문으로 대체하는 최적화)할 수 있다.
// 컴포지션 — HealthSystem.ApplyDamage (sealed 클래스)
IL_000d: ldfld class HealthSystem Player::_health
IL_0012: ldarg.1
IL_0013: callvirt instance void HealthSystem::ApplyDamage(int32)
// HealthSystem이 sealed이므로 JIT가 devirtualize → call로 최적화 → 인라인 가능
IL 수준에서는 둘 다 callvirt로 보이지만, JIT 컴파일러는 sealed 클래스의 메서드를 devirtualize(가상 호출을 직접 호출로 변환하는 JIT 최적화)하여 인라인할 수 있다. 이것이 컴포지션에서 sealed를 붙이는 것이 중요한 이유다.
실전 적용
판단 기준 — 상속을 쓸 때와 쓰지 않을 때

핵심 질문 네 가지를 순서대로 던져보자:
- 진정한 is-a 관계인가? — "X는 Y의 한 종류다"가 영구적으로 성립하는가?
Dog가Animal인 것은 영원히 참이지만, 플레이어가 유닛인 것은 요구사항에 따라 달라질 수 있다. - LSP(리스코프 치환 원칙, 부모 타입을 사용하는 모든 곳에서 자식 타입을 넣어도 프로그램이 올바르게 동작하는 원칙)를 만족하는가? — 부모 자리에 자식을 넣었을 때 예상대로 동작하는가?
- 상속 계층이 얕은가? — 2단(부모-자식)까지는 괜찮다. 3단 이상이면 컴포지션을 고려해야 한다.
- 런타임에 행동을 교체할 필요가 없는가? — 무기 교체, 이동 방식 변경 등이 필요하면 컴포지션이 맞다.
Before — 상속으로 행동을 고정한 경우
적의 이동 방식을 상속으로 구현하면, 런타임에 이동 방식을 바꿀 수 없다.
public class Enemy
{
public virtual void Move() => Console.WriteLine("Walking");
}
public class FlyingEnemy : Enemy
{
public override void Move() => Console.WriteLine("Flying");
}
// 날아다니던 적이 날개가 부러져서 걸어야 한다면?
// FlyingEnemy를 삭제하고 Enemy를 새로 생성해야 한다 — 모든 상태(체력 등)를 잃는다
After — 컴포지션(Strategy 패턴)으로 행동을 교체 가능하게
Strategy 패턴 (전략 패턴) 행동(알고리즘)을 인터페이스로 캡슐화하고, 런타임에 구현체를 교체하여 객체의 동작을 동적으로 변경하는 디자인 패턴이다.
public interface IMovement
{
void Move();
}
public sealed class WalkMovement : IMovement
{
public void Move() => Console.WriteLine("Walking");
}
public sealed class FlyMovement : IMovement
{
public void Move() => Console.WriteLine("Flying");
}
public class Character
{
private IMovement _movement;
public Character(IMovement movement)
{
_movement = movement;
}
public void SetMovement(IMovement movement)
{
_movement = movement;
}
public void PerformMove()
{
_movement.Move();
}
}
// Character.PerformMove — 부품에 위임
IL_0001: ldarg.0
IL_0002: ldfld class IMovement Character::_movement // _movement 필드 로드
IL_0007: callvirt instance void IMovement::Move() // 인터페이스 통해 호출
// Main — 런타임에 행동 교체
IL_0001: newobj instance void WalkMovement::.ctor() // 걷기 행동 생성
IL_0006: newobj instance void Character::.ctor(class IMovement) // Character에 주입
IL_000b: stloc.0
IL_000c: ldloc.0
IL_000d: callvirt instance void Character::PerformMove() // Walking
IL_0014: newobj instance void FlyMovement::.ctor() // 비행 행동 생성
IL_0019: callvirt instance void Character::SetMovement(class IMovement) // 행동 교체!
IL_001f: ldloc.0
IL_0020: callvirt instance void Character::PerformMove() // Flying
SetMovement에서 stfld로 _movement 필드의 참조를 새 객체로 교체한다. 상속에서는 불가능한 런타임 행동 교체가 컴포지션에서는 필드 하나를 바꾸는 것만으로 가능하다. Unity에서 무기 교체, 이동 방식 변경, AI 상태 전환 등에 이 패턴이 널리 사용된다.
Unity 실전 — 컴포넌트 패턴
Unity의 GameObject + Component 시스템은 컴포지션의 교과서적 구현이다.
// Unity 방식 — 컴포넌트(부품)를 조립
public class PlayerMovement : MonoBehaviour
{
private Rigidbody _rb;
void Start()
{
_rb = GetComponent<Rigidbody>(); // 부품 조회 후 캐싱
}
void FixedUpdate()
{
float h = Input.GetAxis("Horizontal");
_rb.MovePosition(transform.position + new Vector3(h, 0, 0) * Time.fixedDeltaTime);
}
}
public class Health : MonoBehaviour
{
[SerializeField] private int _maxHp = 100;
private int _currentHp;
void Start() => _currentHp = _maxHp;
public void TakeDamage(int amount)
{
_currentHp -= amount;
if (_currentHp <= 0) Destroy(gameObject);
}
}
// Player에 PlayerMovement + Health 컴포넌트를 붙이면 이동 + 체력 완성
// 적에게도 Health만 붙이면 체력 시스템 재사용 완료
// 폭발 상자에도 Health만 붙이면 파괴 가능한 오브젝트 완성
MonoBehaviour를 상속하는 것은 프레임워크가 요구하는 상속이다 — Unity 엔진의 생명주기(Start, Update 등)에 연결되기 위해 반드시 필요하다. 하지만 게임 로직은 컴포넌트(부품)를 조립하는 컴포지션으로 구현한다.
GetComponent<T>()는 내부적으로 게임 오브젝트에 붙은 컴포넌트를 탐색하는 비용이 있으므로, Start()나 Awake()에서 한 번만 호출하고 결과를 캐싱해야 한다. Update()에서 매 프레임 호출하면 불필요한 오버헤드가 발생한다.
함정과 주의사항
함정 1 — 깨지기 쉬운 기반 클래스 문제 (Fragile Base Class)
상속의 가장 위험한 함정은 부모 클래스의 내부 구현에 자식이 의존하게 되는 것이다.
new— 메서드 숨기기 (Method hiding) 부모 클래스와 같은 이름의 메서드를 자식에서 새로 정의하지만,override와 달리 다형성이 동작하지 않는다. 부모 타입 변수로 호출하면 부모의 메서드가 실행된다.
예시:public new void Add(T item) { ... }List<T>타입으로 캐스팅하면 원래Add가 호출됨
// ❌ 상속 — 부모의 내부 구현에 의존
public class CountingList<T> : List<T>
{
public int AddCount { get; private set; }
public new void Add(T item)
{
AddCount++;
base.Add(item);
}
public new void AddRange(IEnumerable<T> items)
{
AddCount += items.Count();
base.AddRange(items); // List<T>.AddRange가 내부적으로 Add를 호출할까?
}
}
// CountingList.AddRange — 부모의 AddRange에 직접 위임
IL_0015: ldarg.0
IL_0016: ldarg.1
IL_0017: call instance void class [System.Collections]System.Collections.Generic.List`1<!T>::AddRange(...)
// 문제: List<T>.AddRange의 내부 구현이 this.Add를 호출하는지 알 수 없음
// 만약 호출한다면 AddCount가 중복 카운트됨!
base.AddRange가 내부적으로 Add를 호출하는지 여부는 .NET 런타임 버전에 따라 달라질 수 있다. 오늘 잘 동작하던 코드가 런타임 업데이트 후 깨질 수 있다 — 이것이 "깨지기 쉬운 기반 클래스 문제"다.
// ✅ 컴포지션 — 내부 구현에 의존하지 않음
public class SafeCountingList<T>
{
private readonly List<T> _inner = new();
public int AddCount { get; private set; }
public void Add(T item)
{
AddCount++;
_inner.Add(item);
}
public void AddRange(IEnumerable<T> items)
{
foreach (var item in items)
Add(item); // 항상 자신의 Add를 호출 — 예측 가능
}
public int Count => _inner.Count;
}
// SafeCountingList.AddRange — 자신의 Add를 반복 호출
IL_0012: ldarg.0
IL_0013: ldloc.1
IL_0014: call instance void class SafeCountingList`1<!T>::Add(!0) // 자신의 Add 호출
// List<T>의 내부 구현과 무관하게 항상 정확히 카운트됨
컴포지션 방식에서는 List<T>를 상속하지 않고 필드로 포함한다. _inner.Add만 호출하고 AddRange에서는 자신의 Add를 루프로 호출하므로, List<T>의 내부 구현이 바뀌어도 AddCount는 항상 정확하다.
함정 2 — Unity에서 깊은 상속 트리
// ❌ 깊은 상속 트리
public class Entity : MonoBehaviour { }
public class LivingEntity : Entity { } // 2단
public class Character : LivingEntity { } // 3단
public class Player : Character { } // 4단
public class Warrior : Player { } // 5단 — MonoBehaviour까지 6단!
// "바나나 하나를 원했는데 정글 전체를 얻게 되었다"
// Warrior에 작은 기능 하나를 추가하려면 5개 클래스를 모두 이해해야 한다
// ✅ 컴포넌트 조합
// Warrior 게임 오브젝트에 필요한 컴포넌트만 붙인다
// - Health.cs (체력)
// - MeleeAttack.cs (근접 공격)
// - PlayerInput.cs (입력 처리)
// - Movement.cs (이동)
// 각 컴포넌트는 독립적이므로, 하나를 수정해도 나머지에 영향 없음
Unity의 IL2CPP(Intermediate Language To C++, Unity가 IL을 C++ 코드로 변환하여 네이티브 빌드하는 AOT 컴파일러) AOT 컴파일에서 깊은 상속 트리는 vtable 크기를 키우고 코드 사이즈를 증가시킨다. 컴포넌트 조합 방식은 각 클래스가 독립적이므로 IL2CPP가 불필요한 가상 메서드 슬롯을 생성하지 않는다.
함정 3 — 상속 대신 인터페이스를 구현하고도 강한 결합을 만드는 실수
// ❌ 인터페이스를 쓰지만 concrete 타입에 직접 의존
public class GameManager : MonoBehaviour
{
private WalkMovement _movement = new(); // 구체 타입에 의존
void Update()
{
_movement.Move(); // WalkMovement를 FlyMovement로 교체할 수 없음
}
}
// ✅ 인터페이스 타입으로 선언하여 느슨한 결합
public class GameManager : MonoBehaviour
{
private IMovement _movement;
public void Init(IMovement movement)
{
_movement = movement; // 어떤 구현이든 주입 가능
}
void Update()
{
_movement.Move(); // 런타임에 교체 가능
}
}
컴포지션의 핵심은 인터페이스 타입으로 부품을 선언하는 것이다. 구체 타입으로 선언하면 컴포지션을 써도 상속과 같은 강한 결합이 된다.
C# 버전별 변화
C# 8.0 — 기본 인터페이스 메서드 (Default Interface Methods)
C# 8.0 이전에는 인터페이스에 기본 구현을 넣을 수 없어서, 공통 동작을 제공하려면 추상 클래스(상속)를 써야 했다.
// Before (C# 7.x) — 공통 동작을 위해 추상 클래스 상속 필요
public abstract class Loggable
{
public void Log(string message) => Console.WriteLine($"[{GetType().Name}] {message}");
}
// Player가 MonoBehaviour도 상속해야 하고, Loggable도 필요하다면?
// C#은 다중 상속을 지원하지 않으므로 불가능
// After (C# 8.0+) — 인터페이스에 기본 구현 제공
public interface ILoggable
{
void Log(string message) => Console.WriteLine($"[{GetType().Name}] {message}");
}
// 이제 MonoBehaviour를 상속하면서 ILoggable도 구현 가능
public class Player : MonoBehaviour, ILoggable, IDamageable
{
void Start()
{
((ILoggable)this).Log("Player spawned"); // 기본 구현 사용
}
public void TakeDamage(int amount) { /* ... */ }
}
주의할 점은 기본 인터페이스 메서드는 인터페이스 타입으로 캐스팅해야만 호출할 수 있다는 것이다. this.Log()는 컴파일 에러가 나고, ((ILoggable)this).Log()로 호출해야 한다. 또한 Unity의 현재 IL2CPP는 기본 인터페이스 메서드 지원이 제한적일 수 있으므로, 프로젝트의 Unity 버전과 스크립팅 백엔드를 확인해야 한다.
C# 12 — 기본 생성자 (Primary Constructors)
컴포지션에서 생성자를 통해 부품을 주입하는 코드를 간결하게 만들어 준다.
// Before (C# 11 이전) — 생성자 + 필드 선언이 필요
public class Character
{
private readonly IMovement _movement;
private readonly HealthSystem _health;
public Character(IMovement movement, HealthSystem health)
{
_movement = movement;
_health = health;
}
}
// After (C# 12) — 기본 생성자로 간결하게
public class Character(IMovement movement, HealthSystem health)
{
public void PerformMove() => movement.Move();
public void TakeDamage(int amount) => health.ApplyDamage(amount);
}
기본 생성자 매개변수는 클래스 전체에서 접근 가능하며, 컴파일러가 자동으로 필드를 생성한다. 컴포지션의 보일러플레이트(반복적으로 작성해야 하는 정형화된 코드)가 크게 줄어든다.
정리
- 상속은 "is-a" 관계가 영구적이고, LSP를 만족하며, 계층이 2단 이하이고, 런타임 행동 교체가 불필요할 때만 사용한다.
- 컴포지션은 기본값이다. 확신이 없으면 컴포지션을 선택한다. "클래스 상속보다 객체 컴포지션을 선호하라"는 GoF(Gang of Four, 디자인 패턴 창시자 4인)의 원칙을 기억한다.
- 상속은 컴파일 타임에 고정, 컴포지션은 런타임에 교체 가능하다. IL에서 상속은
call base로 부모에 직접 묶이고, 컴포지션은ldfld+callvirt로 부품에 위임한다. - sealed 클래스를 적극 활용한다. JIT가 devirtualize하여 인라인 최적화가 가능해진다.
- Unity에서는 MonoBehaviour 상속은 프레임워크 요구사항, 게임 로직은 컴포넌트 조합(컴포지션)으로 구현한다.
- 깊은 상속 트리(3단 이상)를 발견하면 리팩토링 신호다. 바나나를 원했는데 정글을 얻게 되는 상황을 방지한다.
- 컴포지션의 부품은 인터페이스 타입으로 선언한다. 구체 타입으로 선언하면 컴포지션을 써도 강한 결합이 된다.
'C# 심화' 카테고리의 다른 글
| [PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가 (0) | 2026.04.05 |
|---|---|
| [PART3.상속과 다형성(2/4)] virtual / override / new — 다형성의 실제 동작 (0) | 2026.04.05 |
| [PART2.클래스와 객체(7/7)] 연산자 오버로딩 — 사용자 정의 타입에 연산자를 부여하는 방법 (1) | 2026.04.05 |
| [PART2.클래스와 객체(6/7)] 접근 한정자 완전 정리 — public·internal·protected·private protected (0) | 2026.04.05 |
| [PART2.클래스와 객체(5/7)] object 클래스 — 모든 타입의 조상 (1) | 2026.04.05 |
