| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- ui
- AES
- C#
- 프레임워크
- Dots
- unity
- adfit
- 오공완
- job
- 패스트캠퍼스
- 최적화
- RSA
- 환급챌린지
- 직장인자기계발
- 샘플
- 2D Camera
- Job 시스템
- 가이드
- 패스트캠퍼스후기
- Framework
- base64
- 암호화
- Unity Editor
- DotsTween
- sha
- Custom Package
- Tween
- 직장인공부
- TextMeshPro
- 게임개발
- Today
- Total
EveryDay.DevUp
[PART4.인터페이스(3/3)] IComparable<T> vs IComparer<T> — 정렬 기준은 누가 정하는가 본문
[PART4.인터페이스(3/3)] IComparable<T> vs IComparer<T> — 정렬 기준은 누가 정하는가
EveryDay.DevUp 2026. 4. 5. 18:02IComparable<T> vs IComparer<T> — 정렬 기준은 누가 정하는가
Unity 모바일 게임에서 인벤토리를 정렬하거나, 적을 거리순으로 타겟팅하거나, 랭킹 보드를 갱신할 때 — 모두 "두 객체 중 누가 먼저인가?"라는 질문에 답해야 한다. C#에서 이 질문에 답하는 방법은 두 가지다.
목차
문제 제기
정렬이 필요한 순간, 기준은 어디에 있는가?
Unity에서 적 수십 마리를 거리순으로 정렬해야 한다고 하자. List<T>.Sort()를 호출하면 정렬은 된다. 그런데 "거리"라는 기준은 누가, 어디에 정의하는가?
public struct Enemy
{
public float Distance;
public int Hp;
public string Name;
}
var enemies = new List<Enemy>();
enemies.Sort(); // InvalidOperationException — 정렬 기준을 모른다!
Sort()는 요소끼리 비교할 방법이 없으면 예외를 던진다. 정렬 알고리즘은 "누가 더 작은가?"를 알아야 위치를 결정할 수 있기 때문이다.
C#은 이 문제를 두 가지 인터페이스로 해결한다.
IComparable<T>— 객체 스스로 "나는 상대보다 크다/작다"를 판단한다.IComparer<T>— 외부의 제3자가 "이 둘 중 누가 먼저인가"를 판단한다.
핵심 질문은 하나다: 정렬 기준은 객체 안에 있는가, 바깥에 있는가?
IComparable<T> — "내가 직접 판단한다"
개념 정의
학교 시험을 생각해 보자. 학생마다 "내 점수는 몇 점"이라는 정보를 스스로 갖고 있다. 선생님이 "점수순으로 줄 서라"고 하면, 각 학생은 옆 학생과 자기 점수를 직접 비교해서 자리를 잡는다. 이것이 IComparable<T>의 동작 방식이다 — 타입 자체에 "자연스러운 정렬 기준(natural ordering)"을 내장하는 것이다.

