1. 소개
상상해봐, 우리가 좋아하는 DogShelter 클래스가 있고, 거기엔 강아지 컬렉션이 저장돼 있어. 지난 강의에서 이미 인덱서를 추가해서 강아지 번호로 가져올 수 있었지: Dog firstDog = myShelter[0];. 완전 멋지지!
근데 만약 사용자가 번호가 아니라, 예를 들어 이름으로 강아지를 찾고 싶으면 어떡하지? 아니면 품종으로? 아니면 여러 조건 조합으로? 물론 GetDogByName("Buddy")나 GetDogByBreedAndAge("Labrador", 5) 같은 메서드를 추가할 수도 있어. 이건 완전 정상적인 방법이야.
하지만 가끔은 더 "배열처럼" 직관적으로 접근하고 싶을 때가 있잖아. 예를 들어 이렇게 쓰고 싶은 거지: Dog buddy = myShelter["Buddy"]; 또는 Dog oldLab = myShelter["Labrador", 8];.
DogShelter가 우리 코드라면 그냥 인덱서를 추가하면 돼. 근데 만약 DogShelter가 외부 라이브러리 클래스라서 수정 못 한다면? 아니면 아주 특이한 접근법을 추가하고 싶은데, 원본 클래스를 "더럽히고" 싶지 않다면?
바로 여기서 확장 인덱서(Extension Indexers)가 등장하는 거야!
2. "대괄호"를 밖에서 붙이기
지난 강의에서 DisplayName 확장 프로퍼티를 Dog에 추가했던 거 기억나? 인덱서도 거의 똑같이 동작해!
확장 인덱서는 static 클래스에 정의된 static 인덱서로, 이미 존재하는 타입의 객체에 obj[인덱스] 문법을 쓸 수 있게 해줘. 원래 그 타입에 인덱서가 없었거나, 다른 타입의 인덱서를 추가하고 싶을 때도 쓸 수 있어.
이건 마치 냉장고를 샀는데, 특정 부분을 두드리면 콜라가 나오는 기능을 나중에 추가한 것과 비슷해. 냉장고는 그대로인데, 기능이 "밖에서" 추가된 거지!
확장 인덱서 문법
public static class MyExtensionClass
{
extension(ObjectType 인스턴스)
{
public static ReturnType this[인덱스타입 index ]
{
get
{
// 읽기 로직, 인스턴스와 index 사용
return ...;
}
set
{
// 인스턴스, index 그리고 'value' 키워드 사용
// 'value'는 새 값이야
}
}
}
}
this 확장대상타입 인스턴스에 주목! 이 문법은 우리가 확장 메서드나 프로퍼티에서 봤던 거랑 똑같아. 인스턴스는 우리가 확장하는 객체를 get과 set 액세서 안에서 부를 이름이야.
3. Extension Indexer 선언하기 (머리 안 터지게!)
문법은 지난 강의에서 다뤘던 Extension Properties랑 비슷한데, 인덱스 파라미터가 추가된 거야. 최소 예제는 이래:
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
public static Dog this[string name]
{
get
{
foreach (var dog in shelter)
{
if (dog.Name == name)
return dog;
}
return null;
}
}
}
}
익숙한 요소들:
- this가 첫 파라미터 앞에 붙는 건 Extension Members(확장 대상 객체) 규칙이야.
- 클래스 이름 뒤에 대괄호 안에서 쓸 파라미터들이 나와.
실습: DogShelter에 이름 인덱서 확장하기
우리 예제 프로젝트를 살짝 바꿔보자. 강아지 보호소가 있고, 각 강아지는 이름이 유니크하다고 해보자:
DogShelter 클래스 (라이브러리/외부 코드)
public class Dog
{
public string Name { get; set; }
public int Age { get; set; }
}
public class DogShelter : IEnumerable<Dog>
{
private List<Dog> dogs = new List<Dog>();
public void AddDog(Dog dog) => dogs.Add(dog);
// 기존 번호 인덱서
public Dog this[int index]
{
get => dogs[index];
set => dogs[index] = value;
}
public IEnumerator<Dog> GetEnumerator() => dogs.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
원하는 것: shelter["부샤"]
예전엔 — 메서드로만 가능:
// C# 14 이전:
public static Dog? FindByName(this DogShelter shelter, string name) { ... }
이제는 — Extension Indexer로!
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
public static Dog? this[string name]
{
get
{
foreach (var dog in shelter)
if (dog.Name == name)
return dog;
return null;
}
set
{
for (int i = 0; i < shelter.Count; i++)
{
if (shelter[i].Name == name)
{
shelter[i] = value!;
return;
}
}
throw new ArgumentException("강아지를 찾을 수 없음");
}
}
}
}
이제 메인 코드가 훨씬 깔끔해졌지:
var shelter = new DogShelter();
shelter.AddDog(new Dog { Name = "부샤", Age = 3 });
shelter.AddDog(new Dog { Name = "투직", Age = 5 });
// extension-인덱서 사용!
Dog busya = shelter["부샤"]!;
Console.WriteLine(busya.Age);
shelter["부샤"] = new Dog { Name = "부샤", Age = 4 };
시각화: 실제로 어떻게 동작할까?
| 동작 | 예전 방식 | Extension Indexer |
|---|---|---|
| 이름으로 찾기 | shelter.FindByName("X") | shelter["X"] |
| 이름으로 강아지 수정 | shelter.UpdateName("X", ..) | shelter["X"] = ... |
4. Extension Indexers의 디테일과 특징
컴파일러와 범위
- Extension Indexer는 public static 클래스에 선언해야 해 (extension methods랑 똑같이).
- 필요한 using을 꼭 추가해야 해. 까먹으면 컴파일러는 조용한데, 코드는 안 돌아감.
- 기본 클래스에 이미 같은 인덱서가 있으면 확장 불가(시그니처가 달라야 함).
set 액세서 구현
get만 선언하면 읽기 전용 인덱서가 돼. set도 추가하면(위 예제처럼) 읽고 쓸 수 있어.
값/참조 전달
Extension Indexer는 확장하는 객체 인스턴스(this가 첫 파라미터)에 동작해. 객체가 참조 타입이면 상태를 바꿀 수 있어.
한 클래스에 여러 인덱서
문제 없어 — 파라미터 조합만 다르면 여러 extension-인덱서 선언 가능! 예를 들어 나이로 찾기: shelter[5](기존), shelter["부샤"](새로운), shelter[age: 3](또 다른, 원하면).
예시: DogShelter에 인덱서 두 개 추가
public static class DogShelterExtensions
{
extension(DogShelter shelter)
{
// 이름으로
public static Dog? this[string name]
{
get => shelter.FirstOrDefault(d => d.Name == name);
set
{
for (int i = 0; i < shelter.Count; i++)
if (shelter[i].Name == name)
shelter[i] = value!;
}
}
// 나이로 — 해당 나이의 첫 강아지 반환
public static Dog? this[int age]
{
get => shelter.FirstOrDefault(d => d.Age == age);
}
}
}
이제 이렇게 쓸 수 있어:
var youngDog = shelter[1]; // 나이로
var tony = shelter["토니"]; // 이름으로
shelter["투직"] = new Dog { Name = "투직", Age = 9 };
실제 사용 예시
- 외부 라이브러리: 외부 클래스에 인덱싱 방법을 추가하고 싶을 때, 원본 소스 안 건드리고. 예를 들어 주문 컬렉션에서 번호, 날짜, 상태 등으로 찾고 싶을 때, 래퍼 메서드 중복 없이 가능.
- "어댑터 패턴": 구식 컬렉션 API를 더 현대적이고 C#스러운 스타일로 바꿔주면서, 기존 코드 호환성도 유지.
- 레거시 코드 마이그레이션: 이미 작성된 타입에 새 기능을 추가하면서 기존 코드와 테스트는 그대로 둘 수 있음.
- 테스트 편의성: 임시로 인덱서를 붙여서(예: 테스트에만 필요한 특이한 검색), 메인 클래스를 더럽히지 않고 쓸 수 있음.
5. Extension Indexers 쓸 때 흔한 실수와 함정
만약 기본 클래스에 똑같은 시그니처의 인덱서가 이미 있으면, extension-인덱서는 호출되지 않아 — 기본 인덱서가 우선이야.
Extension-인덱서도 extension member라서, using(네임스페이스 import) 없으면 확장이 안 보여.
또 자주 하는 실수 — null을 반환하면서 사용자에게 알리지 않는 것. 누가 실수로 없는 요소에 접근했는데 extension-인덱서가 null을 주면, 다른 코드에서 NullReferenceException이 날 수 있어. 좋은 습관은, 예외를 던질지, 특별한 더미 객체를 줄지, 그냥 null을 줄지 미리 생각해두는 거야.
여러 extension-인덱서를 만들 땐, 파라미터 타입과 개수로 시그니처가 유니크한지 꼭 확인해. 똑같은 시그니처 두 개 만들면 컴파일 에러야.
Extension-인덱서는 객체 인스턴스에만 동작하고, static 타입에는 안 돼.
GO TO FULL VERSION