1. 소개
왜 컴페레이터가 진짜 중요한지…
실제 프로젝트에서는 교과서 문제처럼 단순하지 않아. 객체도 복잡하고, 데이터는 사용자나 다른 서비스에서 오고, 우리가 원하는 "정렬 순서"(혹은 동등성 규칙)는 상황마다 계속 바뀌지. 컴페레이터를 쓰면 코드가 유연해지고, 동작도 예측 가능하고 컨트롤하기 쉬워져.
.NET에서는 사용자 정의 클래스 객체를 정렬, 검색, 그룹화하거나 중복을 제거할 때, 그리고 "순서"가 있는 데이터 구조를 만들 때마다 컴페레이터가 꼭 필요해. 특히 컬렉션, 알고리즘, 외부 API 연동할 때 자주 만나게 돼.
.NET에서 컴페레이터가 쓰이는 곳:
- 컬렉션 정렬 (List<T>.Sort, Array.Sort, OrderBy in LINQ)
- 순서가 있는 데이터 구조 (SortedSet<T>, SortedDictionary<TKey, TValue>)
- 컬렉션에서 객체 검색 및 비교 (Contains, IndexOf — "순서"뿐 아니라 "동등성"도 필요할 때)
- 그룹화, 필터링, 중복 제거 (예: .Distinct())
실제로 왜 필요할까?
- 정렬된 결과가 중요한 리포트 (예: 학생 리스트를 이름순이나 평균 점수순으로)
- 입력 데이터를 기준값과 매칭 (예: 특정 조건으로 레코드 찾기)
- 메모리와 속도 절약 (적절한 데이터 구조 선택이 검색을 빠르게 하고 앱 반응성도 좋아짐)
- 유니크함 검증 (예: 이메일로 사용자 가입할 때)
2. 실생활 예시: 여러 기준으로 정렬하기
예를 들어, 사용자 클래스를 만들었다고 해보자:
public class User
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
public int Age { get; set; }
// override Equals와 GetHashCode도 추가할 수 있는데, 이건 숙제로 남겨둘게
}
사용자 리스트가 있고, 우리가 하고 싶은 건:
- 성 기준으로 정렬하고, 성이 같으면 이름 기준으로 정렬하기.
- 이메일로 사용자 검색 (대소문자 무시).
- 사용자 추가할 때 중복은 빼기.
IComparer<T>로 정렬하기
방법 1: 클래식하게 별도 컴페레이터 클래스를 만든다.
// 성+이름 기준 정렬 컴페레이터
public class UserFullNameComparer : 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;
int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
if (lastNameComparison != 0)
return lastNameComparison;
return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
}
}
사용 예시:
var users = new List<User>
{
new User { FirstName = "이반", LastName = "페트로프", Age = 20, Email = "ivan.petrov@email.com" },
new User { FirstName = "안나", LastName = "스미르노바", Age = 22, Email = "anna.smirnova@email.com" },
new User { FirstName = "표트르", LastName = "페트로프", Age = 18, Email = "petr.petrov@email.com" }
};
users.Sort(new UserFullNameComparer());
foreach (var user in users)
{
Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");
}
// 결과 — 사용자가 성 기준, 성이 같으면 이름 기준으로 정렬됨.
참고: 여러 군데서 같은 비교 순서가 필요하거나, 람다로는 복붙이 너무 많아질 때 이렇게 해.
람다로 "즉석" 정렬하기
클래스 만들기 귀찮고, 한 번만 쓸 거면? 람다로 바로!
users.Sort((x, y) =>
{
int lastNameComparison = StringComparer.OrdinalIgnoreCase.Compare(x.LastName, y.LastName);
if (lastNameComparison != 0)
return lastNameComparison;
return StringComparer.OrdinalIgnoreCase.Compare(x.FirstName, y.FirstName);
});
동일하게 동작하지만, 컴페레이터가 호출 안에서 바로 만들어져. 코드 줄수 아끼지만, 여러 번 쓸 땐 불편할 수도 있어.
3. 꿀팁 검색: 이메일 대소문자 무시 비교
실제로 사용자는 이메일을 아무렇게나 입력하지. 우리는 프로그래머니까, 판사가 아니라서, 이메일 비교는 대소문자 무시가 합리적이야.
컴페레이터랑 검색으로 구현해보자:
public class EmailComparer : IEqualityComparer<User>
{
public bool Equals(User? x, User? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return string.Equals(x.Email, y.Email, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(User obj)
{
return obj.Email?.ToLowerInvariant().GetHashCode() ?? 0;
}
}
HashSet에서 사용 예시:
var usersSet = new HashSet<User>(new EmailComparer());
usersSet.Add(new User { Email = "Petrov@example.com" });
bool contains = usersSet.Contains(new User { Email = "petrov@example.com" }); // true!
중요: 직접 EqualityComparer 만들 땐 Equals랑 GetHashCode 둘 다 꼭 구현해야 해. 둘 중 하나라도 빼먹으면, 컬렉션에서 비교가 이상하게 동작할 수 있어.
4. SortedSet과 SortedDictionary 활용
여기서 컴페레이터가 진짜 빛을 발하지.
SortedSet<T>이랑 SortedDictionary<TKey, TValue>는 네 객체를 어떻게 비교할지 .NET에 설명 안 해주면 못 써. 비교 순서랑 동등성 규칙이 어떤 요소가 "다른 것"으로 취급되는지에 영향을 줘!
SortedSet<User> 예시
var sortedUsersByFullName = new SortedSet<User>(new UserFullNameComparer())
{
new User { FirstName = "이반", LastName = "페트로프", Age = 20 },
new User { FirstName = "안나", LastName = "스미르노바", Age = 22 },
new User { FirstName = "표트르", LastName = "페트로프", Age = 18 },
new User { FirstName = "이반", LastName = "페트로프", Age = 25 } // 이름+성 중복
};
// "이반 페트로프" 나이 다르게 두 명 있어도, 하나만 들어감
Console.WriteLine("SortedSet에 있는 사용자:");
foreach (var user in sortedUsersByFullName)
Console.WriteLine($"{user.LastName} {user.FirstName} ({user.Age})");
SortedSet은 "이반 페트로프" 두 명을 안 넣어줘!
중요: 여기서 컴페레이터가 "유니크함"의 기준이야. 성+이름만 비교하면, 나이가 달라도 같은 사용자로 취급돼.
5. 표: .NET에서 언제 어떤 컴페레이터를 써야 할까
| 상황 | 뭘 구현해야 하나 | 사용 예시 |
|---|---|---|
| 자연스러운 정렬 순서 (타입에 대해 하나, 범용) | |
|
| 여러 정렬 기준 (이름, 나이, 이메일 등) | (클래스, 람다) |
|
| 컬렉션에서 유니크한 요소 찾기 | |
, |
| 그룹화, 중복 제거 | |
LINQ |
| 시간, 날짜, 복잡한 필드 조합으로 정렬 | , 즉석 람다 |
|
| LINQ에서 사용 (일회성 쿼리) | OrderBy, ThenBy에 람다 |
|
6. 실습: 컬렉션에서 검색과 비교
우리 앱에서 이메일(대소문자 무시)로 유니크한 사용자 등록을 구현해보자. 이미 그 이메일이 있으면 알려줘야 해.
public static bool RegisterUser(List<User> users, User newUser)
{
// 이메일 유니크함 검색에 Any+람다 사용
bool exists = users.Any(u =>
string.Equals(u.Email, newUser.Email, StringComparison.OrdinalIgnoreCase));
if (exists)
{
Console.WriteLine($"이메일: {newUser.Email} 이미 등록됨!");
return false;
}
users.Add(newUser);
Console.WriteLine($"사용자 {newUser.FirstName} 추가됨.");
return true;
}
사용 예시:
var userList = new List<User>
{
new User { Email = "first@example.com" }
};
RegisterUser(userList, new User { FirstName = "바사", Email = "FIRST@example.com" });
// 출력: "이메일: FIRST@example.com 이미 등록됨!"
7. 컴페레이터 쓸 때 흔한 실수
다들 실수 리스트 좋아하지? 우리도 재밌게 해보자.
프로그래머가 사용자 정의 타입에 컴페레이터 구현할 때 null 체크를 빼먹거나, 더 심각하게는 "순서의 엄격함"을 깨는 비교를 할 때가 있어. 예를 들어, 컴페레이터에서 모순된 값을 리턴하면 정렬이 엉망이 되고, 컬렉션에서 객체가 사라지거나 "합쳐져" 버릴 수도 있어.
또 자주 하는 실수는 Equals 구현과 컴페레이터 논리가 일치하지 않는 거야. 예를 들어, Equals는 다르다고 하고, 컴페레이터는 같다고 하면 SortedSet이나 SortedDictionary에서 난리가 나지: 분명히 있는 것 같은데 못 찾거나, 이상하게 동작해.
또 한 가지, 객체의 한 속성(예: 성)만 비교하고, 같은 성 가진 다른 사용자가 많을 수 있다는 걸 까먹는 경우도 있어. 그러면 객체가 "덮어써지거나", 사라지거나, 데이터가 일관성 없게 돼. 즉, 실제 시스템 상태랑 안 맞게 되고, 중복, 정보 누락, 프로그램 로직 깨짐 같은 문제가 생겨.
GO TO FULL VERSION