| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- Unity Editor
- job
- Job 시스템
- unity
- 샘플
- RSA
- adfit
- 가이드
- 2D Camera
- 오공완
- 암호화
- base64
- Framework
- TextMeshPro
- 환급챌린지
- 최적화
- Custom Package
- 패스트캠퍼스후기
- 직장인자기계발
- Tween
- 게임개발
- C#
- AES
- sha
- 직장인공부
- DotsTween
- ui
- 프레임워크
- Dots
- 패스트캠퍼스
- Today
- Total
EveryDay.DevUp
[PART4.인터페이스(2/3)] IEnumerable vs ICollection vs IList — 계층의 의미 본문
[PART4.인터페이스(2/3)] IEnumerable vs ICollection vs IList — 계층의 의미
EveryDay.DevUp 2026. 4. 5. 17:32IEnumerable vs ICollection vs IList — 계층의 의미
C#의 컬렉션 인터페이스는 왜 세 단계로 나뉘어 있을까? 각 계층이 약속하는 계약(Contract)을 이해하면, 매개변수와 반환 타입을 올바르게 선택할 수 있다.
1. 문제 제기
Unity 프로젝트에서 적 목록을 관리하는 메서드를 만든다고 하자.
public void ProcessEnemies(List<Enemy> enemies)
{
foreach (var enemy in enemies)
{
enemy.TakeDamage(10);
}
}
이 메서드는 List<Enemy>만 받을 수 있다. 배열(Enemy[])이나 LINQ 결과(IEnumerable<Enemy>)는 전달할 수 없다. 매개변수 타입을 IEnumerable<Enemy>로 바꾸면 모든 컬렉션을 받을 수 있는데, 왜 굳이 List<T>로 제한했을까?
반대로, 내부에서 관리하는 적 리스트를 외부에 반환할 때 IList<Enemy>를 쓰면 어떤 일이 생길까?
public class EnemyManager
{
private List<Enemy> _enemies = new List<Enemy>();
public IList<Enemy> GetEnemies() => _enemies;
}
// 외부 코드 — 내부 상태를 마음대로 조작
manager.GetEnemies().Clear(); // 적이 전부 사라진다!
이 두 가지 문제의 답은 컬렉션 인터페이스의 계층 구조에 있다. IEnumerable<T>, ICollection<T>, IList<T>가 각각 어떤 계약을 맺는지 알면, 매개변수는 가능한 한 약하게, 반환 타입은 필요한 만큼만 노출하는 원칙을 지킬 수 있다.
2. 개념 정의
세 인터페이스의 계층 관계
식당의 주방을 비유로 생각해 보자.
- IEnumerable<T> — 컨베이어 벨트. 음식이 하나씩 나오고, 다 나올 때까지 기다리기만 하면 된다. 몇 개인지 미리 알 수 없고, 특정 위치의 음식을 골라 집을 수도 없다.
- ICollection<T> — 주방 선반. 음식 총 몇 개인지 즉시 알 수 있고, 새 음식을 올리거나 뺄 수 있다. 하지만 "3번째 칸에 있는 것"을 바로 집을 수는 없다.
- IList<T> — 번호표가 붙은 선반. 몇 번째 칸에 뭐가 있는지 바로 알 수 있고, 특정 번호에 끼워 넣거나 빼는 것도 가능하다.