IComparable<T>— 비교 가능 인터페이스 (Comparable interface) 타입 T가 직접 구현하는 인터페이스로,int CompareTo(T other)메서드 하나를 요구한다.this가other보다 작으면 음수, 같으면 0, 크면 양수를 반환한다. 이 타입의 "기본 정렬 기준"을 정의하는 데 사용한다.
public struct EnemyData : IComparable<EnemyData>
{
public float Distance;
public int Hp;
// 기본 정렬: 거리순 (가까운 적이 먼저)
public int CompareTo(EnemyData other)
{
return Distance.CompareTo(other.Distance);
}
}
.class public sequential ansi sealed beforefieldinit EnemyData
extends [System.Runtime]System.ValueType
implements class [System.Runtime]System.IComparable`1<valuetype EnemyData> // 제네릭 인터페이스 구현
{
.field public float32 Distance
.field public int32 Hp
.method public final hidebysig newslot virtual
instance int32 CompareTo (
valuetype EnemyData other // 매개변수가 valuetype — 박싱 없음
) cil managed
{
.locals init (
[0] int32
)
IL_0001: ldarg.0 // this의 주소를 로드
IL_0002: ldflda float32 EnemyData::Distance // this.Distance의 주소
IL_0007: ldarg.1 // other를 값 복사로 로드 (박싱 없음)
IL_0008: ldfld float32 EnemyData::Distance // other.Distance 값
IL_000d: call instance int32 [System.Runtime]System.Single::CompareTo(float32) // float 비교
IL_0012: stloc.0
IL_0015: ldloc.0
IL_0016: ret
}
}
IL에서 주목할 점은 CompareTo의 매개변수가 valuetype EnemyData라는 것이다. object가 아닌 구체적인 값 타입을 직접 받으므로 box 명령어가 전혀 없다. ldarg.1로 스택에 값을 직접 로드하고, ldfld로 필드를 바로 꺼낸다.
List<T>.Sort()를 인자 없이 호출하면, 내부적으로 Comparer<T>.Default를 사용한다. 이 기본 비교자는 T가 IComparable<T>를 구현했는지 확인하고, 구현했다면 CompareTo를 호출한다.
var list = new List<EnemyData>();
// ... 요소 추가
list.Sort(); // → Comparer<EnemyData>.Default → EnemyData.CompareTo() 호출
// Main 메서드 내부
IL_0007: ldloc.0
IL_0008: callvirt instance void class [System.Collections]System.Collections.Generic.List`1<valuetype EnemyData>::Sort()
// Sort() 내부에서 Comparer<EnemyData>.Default를 사용 → CompareTo 호출
핵심: IComparable<T>를 구현하면 그 타입의 "기본 정렬 기준"이 정해진다. Sort() 한 줄이면 정렬이 끝난다.
내부 동작
List<T>.Sort()를 호출하면 런타임에서 어떤 일이 벌어지는지 따라가 보자.

Comparer<T>.Default의 동작 순서:
- T가
IComparable<T>를 구현했으면 →GenericComparer<T>를 반환한다. 이 비교자는 내부에서a.CompareTo(b)를 호출한다. - T가
Nullable<T>이면 →NullableComparer<T>를 반환한다. - 둘 다 아니면 →
InvalidOperationException을 던진다.
.NET의 정렬 알고리즘은 IntroSort(Introspective Sort)를 사용한다. 퀵소트로 시작하되, 재귀 깊이가 일정 수준을 넘으면 힙소트로 전환하고, 요소 수가 적으면 삽입 정렬을 사용하는 혼합 알고리즘이다. 평균 시간복잡도는 O(N log N)이다.
IComparer<T> — "외부에서 판단한다"
개념 정의
이번에는 축구 심판을 생각해 보자. 선수들은 자기 실력을 스스로 평가하지 않는다. 심판이 "이 선수가 저 선수보다 반칙이 많다"고 판정한다. 그런데 같은 두 선수를 다른 심판이 보면 "이 선수가 저 선수보다 골이 많다"고 판정할 수도 있다. 같은 데이터를 다른 기준으로 비교하는 것 — 이것이 IComparer<T>의 핵심이다.

