1. 소개
상상해봐, 네가 대학교 학장인데 학생이 엄청 많아. 자주 여러 기준으로 정렬된 리스트가 필요하지:
- 이름순 (알파벳순으로 학생을 빨리 찾으려고).
- 평균 점수순 (우수생한테 장학금 주려고).
- 나이순 (통계, 대회 등등).
- 학년별로, 그리고 학년 안에서는 성으로 정렬.
만약 IComparable<T>만 쓴다면, 학생 클래스에 하나의 비교 방식만 구현할 수 있어. 예를 들어 "학생의 자연스러운 순서"를 평균 점수로 정했다고 해보자. 좋아, 이제 List.Sort() 잘 돌아가! 근데 이름순으로 정렬하고 싶으면? 학생 클래스는 이미 점수 비교로 "고정"돼버렸지. 동시에 두 개의 "자연스러운" 순서를 가질 수 없어. 이건 마치 "복싱에서 최고가 되는 법"만 배우고, 갑자기 체스에서 최고가 되려고 복싱 룰을 써보는 거랑 똑같아. 잘 안 되겠지?
이럴 때, 즉 같은 타입의 객체를 여러 방식으로 정렬해야 하고, 비교 로직을 클래스에 넣고 싶지 않을 때 IComparer<T> 인터페이스가 딱이야. 클래스 소스코드에 접근할 수 없거나, 클래스가 모든 정렬 방식을 알 필요가 없을 때도 유용하지.
2. IComparer<T> 인터페이스
IComparable<T>가 객체 스스로 "난 얘보다 빠르다!"라고 말하는 내부 감각이라면, IComparer<T>는 완전히 외부의 심판 또는 독립적인 중재자야. 두 명의 플레이어(객체)를 데려다가 미리 정해둔 규칙으로 비교해주는 거지.
네가 축구팀 감독이라고 생각해봐. 주장 뽑아야 해.
- IComparable<T>: 각 선수 스스로 "난 얘보다 빨라서 더 세!"라고 말하는 거야. (자기만의 내부 규칙)
- IComparer<T>: 감독인 네가 "오늘은 패스 정확도로 주장 뽑는다! 철수, 패스! 영희, 패스! 오, 철수가 더 정확하네! 오늘은 철수가 주장!" (이건 네가 정한 외부 규칙이야. 누구한테나 적용 가능)
정의: IComparer<T>는 .NET에서 두 개의 T 타입 객체를 외부 비교 로직으로 비교할 수 있게 해주는 인터페이스야. IComparer<T>를 구현한 클래스는 비교 대상이 아니고, 그냥 Compare라는 메서드를 제공해서 두 객체의 순서를 정해주는 역할만 해.
문법
IComparer<T> 인터페이스 문법은 아주 간단해:
public interface IComparer<T>
{
// 두 개의 T 타입 객체를 비교하는 메서드
// x - 첫 번째 비교 대상
// y - 두 번째 비교 대상
int Compare(T x, T y);
}
Compare(T x, T y) 메서드는 IComparable<T>의 CompareTo(T other)랑 똑같이 동작해:
- x가 y보다 "작으면" 음수 반환.
- x가 y랑 "같으면" 0 반환.
- x가 y보다 "크면" 양수 반환.
여기서 "작다", "같다", "크다"는 우리가 Compare 구현에서 정하는 비교 로직에 따라 달라져.
IComparer<T>와 IComparable<T>의 차이
| 클래스 / 인터페이스 | 어디에 구현? | 용도 | 사용 예시 |
|---|---|---|---|
|
타입(클래스/구조체) 안에 직접 | 하나의 표준 비교 방식 | ID 오름차순 정렬 |
|
별도의 클래스 | 비교 방식 여러 개 가능 | 이름, 날짜 등으로 정렬 |
3. IComparer<T> 실전 구현
우리 "큰 프로젝트" 계속 만들어보자 — 간단한 사용자 모델이 있다고 해보자. 이런 클래스가 있어:
// 사용자 클래스
public class User
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
}
이름순 정렬: 별도 컴페어러 만들기
IComparer<User>를 구현해서 이름순으로 비교하는 클래스를 만들어보자:
// 이름순 정렬용 컴페어러 클래스
public class UserNameComparer : IComparer<User>
{
public int Compare(User x, User y)
{
// null 체크 (예상치 못한 상황 방지!)
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1; // null은 어떤 객체보다 "작다"
if (y is null) return 1;
// 이름 기준으로 비교 (문자열 정렬 표준 사용)
return string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
}
}
컴페어러로 리스트 정렬하기:
List<User> users = new List<User>
{
new User { Name = "이반", Age = 20, Email = "ivan@mail.com" },
new User { Name = "안나", Age = 32, Email = "anna@gmail.com" },
new User { Name = "보리스", Age = 28, Email = "boris@work.org" },
new User { Name = "루슬란", Age = 19, Email = "ruslan@yandex.ru" }
};
// IComparer로 이름순 정렬
users.Sort(new UserNameComparer());
users.ForEach(u => Console.WriteLine(u.Name)); // 안나, 보리스, 이반, 루슬란
어때, 엄청 깔끔하지? 컴페어러 클래스는 프로그램에서 독립적으로 쓸 수 있어서, 다른 사용자 리스트에도 재사용 가능해.
4. 여러 비교 방식: 다양한 컴페어러 만들기
컴페어러는 원하는 만큼 만들 수 있어. 예를 들어 나이순 정렬도 만들어보자:
// 나이순 정렬용 컴페어러
public class UserAgeComparer : IComparer<User>
{
public int Compare(User x, User y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
// 나이 오름차순 정렬
return x.Age.CompareTo(y.Age);
}
}
이제 이렇게 쓸 수 있어:
users.Sort(new UserAgeComparer());
users.ForEach(u => Console.WriteLine($"{u.Name} ({u.Age})"));
// 출력: 루슬란 (19) 이반 (20) 보리스 (28) 안나 (32)
나이 내림차순으로 정렬하고 싶으면, 인자 순서만 바꿔주면 돼:
// 나이 내림차순 정렬용 컴페어러
public class UserAgeDescendingComparer : IComparer<User>
{
public int Compare(User x, User y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
// 순서 바꿈: y.CompareTo(x)
return y.Age.CompareTo(x.Age);
}
}
5. 유용한 팁
내부적으로 어떻게 동작할까?
Sort()에 컴페어러를 넘기면, 리스트가 각 요소를 컴페어러에 넘기면서 "누굴 먼저 둘까?"라고 물어봐. 네 Compare가 "얘, 얘, 아니면 순서 그대로"라고 답해주지. 이걸 모든 쌍에 반복해서 최종 정렬 리스트가 만들어져.
값이 같으면 어떻게 할까? 그냥 0을 반환하면 돼 — 그럼 순서가 그대로거나, 내부 정렬 로직에 따라 결정돼.
IComparer<T>가 또 어디에 쓰일까?
IComparer<T>는 리스트 말고도 .NET에서 여러 군데서 써. 예시 몇 개:
- SortedList<TKey, TValue>나 SortedSet<T> 같은 컬렉션 생성자: 요소 순서를 지정해야 할 때.
- BinarySearch로 검색할 때.
예시:
var sortedSet = new SortedSet<User>(new UserAgeComparer());
이제 SortedSet은 항상 나이순으로 자동 정렬돼!
null-안전성 한 번 더!
초보들이 제일 많이 하는 실수 중 하나가 NullReferenceException이야. Compare 안에서 null 체크 꼭 해줘야 해, 특히 리스트에 null 값이 있을 수 있으면.
자주 쓰는 패턴 (한 번 더 보여줄게, 확실하게!):
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
이 습관 들이면, 프로그램이 갑자기 뻗는 걸 막을 수 있어!
IComparer<T> 방식의 장점과 한계
- 비교 로직과 데이터를 확실히 분리할 수 있어. 사용자 클래스는 "왜, 어떻게" 정렬되는지 신경 안 써도 돼.
- 비교 로직을 여러 군데서 쉽게 재사용 가능.
- 확장성 최고: 타입 안 건드리고 원하는 만큼 정렬 방식 추가 가능.
하지만 "null 체크 깜빡"이나 "비교 로직 불일치" 같은 실수는 조심해야 해. 예를 들어 Compare(x, y)가 0을 반환하면 Compare(y, x)도 0이어야 하고, Compare(x, y)가 0보다 크면 Compare(y, x)는 0보다 작아야 해.
비주얼 요약: 언제 뭘 써야 할까?
| 상황 | 뭐 쓰기? | 로직 구현 위치 |
|---|---|---|
| 하나의 "자연스러운" 정렬 | |
타입(클래스/구조체) 안 |
| 여러 정렬 방식 | |
별도 컴페어러 클래스 |
| 빠르고, 한 번만, "즉석에서" | / 람다 |
Sort 메서드 파라미터, 델리게이트로 |
| 복잡하고 자주 쓰는 로직 | |
별도 컴페어러 클래스 |
다음 강의에서는 델리게이트랑 람다식으로 객체 비교하는 법도 배울 거야. 그 전까지는 네 앱에서 여러 컴페어러 직접 만들어보고, 정렬 로직이 클래스 밖에 있으니까 얼마나 유연하고 확장성 좋은지 직접 느껴봐!
6. 컴페어러 구현할 때 자주 하는 실수
실수 1: null 체크 안 함.
비교 대상 중 하나가 null인데 코드에서 이걸 처리 안 하면, NullReferenceException으로 프로그램이 뻗을 수 있어.
실수 2: -1, 0, +1 반환값이 잘못됨.
Compare 메서드는 첫 번째 객체가 두 번째보다 작으면 음수, 같으면 0, 크면 양수를 반환해야 해. 이 규칙 어기면 정렬이 "이상하게" 동작해.
실수 3: 비교 로직이 비대칭임.
x와 y를 비교할 때랑 y와 x를 비교할 때 결과가 반대가 아니면, 결과가 예측 불가가 돼.
실수 4: 사용자 타입에 Sort()만 쓰고 컴페어러 안 넘김.
타입이 IComparable이나 IComparable<T>를 구현 안 했는데 Sort()만 호출하면 InvalidOperationException 예외가 나와.
예방 팁:
경계 상황 꼭 체크하고, 중요한 부분은 유닛 테스트로 커버하고(이건 나중에 또 다룰 거야!), 문서도 자주 참고해. 그러면 컴페어러가 스위스 시계처럼 잘 돌아갈 거야!
GO TO FULL VERSION