각 계층이 요구하는 멤버
| 인터페이스 | 추가되는 멤버 | 한 줄 요약 |
|---|---|---|
IEnumerable<T> |
GetEnumerator() |
"나를 순회할 수 있다" |
ICollection<T> |
Count, Add, Remove, Clear, Contains, CopyTo, IsReadOnly |
"크기를 알고, 항목을 추가/삭제할 수 있다" |
IList<T> |
this[int], IndexOf, Insert, RemoveAt |
"인덱스로 임의 접근할 수 있다" |
아래로 내려갈수록 기능이 많아지지만, 그만큼 구현체가 지켜야 할 약속도 많아진다. IEnumerable<T>를 구현하려면 GetEnumerator() 하나만 만들면 되지만, IList<T>를 구현하려면 인덱서, 삽입, 삭제까지 모두 구현해야 한다.
3. 내부 동작
IEnumerable<T>과 이터레이터 상태 머신
yield return— 이터레이터 생성 키워드 메서드 실행을 일시 중단하고 값을 하나 반환한다. 다음 호출 시 중단된 지점부터 실행을 재개한다. 컴파일러가 자동으로 상태 머신 클래스를 생성한다.
예시:yield return 1;값 1을 반환하고 메서드 실행을 일시 정지
IEnumerable<T>의 핵심은 지연 실행(Deferred Execution)이다. yield return을 사용하면 컴파일러가 상태 머신 클래스를 자동 생성하여, MoveNext() 호출마다 다음 yield return 지점까지만 실행한다.
using System;
using System.Collections.Generic;
public class YieldExample
{
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
}
public class Program
{
public static void Main()
{
foreach (var n in YieldExample.GetNumbers())
{
Console.WriteLine(n);
}
}
}
// YieldExample.GetNumbers() — 메서드 본체
.method public hidebysig static
class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32> GetNumbers () cil managed
{
IL_0000: ldc.i4.s -2 // 초기 상태값 -2
IL_0002: newobj instance void YieldExample/'<GetNumbers>d__0'::.ctor(int32) // 상태 머신 객체 생성
IL_0007: ret // 상태 머신을 반환 — 아직 아무것도 실행하지 않음
}
// 컴파일러가 생성한 상태 머신의 MoveNext()
.method private final hidebysig newslot virtual
instance bool MoveNext () cil managed
{
.locals init ([0] int32)
IL_0000: ldarg.0
IL_0001: ldfld int32 '<>1__state' // 현재 상태 읽기
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: switch (IL_001f, IL_0021, IL_0023, IL_0025) // 상태별 분기
IL_0029: ldarg.0
IL_002a: ldc.i4.m1
IL_002b: stfld int32 '<>1__state' // 상태를 -1(실행 중)로 전환
IL_0031: ldarg.0
IL_0032: ldc.i4.1
IL_0033: stfld int32 '<>2__current' // Current = 1 (첫 번째 yield return)
IL_0038: ldarg.0
IL_0039: ldc.i4.1
IL_003a: stfld int32 '<>1__state' // 상태를 1로 전환 (다음 MoveNext 시 두 번째 분기로)
IL_003f: ldc.i4.1
IL_0040: ret // true 반환 — 아직 요소가 있음
}
핵심을 정리하면:
GetNumbers()를 호출하면 실제 코드를 실행하지 않고,<GetNumbers>d__0라는 상태 머신 클래스의 인스턴스를 힙에 생성(newobj)하여 반환한다.foreach가MoveNext()를 호출할 때마다<>1__state필드를 읽고,switch문으로 이전yield return이후 지점으로 점프한다.<>2__current필드에 현재 값을 저장하고true를 반환한다. 더 이상 값이 없으면false를 반환한다.
이 구조 덕분에 데이터를 미리 메모리에 전부 올리지 않고, 필요할 때 하나씩 생성할 수 있다.
ICollection<T>.Count vs LINQ Count() — 타입 스니핑
LINQ의 Count() 확장 메서드는 내부적으로 타입 스니핑(Type Sniffing)을 수행한다. 전달받은 IEnumerable<T>가 실제로 ICollection<T>를 구현하고 있는지 런타임에 확인하여, 구현하고 있다면 O(1)인 Count 프로퍼티를 바로 사용한다.
using System;
using System.Collections.Generic;
using System.Linq;
public class LinqTypeSniffing
{
// LINQ Count() 확장 메서드 사용
public static int CountViaEnumerable(IEnumerable<int> items)
{
return items.Count();
}
// ICollection<T>.Count 프로퍼티 직접 사용
public static int CountViaCollection(ICollection<int> items)
{
return items.Count;
}
}
// CountViaEnumerable — LINQ Count() 확장 메서드 호출
.method public hidebysig static
int32 CountViaEnumerable (
class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32> items
) cil managed
{
IL_0001: ldarg.0
IL_0002: call int32 [System.Linq]System.Linq.Enumerable::Count<int32>(...) // 정적 메서드 호출
IL_000b: ret
}
// CountViaCollection — ICollection<T>.Count 프로퍼티 직접 호출
.method public hidebysig static
int32 CountViaCollection (
class [System.Runtime]System.Collections.Generic.ICollection`1<int32> items
) cil managed
{
IL_0001: ldarg.0
IL_0002: callvirt instance int32 class ICollection`1<int32>::get_Count() // 인터페이스 가상 호출
IL_000b: ret
}
CountViaEnumerable은 Enumerable.Count<int32>() 정적 메서드를 호출한다. 이 메서드 내부에서 ICollection<T>로의 캐스팅 시도가 일어난다. 반면 CountViaCollection은 ICollection<T>.get_Count()를 직접 callvirt로 호출하여 즉시 O(1)로 결과를 얻는다.
ElementAt()도 같은 원리다. 내부적으로 IList<T>인지 확인한 뒤, 맞으면 인덱서로 O(1) 접근하고, 아니면 처음부터 순회한다.
결론: 매개변수 타입이 ICollection<T>나 IList<T>이면, LINQ가 내부에서 하는 타입 스니핑 비용 없이 바로 최적 경로를 탈 수 있다.
foreach의 두 얼굴 — 인터페이스 디스패치 vs 구조체 열거자
같은 foreach라도 변수의 선언 타입에 따라 IL이 완전히 달라진다.
using System;
using System.Collections.Generic;
public class EnumerableForEach
{
// IEnumerable<T>로 받으면 — 인터페이스 가상 디스패치
public static int SumViaEnumerable(IEnumerable<int> items)
{
int sum = 0;
foreach (var item in items) { sum += item; }
return sum;
}
}
public class ListForEach
{
// List<T>로 받으면 — 구조체 열거자 직접 호출
public static int SumViaList(List<int> items)
{
int sum = 0;
foreach (var item in items) { sum += item; }
return sum;
}
}
// SumViaEnumerable — IEnumerable<int>로 순회
.method public hidebysig static
int32 SumViaEnumerable (
class IEnumerable`1<int32> items
) cil managed
{
.locals init (
[0] int32,
[1] class IEnumerator`1<int32>, // 참조 타입(인터페이스)으로 저장
[2] int32, [3] int32
)
IL_0004: ldarg.0
IL_0005: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator() // 가상 호출
IL_000a: stloc.1
// 루프 내부
IL_000d: ldloc.1
IL_000e: callvirt instance !0 IEnumerator`1<int32>::get_Current() // 매번 가상 디스패치
IL_001a: ldloc.1
IL_001b: callvirt instance bool IEnumerator::MoveNext() // 매번 가상 디스패치
}
// SumViaList — List<int>로 순회
.method public hidebysig static
int32 SumViaList (
class List`1<int32> items
) cil managed
{
.locals init (
[0] int32,
[1] valuetype List`1/Enumerator<int32>, // 값 타입(구조체)으로 저장 — 힙 할당 없음
[2] int32, [3] int32
)
IL_0004: ldarg.0
IL_0005: callvirt instance valuetype List`1/Enumerator<!0> List`1<int32>::GetEnumerator()
IL_000a: stloc.1
// 루프 내부
IL_000d: ldloca.s 1 // 주소를 로드 (값 타입이므로)
IL_000f: call instance !0 List`1/Enumerator<int32>::get_Current() // 직접 호출 (call)
IL_001b: ldloca.s 1
IL_001d: call instance bool List`1/Enumerator<int32>::MoveNext() // 직접 호출 (call)
}
차이가 극명하다:
| IEnumerable<T> | List<T> | |
|---|---|---|
| 열거자 타입 | class IEnumerator(참조 타입) |
valuetype List.Enumerator(구조체) |
| 호출 방식 | callvirt(가상 디스패치) |
call(직접 호출) |
| 힙 할당 | List<T>.Enumerator 구조체가 인터페이스로 박싱됨 |
스택에 저장, 힙 할당 없음 |
Unity의 Update() 같은 핫패스에서 매 프레임 수천 개의 요소를 순회한다면, 이 차이가 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 부담으로 누적된다.
4. 실전 적용
매개변수는 가능한 한 약한 타입으로 받기
메서드가 컬렉션을 순회만 한다면, IEnumerable<T>로 받는 것이 가장 유연하다.
// ❌ 나쁜 예: List<T>만 받을 수 있다
public void ApplyDamageToAll(List<Enemy> enemies)
{
foreach (var enemy in enemies) { enemy.TakeDamage(10); }
}
// ✅ 좋은 예: 배열, List, LINQ 결과 모두 받을 수 있다
public void ApplyDamageToAll(IEnumerable<Enemy> enemies)
{
foreach (var enemy in enemies) { enemy.TakeDamage(10); }
}
다만, 메서드 내부에서 Count가 필요하면 ICollection<T>, 인덱스 접근이 필요하면 IList<T>로 올린다. 필요한 최소한의 계약만 요구하는 것이 원칙이다.
반환 타입은 내부 상태를 보호할 만큼만 노출하기
using System;
using System.Collections.Generic;
public class EnemyManager
{
private List<string> enemies = new List<string>();
// ❌ 위험: 외부에서 내부 리스트를 직접 수정 가능
public IList<string> GetEnemiesDangerous()
{
return enemies;
}
// ⚠️ 느슨한 보호: IEnumerable로 반환하지만 캐스팅하면 뚫린다
public IEnumerable<string> GetEnemiesSafe()
{
return enemies;
}
// ✅ 확실한 보호: ReadOnlyCollection 래퍼
public IReadOnlyList<string> GetEnemiesReadOnly()
{
return enemies.AsReadOnly();
}
}
// GetEnemiesDangerous — List<string>을 IList<string>으로 반환
.method public hidebysig instance class IList`1<string> GetEnemiesDangerous ()
{
IL_0002: ldfld class List`1<string> EnemyManager::enemies // 내부 필드를 그대로 반환
IL_000b: ret
}
// GetEnemiesSafe — List<string>을 IEnumerable<string>으로 반환
.method public hidebysig instance class IEnumerable`1<string> GetEnemiesSafe ()
{
IL_0002: ldfld class List`1<string> EnemyManager::enemies // 역시 같은 참조를 반환
IL_000b: ret
}
// GetEnemiesReadOnly — AsReadOnly()로 래핑
.method public hidebysig instance class IReadOnlyList`1<string> GetEnemiesReadOnly ()
{
IL_0002: ldfld class List`1<string> EnemyManager::enemies
IL_0007: callvirt instance class ReadOnlyCollection`1<!0> List`1<string>::AsReadOnly() // 래퍼 객체 생성
IL_0010: ret
}
IL을 보면 GetEnemiesDangerous와 GetEnemiesSafe는 동일하게 내부 필드의 참조를 그대로 반환한다. IEnumerable<string>으로 반환해도 (IList<string>)manager.GetEnemiesSafe()로 캐스팅하면 Add, Remove가 가능하다. AsReadOnly()만이 ReadOnlyCollection<T> 래퍼 객체를 생성하여 진정한 읽기 전용 보호를 제공한다.
Unity 핫패스에서의 인터페이스 선택
using System;
using System.Collections.Generic;
using System.Linq;
// ❌ Before: Update에서 LINQ 사용 — 매 프레임 이터레이터 + 델리게이트 객체 생성
public class BadUpdate
{
private List<string> enemies = new List<string>();
public void Update()
{
var alive = enemies.Where(e => e != null);
foreach (var e in alive) { /* 처리 */ }
}
}
// ✅ After: for 루프 + 캐시된 리스트 — 힙 할당 없음
public class GoodUpdate
{
private List<string> enemies = new List<string>();
private List<string> aliveCache = new List<string>();
public void Update()
{
aliveCache.Clear();
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i] != null) { aliveCache.Add(enemies[i]); }
}
}
}
// BadUpdate.Update() — LINQ Where
IL_0002: ldfld class List`1<string> BadUpdate::enemies
IL_0007: ldsfld class Func`2<string, bool> '<>c'::'<>9__1_0' // 캐싱된 델리게이트 확인
IL_000c: dup
IL_000d: brtrue.s IL_0026 // 캐싱되어 있으면 스킵
IL_000f: pop
IL_0010: ldsfld class '<>c' '<>c'::'<>9'
IL_0015: ldftn instance bool '<>c'::'<Update>b__1_0'(string)
IL_001b: newobj instance void Func`2<string, bool>::.ctor(...) // 델리게이트 최초 1회 생성
IL_0026: call class IEnumerable`1<!!0> Enumerable::Where<string>(...) // Where 이터레이터 객체 생성
IL_002e: callvirt instance class IEnumerator`1<!0> IEnumerable`1<string>::GetEnumerator() // 열거자 생성
// GoodUpdate.Update() — for 루프
IL_0007: callvirt instance void List`1<string>::Clear() // 기존 캐시 비우기
IL_0019: callvirt instance !0 List`1<string>::get_Item(int32) // 인덱서 접근 O(1)
IL_0038: callvirt instance void List`1<string>::Add(!0) // 캐시에 추가
IL_004b: callvirt instance int32 List`1<string>::get_Count() // Count O(1) 확인
BadUpdate는 Where()가 호출될 때마다 내부적으로 WhereEnumerableIterator 객체를 힙에 생성한다. 델리게이트 자체는 정적 필드에 캐싱되지만(ldsfld), 이터레이터 객체와 GetEnumerator() 호출로 인한 열거자는 매 프레임 새로 만들어진다.
GoodUpdate는 List<T>의 Count 프로퍼티(O(1))와 인덱서(O(1))를 직접 사용하므로, 추가 힙 할당이 전혀 없다.
5. 함정과 주의사항
함정 1: IEnumerable<T>을 여러 번 순회하면 매번 다시 실행된다
// ❌ 잘못된 패턴: IEnumerable을 두 번 순회
public void ProcessTwice(IEnumerable<int> items)
{
Console.WriteLine($"총 {items.Count()}개"); // 첫 번째 전체 순회
foreach (var item in items) // 두 번째 전체 순회
{
Console.WriteLine(item);
}
}
items가 DB 쿼리나 yield return 기반 시퀀스라면, Count()에서 한 번, foreach에서 한 번, 총 두 번 실행된다. 네트워크 요청이면 두 번 호출되고, yield return이면 부작용이 두 번 발생할 수 있다.
// ✅ 올바른 패턴: 먼저 구체화한 뒤 재사용
public void ProcessTwice(IEnumerable<int> items)
{
var materialized = items.ToList(); // 한 번만 순회하여 메모리에 올림
Console.WriteLine($"총 {materialized.Count}개"); // O(1) — List<T>.Count 프로퍼티
foreach (var item in materialized)
{
Console.WriteLine(item);
}
}
함정 2: 배열의 IList<T> 구현은 불완전하다
T[](배열)은 IList<T>를 구현하지만, 크기가 고정이므로 Add, Remove, Insert를 호출하면 NotSupportedException이 발생한다.
// ❌ 런타임 예외 발생
IList<int> list = new int[] { 1, 2, 3 };
list.Add(4); // NotSupportedException!
// ✅ 배열인지 확인하거나, IsReadOnly 체크
if (!list.IsReadOnly)
{
list.Add(4);
}
이런 이유로 매개변수에서 "읽기만 할 것"이라면 IList<T> 대신 IReadOnlyList<T>를 사용하는 것이 의도를 명확히 전달한다.
함정 3: Unity 코루틴의 IEnumerator와 IEnumerable<T>은 다르다
Unity의 코루틴은 IEnumerator(비제네릭)를 반환한다. 이것은 IEnumerable<T> 계열이 아니라, 열거자(IEnumerator) 자체다.
// Unity 코루틴 — IEnumerator 반환
IEnumerator SpawnWave()
{
for (int i = 0; i < 10; i++)
{
Instantiate(enemyPrefab, GetSpawnPoint(), Quaternion.identity);
yield return new WaitForSeconds(0.5f);
}
}
코루틴이 IEnumerator를 반환하는 이유는 Unity 엔진이 직접 MoveNext()를 호출하여 프레임 단위로 실행을 제어하기 때문이다. IEnumerable<T>처럼 foreach로 순회하는 것이 아니라, Unity 런타임이 매 프레임 MoveNext()를 호출하고 Current에 담긴 YieldInstruction(WaitForSeconds, WaitForEndOfFrame 등)을 해석하여 다음 실행 시점을 결정한다.
6. C# 버전별 변화
C# 2.0 — 제네릭 인터페이스 도입
C# 1.0의 IEnumerable, ICollection, IList는 모두 비제네릭이었다. object 타입으로 요소를 주고받아야 했기 때문에 값 타입에서 박싱이 발생했다.
// C# 1.0 — 비제네릭 IList
System.Collections.IList list = new System.Collections.ArrayList();
list.Add(42); // int → object 박싱
int value = (int)list[0]; // object → int 언박싱
// C# 2.0+ — 제네릭 IList<T>
IList<int> list = new List<int>();
list.Add(42); // 박싱 없음
int value = list[0]; // 캐스팅 불필요
C# 2.0 — yield return과 이터레이터
yield return이 도입되면서 IEnumerable<T>의 지연 실행이 언어 차원에서 지원되었다. 상태 머신을 직접 구현할 필요가 없어졌다.
C# 3.0 — LINQ와 IEnumerable<T>의 전성기
LINQ가 도입되면서 IEnumerable<T>가 컬렉션 조작의 중심이 되었다. Where, Select, OrderBy 등 모든 LINQ 확장 메서드가 IEnumerable<T>를 기반으로 동작한다.
.NET 4.5 — IReadOnlyCollection<T>과 IReadOnlyList<T>
// .NET 4.5 이전 — 읽기 전용 의도를 표현하기 어려움
public IEnumerable<Enemy> GetEnemies() => _enemies;
// 문제: 호출자가 캐스팅하면 수정 가능
// .NET 4.5 이후 — 읽기 전용 인터페이스 등장
public IReadOnlyList<Enemy> GetEnemies() => _enemies.AsReadOnly();
// Count와 인덱서는 사용 가능하되, Add/Remove는 불가능
IReadOnlyCollection<T>은 Count + GetEnumerator()만, IReadOnlyList<T>는 여기에 읽기 전용 인덱서를 추가한다. 반환 타입에서 "이 컬렉션은 수정하지 마라"는 의도를 명확히 전달할 수 있게 되었다.
7. 정리
- IEnumerable<T>: "순회만 가능" — 가장 약한 계약. 매개변수의 기본 선택지. 지연 실행을 지원하며,
yield return으로 상태 머신을 자동 생성한다. - ICollection<T>: "크기를 알고, 추가/삭제 가능" —
CountO(1)이 필요하면 이 계층. - IList<T>: "인덱스 임의 접근 가능" — 인덱서가 필요할 때만 사용. 기능이 가장 많지만 구현 부담도 가장 크다.
체크리스트
- [ ] 매개변수 타입은 메서드가 실제로 필요한 최소 계층인가? (순회만 한다면
IEnumerable<T>) - [ ] 반환 타입이 내부 상태를 불필요하게 노출하지 않는가? (
IReadOnlyList<T>또는AsReadOnly()고려) - [ ]
IEnumerable<T>을 여러 번 순회하고 있지 않은가? (필요하면.ToList()로 구체화) - [ ] Unity 핫패스(
Update,FixedUpdate)에서 LINQ를 사용하고 있지 않은가? (for 루프 + 캐시된 리스트로 대체) - [ ]
List<T>타입으로 직접foreach를 돌리고 있는가? (인터페이스 타입이면 박싱 발생 가능) - [ ] 배열을
IList<T>로 받은 뒤Add/Remove를 호출하지 않는가? (NotSupportedException주의)
'C# 심화' 카테고리의 다른 글
| [PART5.구조체와 레코드(1/4)] struct vs class — 무엇을 언제 선택하는가 (0) | 2026.04.05 |
|---|---|
| [PART4.인터페이스(3/3)] IComparable<T> vs IComparer<T> — 정렬 기준은 누가 정하는가 (0) | 2026.04.05 |
| [PART4.인터페이스(1/3)] interface — 계약으로서의 인터페이스 (0) | 2026.04.05 |
| [PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법 (0) | 2026.04.05 |
| [PART3.상속과 다형성(3/4)] abstract class vs interface — 언제 무엇을 선택하는가 (0) | 2026.04.05 |