IComparer<T>— 비교자 인터페이스 (Comparer interface) 별도의 클래스에 구현하는 인터페이스로,int Compare(T x, T y)메서드 하나를 요구한다. x가 y보다 작으면 음수, 같으면 0, 크면 양수를 반환한다. 데이터 클래스를 수정하지 않고도 다양한 정렬 기준을 제공할 수 있다.
public struct Enemy
{
public float Distance;
public int Hp;
public string Name;
}
// 거리순 비교자
public class DistanceComparer : IComparer<Enemy>
{
public int Compare(Enemy x, Enemy y)
{
return x.Distance.CompareTo(y.Distance);
}
}
// 체력순 비교자
public class HpComparer : IComparer<Enemy>
{
public int Compare(Enemy x, Enemy y)
{
return x.Hp.CompareTo(y.Hp);
}
}
.class public auto ansi beforefieldinit DistanceComparer
extends [System.Runtime]System.Object
implements class [System.Runtime]System.Collections.Generic.IComparer`1<valuetype Enemy> // IComparer<Enemy>
{
.method public final hidebysig newslot virtual
instance int32 Compare (
valuetype Enemy x, // 제네릭 — 값 타입 직접 전달
valuetype Enemy y
) cil managed
{
IL_0001: ldarga.s x // x의 주소 로드
IL_0003: ldflda float32 Enemy::Distance // x.Distance 주소
IL_0008: ldarg.2 // y를 값으로 로드
IL_0009: ldfld float32 Enemy::Distance // y.Distance 값
IL_000e: call instance int32 [System.Runtime]System.Single::CompareTo(float32)
IL_0013: stloc.0
IL_0016: ldloc.0
IL_0017: ret
}
}
Compare 메서드의 매개변수가 valuetype Enemy로 선언되어 있다. object가 아니므로 box 명령어가 없다. ldarga.s x로 첫 번째 인자의 주소를 로드하고, ldflda로 필드 주소에 직접 접근한다.
사용법은 Sort()에 비교자 인스턴스를 전달하는 것이다:
var enemies = new List<Enemy> { /* ... */ };
// 거리순 정렬
enemies.Sort(new DistanceComparer());
// 체력순 정렬
enemies.Sort(new HpComparer());
// Main 메서드 내부
IL_009b: newobj instance void DistanceComparer::.ctor() // 비교자 인스턴스 생성 (힙 할당)
IL_00a0: callvirt instance void class List`1<valuetype Enemy>::Sort(class IComparer`1<!0>)
IL_00a7: newobj instance void HpComparer::.ctor() // 다른 비교자로 교체
IL_00ac: callvirt instance void class List`1<valuetype Enemy>::Sort(class IComparer`1<!0>)
newobj로 비교자 인스턴스를 생성하고, Sort(IComparer<T>) 오버로드에 전달한다. 비교자를 교체하면 정렬 기준이 바뀐다 — 데이터 구조인 Enemy는 전혀 수정하지 않는다.
Comparison<T> 델리게이트와 Comparer<T>.Create
매번 별도의 클래스를 만드는 것이 번거로울 수 있다. C#은 두 가지 간편한 방법을 제공한다.
Comparison<T>— 비교 델리게이트delegate int Comparison<in T>(T x, T y)형태의 델리게이트다.IComparer<T>를 클래스로 만들지 않고도 람다식으로 비교 로직을 직접 전달할 수 있다.
var enemies = new List<Enemy>();
// 1) Comparison<T> 델리게이트 — 람다식으로 직접 전달
enemies.Sort((a, b) => a.Hp.CompareTo(b.Hp));
// 2) Comparer<T>.Create — IComparer<T> 인스턴스가 필요할 때
var hpComparer = Comparer<Enemy>.Create((a, b) => a.Hp.CompareTo(b.Hp));
enemies.Sort(hpComparer);
// 람다식 Comparison<T> 전달
IL_000f: ldsfld class Comparison`1<valuetype EnemyData> Program/'<>c'::'<>9__0_0' // 캐시된 델리게이트 확인
IL_0014: dup
IL_0015: brtrue.s IL_002e // 이미 캐시됐으면 재사용
IL_0017: pop
IL_0018: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
IL_001d: ldftn instance int32 Program/'<>c'::'<Main>b__0_0'(valuetype EnemyData, valuetype EnemyData) // 람다 메서드
IL_0023: newobj instance void class Comparison`1<valuetype EnemyData>::.ctor(object, native int) // 첫 호출 시만 생성
IL_0028: dup
IL_0029: stsfld class Comparison`1<valuetype EnemyData> Program/'<>c'::'<>9__0_0' // 캐시에 저장
IL_002e: callvirt instance void class List`1<valuetype EnemyData>::Sort(class Comparison`1<!0>) // 정렬 실행
IL에서 중요한 패턴: 변수를 캡처하지 않는 람다는 컴파일러가 <>c 싱글턴 클래스에 메서드를 생성하고, 델리게이트를 정적 필드(<>9__0_0)에 캐시한다. brtrue.s IL_002e로 캐시 여부를 확인하고, 이미 있으면 newobj를 건너뛴다. 즉, 변수를 캡처하지 않는 람다 정렬은 반복 호출해도 힙 할당이 1회만 발생한다.
실전 적용
Before/After — Unity 인벤토리 정렬
게임에서 인벤토리 아이템을 다양한 기준으로 정렬해야 한다. 먼저 잘못된 접근을 보자.
Before — 정렬 기준을 if/else로 분기
// ❌ 정렬 기준이 추가될 때마다 코드가 비대해진다
public class Inventory
{
private List<Item> items = new();
public void Sort(string criteria)
{
if (criteria == "grade")
items.Sort((a, b) => b.Grade.CompareTo(a.Grade));
else if (criteria == "weight")
items.Sort((a, b) => a.Weight.CompareTo(b.Weight));
else if (criteria == "name")
items.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
// 기준이 추가될 때마다 else if가 늘어난다...
}
}
After — IComparable<T> + IComparer<T> 조합
// ✅ 기본 정렬은 타입에, 추가 기준은 비교자로 분리
public struct Item : IComparable<Item>
{
public string Name;
public int Grade; // 등급 (1=일반, 5=전설)
public float Weight;
// 기본 정렬: 등급 내림차순
public int CompareTo(Item other)
{
return other.Grade.CompareTo(Grade);
}
}
// 무게순 비교자
public struct WeightComparer : IComparer<Item>
{
public int Compare(Item x, Item y)
{
return x.Weight.CompareTo(y.Weight);
}
}
// Item.CompareTo — 등급 내림차순
.method public final hidebysig newslot virtual
instance int32 CompareTo (
valuetype Item other // 값 타입 직접 전달 — 박싱 없음
) cil managed
{
IL_0001: ldarga.s other // other의 주소
IL_0003: ldflda int32 Item::Grade // other.Grade 주소
IL_0008: ldarg.0 // this
IL_0009: ldfld int32 Item::Grade // this.Grade (내림차순이므로 순서가 반대)
IL_000e: call instance int32 [System.Runtime]System.Int32::CompareTo(int32)
IL_0013: stloc.0
IL_0016: ldloc.0
IL_0017: ret
}
내림차순을 구현할 때 other.Grade.CompareTo(Grade)로 순서를 뒤집는 것에 주목하자. IL에서 ldarga.s other → ldflda ... Grade가 먼저 오고, 그 다음 ldarg.0 → ldfld ... Grade가 온다. 일반적인 오름차순과 인자 순서가 반대다.
var inventory = new List<Item> { /* ... */ };
// 기본 정렬 — 등급 내림차순
inventory.Sort();
// 무게순 정렬 — 비교자 교체
inventory.Sort(new WeightComparer());
// 기본 정렬
IL_0097: ldloc.0
IL_0098: callvirt instance void class List`1<valuetype Item>::Sort() // IComparable<Item>.CompareTo 호출
// 무게순 정렬
IL_009f: ldloca.s 2
IL_00a1: initobj WeightComparer // struct 비교자 초기화 (스택)
IL_00a7: ldloc.2
IL_00a8: box WeightComparer // ⚠️ IComparer<T> 인터페이스로 전달 시 박싱!
IL_00ad: callvirt instance void class List`1<valuetype Item>::Sort(class IComparer`1<!0>)
여기서 주의할 점이 있다. WeightComparer를 struct로 만들어도 Sort(IComparer<T>)에 전달하는 순간 box WeightComparer가 발생한다. Sort 메서드가 인터페이스 타입의 매개변수를 받기 때문에 struct가 인터페이스 참조로 변환되면서 박싱이 일어나는 것이다. 이 문제는 [함정과 주의사항] 섹션에서 자세히 다룬다.
함정과 주의사항
함정 1: struct IComparer를 만들어도 박싱이 발생한다
"비교자를 struct로 만들면 힙 할당을 피할 수 있지 않을까?" 자연스러운 생각이지만, List<T>.Sort(IComparer<T>)는 매개변수 타입이 인터페이스다. struct를 인터페이스 참조로 넘기면 박싱이 발생한다.
// ❌ struct 비교자를 Sort에 전달 — 박싱 발생
public struct WeightComparer : IComparer<Item>
{
public int Compare(Item x, Item y)
{
return x.Weight.CompareTo(y.Weight);
}
}
inventory.Sort(new WeightComparer()); // box WeightComparer 발생!
IL_009f: ldloca.s 2
IL_00a1: initobj WeightComparer
IL_00a7: ldloc.2
IL_00a8: box WeightComparer // 박싱! — 힙에 비교자 복사본 생성
IL_00ad: callvirt instance void class List`1<valuetype Item>::Sort(class IComparer`1<!0>)
box WeightComparer가 명확히 보인다. 비교자 자체가 힙에 복사되는 것이므로, 정렬 한 번당 1회의 힙 할당이 추가로 발생한다. 비교자 크기가 작고 정렬 횟수가 적으면 무시할 수준이지만, Unity의 Update() 루프에서 매 프레임 정렬하면 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 압박이 된다.
// ✅ 해결 방법 1: Comparison<T> 델리게이트 사용 (캐시됨)
inventory.Sort((a, b) => a.Weight.CompareTo(b.Weight));
// ✅ 해결 방법 2: class 비교자를 static readonly로 캐시
public class WeightComparerClass : IComparer<Item>
{
public static readonly WeightComparerClass Instance = new();
public int Compare(Item x, Item y) => x.Weight.CompareTo(y.Weight);
}
inventory.Sort(WeightComparerClass.Instance); // 인스턴스 재사용 — newobj 없음
함정 2: CompareTo와 Equals의 불일치
IComparable<T>를 구현할 때 가장 위험한 함정은 CompareTo와 Equals의 기준이 다른 것이다. SortedSet<T>와 SortedDictionary<K,V>는 내부적으로 CompareTo의 결과가 0이면 "같은 요소"로 판단한다. Equals를 호출하지 않는다.
// ❌ CompareTo는 Score 기준, Equals는 Name 기준 — 불일치!
public class Player : IComparable<Player>
{
public string Name;
public int Score;
public int CompareTo(Player? other)
{
if (other is null) return 1;
return Score.CompareTo(other.Score); // Score 기준
}
public override bool Equals(object? obj)
{
if (obj is Player p) return Name == p.Name; // Name 기준 — 불일치!
return false;
}
public override int GetHashCode() => Name?.GetHashCode() ?? 0;
}
// CompareTo — Score 기준으로 비교
.method public final hidebysig newslot virtual
instance int32 CompareTo (class Player other) cil managed
{
IL_000d: ldarg.0
IL_000e: ldflda int32 Player::Score // this.Score
IL_0013: ldarg.1
IL_0014: ldfld int32 Player::Score // other.Score
IL_0019: call instance int32 [System.Runtime]System.Int32::CompareTo(int32)
IL_001e: stloc.1
IL_0021: ldloc.1
IL_0022: ret
}
// Equals — Name 기준으로 비교
.method public hidebysig virtual
instance bool Equals (object obj) cil managed
{
IL_0010: ldarg.0
IL_0011: ldfld string Player::Name // this.Name
IL_0016: ldloc.0
IL_0017: ldfld string Player::Name // other.Name
IL_001c: call bool [System.Runtime]System.String::op_Equality(string, string) // == 연산자
IL_0021: stloc.2
IL_0028: ldloc.2
IL_0029: ret
}
IL에서 CompareTo는 Player::Score를, Equals는 Player::Name을 사용한다. 두 메서드가 서로 다른 필드를 기준으로 판단한다.
var players = new SortedSet<Player>
{
new Player { Name = "Alice", Score = 100 },
new Player { Name = "Bob", Score = 100 } // Score가 같다
};
Console.WriteLine(players.Count); // 1 — Bob이 사라졌다!
SortedSet은 레드-블랙 트리를 사용하며, 삽입 시 CompareTo로 위치를 결정한다. "Alice"와 "Bob"의 Score가 같아서 CompareTo가 0을 반환하면, SortedSet은 "이미 있는 요소"로 판단하고 "Bob"을 무시한다. Equals는 호출하지도 않는다.
// ✅ CompareTo와 Equals를 일관되게 유지한다
public class Player : IComparable<Player>
{
public string Name;
public int Score;
public int CompareTo(Player? other)
{
if (other is null) return 1;
int result = Score.CompareTo(other.Score);
if (result != 0) return result;
return string.Compare(Name, other.Name, StringComparison.Ordinal); // 동점 시 이름으로 구분
}
public override bool Equals(object? obj)
{
if (obj is Player p)
return Score == p.Score && Name == p.Name; // 같은 기준
return false;
}
public override int GetHashCode() => HashCode.Combine(Score, Name);
}
핵심: CompareTo가 0을 반환하면 "같은 값"으로 취급하는 컬렉션이 있다. CompareTo와 Equals의 기준을 반드시 일치시켜야 한다.
함정 3: Unity Update에서 매 프레임 비교자 생성
// ❌ 매 프레임 비교자 인스턴스 생성 — GC 스파이크 유발
void Update()
{
enemies.Sort(new DistanceComparer()); // newobj 매 프레임 발생
}
// ✅ 비교자를 필드에 캐시하거나 Comparison<T> 델리게이트 사용
private static readonly DistanceComparer distComparer = new();
void Update()
{
enemies.Sort(distComparer); // 인스턴스 재사용
}
new DistanceComparer()는 IL에서 newobj로 변환되며, 매 프레임 호출되면 초당 60회의 힙 할당이 발생한다. 비교자는 상태가 없는 경우가 대부분이므로 static readonly 필드에 한 번만 생성하여 재사용하면 된다.
C# 버전별 변화
.NET 1.0 — 비제네릭 IComparable, IComparer
.NET 1.x에서는 IComparable과 IComparer — 제네릭이 없는 버전만 존재했다. 이 인터페이스들은 매개변수로 object를 받기 때문에, 값 타입(struct)을 전달하면 박싱(boxing, 값 타입을 힙의 object 참조로 포장하는 과정)이 발생한다.

// ❌ 비제네릭 IComparable — struct에서 박싱 발생
public struct DamageInfo : IComparable
{
public int Amount;
public int CompareTo(object? obj)
{
if (obj is DamageInfo other)
return Amount.CompareTo(other.Amount);
throw new ArgumentException("Type mismatch");
}
}
// 직접 비교
var a = new DamageInfo { Amount = 50 };
var b = new DamageInfo { Amount = 30 };
IComparable comparable = a; // 박싱 1회
int result = comparable.CompareTo(b); // b도 박싱 — 총 2회
.method public hidebysig static
void SortDamage () cil managed
{
IL_0029: ldloc.0
IL_002a: box DamageInfo // a를 IComparable에 대입 — 박싱 1회 (힙 할당)
IL_002f: stloc.2
IL_0030: ldloc.2
IL_0031: ldloc.1
IL_0032: box DamageInfo // b를 CompareTo(object)에 전달 — 박싱 2회 (힙 할당)
IL_0037: callvirt instance int32 [System.Runtime]System.IComparable::CompareTo(object) // 가상 호출
IL_003c: stloc.3
IL_003d: ret
}
box DamageInfo가 2번 등장한다. 하나는 IComparable 변수에 대입할 때, 다른 하나는 CompareTo(object)의 인자로 전달할 때 발생한다. N개의 요소를 정렬하면 O(N log N)회의 비교가 일어나고, 매 비교마다 박싱이 발생한다. 1000개 요소 기준으로 약 10,000회의 박싱 — 20,000바이트 이상의 불필요한 힙 할당이 생긴다.
비제네릭 CompareTo 내부의 IL도 확인하자:
// 비제네릭 CompareTo 내부 — 타입 검사 + 언박싱 필요
.method public final hidebysig newslot virtual
instance int32 CompareTo (object obj) cil managed // object를 받는다
{
IL_0001: ldarg.1
IL_0002: isinst DamageInfo // obj가 DamageInfo인지 런타임 타입 검사
IL_0007: brfalse.s IL_0013
IL_0009: ldarg.1
IL_000a: unbox.any DamageInfo // obj → DamageInfo 언박싱 (값 복사)
IL_000f: stloc.0
IL_0018: ldarg.0
IL_0019: ldflda int32 DamageInfo::Amount
IL_001e: ldloc.0
IL_001f: ldfld int32 DamageInfo::Amount
IL_0024: call instance int32 System.Int32::CompareTo(int32)
IL_0029: stloc.2
IL_0037: ldloc.2
IL_0038: ret
}
isinst로 런타임 타입 검사를 하고, unbox.any로 값을 꺼내야 한다.
.NET 2.0 (C# 2.0) — 제네릭 IComparable<T>, IComparer<T> 도입
제네릭 버전이 도입되면서 컴파일 타임에 타입이 확정되고, isinst와 unbox.any가 모두 사라진다.
// ✅ C# 2.0 — 제네릭으로 박싱 완전 제거
public struct DamageInfo : IComparable<DamageInfo>
{
public int Amount;
public int CompareTo(DamageInfo other)
{
return Amount.CompareTo(other.Amount);
}
}
이 변화로 값 타입의 정렬 성능이 극적으로 향상됐다. 박싱과 타입 검사가 모두 사라졌기 때문이다. Unity에서 struct를 적극 활용하는 환경이라면 이 차이는 프레임레이트에 직접 영향을 준다.
.NET 3.5 (C# 3.0) — LINQ OrderBy
// C# 3.0 — LINQ로 선언적 정렬
var sorted = enemies.OrderBy(e => e.Distance)
.ThenBy(e => e.Hp)
.ToList();
OrderBy는 내부적으로 IComparer<T>를 생성하여 사용한다. 가독성이 좋지만, LINQ 체인은 중간 IEnumerable 객체를 생성하므로 Unity 핫패스(hot path, 매 프레임 반복 실행되는 경로)에서는 List<T>.Sort()가 더 적합하다.
C# 4.0 (.NET 4.0) — 반공변성(Contravariance) 지원
// IComparer<in T> — in 키워드로 반공변성 선언
// 상위 타입의 비교자를 하위 타입 컬렉션에 사용 가능
IComparer<Shape> shapeComparer = new AreaComparer();
List<Circle> circles = new List<Circle>();
circles.Sort(shapeComparer); // IComparer<Shape> → IComparer<Circle> 암시적 변환
in 키워드는 "T가 입력(매개변수)으로만 사용된다"는 의미다. Shape의 비교자가 Circle을 비교해도 안전하다 — Circle은 Shape의 모든 멤버를 가지고 있기 때문이다.
.NET 4.5 — Comparer<T>.Create
// .NET 4.5 — 별도 클래스 없이 비교자 생성
var byHp = Comparer<Enemy>.Create((a, b) => a.Hp.CompareTo(b.Hp));
enemies.Sort(byHp);
Comparer<T>.Create는 Comparison<T> 델리게이트를 받아 IComparer<T> 구현체를 반환한다. SortedSet이나 SortedDictionary처럼 IComparer<T> 인스턴스를 요구하는 API에서 유용하다.
정리
| 항목 | IComparable<T> | IComparer<T> |
|---|---|---|
| 구현 위치 | 타입 자체 | 별도 클래스 |
| 메서드 | int CompareTo(T other) |
int Compare(T x, T y) |
| 정렬 기준 수 | 1개 (기본 정렬) | 무제한 (비교자마다 1개) |
| 호출 방법 | list.Sort() |
list.Sort(comparer) |
| 용도 | 자연스러운 기본 순서 | 상황별 대체 순서 |
| 데이터 클래스 수정 | 필요 | 불필요 |
체크리스트 — 이것만 기억하자
- [ ] 타입에 자연스러운 정렬 기준이 있다면
IComparable<T>를 구현한다 - [ ] 정렬 기준이 여러 개이거나 외부에서 결정해야 하면
IComparer<T>를 사용한다 - [ ] 값 타입(struct)이면 반드시 제네릭 버전(
IComparable<T>,IComparer<T>)을 사용한다 — 비제네릭은 매 비교마다 박싱이 발생한다 - [ ]
CompareTo와Equals의 기준을 일치시킨다 —SortedSet,SortedDictionary에서 요소가 사라지는 버그를 방지한다 - [ ] Unity
Update()에서 정렬할 때는 비교자 인스턴스를 캐시하거나Comparison<T>델리게이트를 사용한다 — 매 프레임new를 피한다 - [ ] 간단한 일회성 정렬은
Comparison<T>람다나Comparer<T>.Create를 활용한다 — 별도 클래스를 만들지 않아도 된다
'C# 심화' 카테고리의 다른 글
| [PART5.구조체와 레코드(2/4)] struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.05 |
|---|---|
| [PART5.구조체와 레코드(1/4)] struct vs class — 무엇을 언제 선택하는가 (0) | 2026.04.05 |
| [PART4.인터페이스(2/3)] IEnumerable vs ICollection vs IList — 계층의 의미 (0) | 2026.04.05 |
| [PART4.인터페이스(1/3)] interface — 계약으로서의 인터페이스 (0) | 2026.04.05 |
| [PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법 (0) | 2026.04.05 |
