C# 컬렉션
C# 컬렉션 (Collections)
관련 있는 객체들을 한데 묶어 관리하고 싶을 때, .NET 런타임은 다양한 컬렉션 타입을 제공해요. 이 글에서는 컬렉션을 이루는 핵심 개념과 실제로 자주 쓰는 List, Dictionary, 반복자(Iterator) 그리고 LINQ 활용까지 하나씩 짚어볼게요.
출처: Microsoft Learn
본문
.NET 런타임은 서로 관련된 객체들을 저장하고 관리해 주는 많은 컬렉션 타입을 제공해요. 그중 일부는 C# 언어 자체에서 인식되기도 하는데, 대표적으로 xref:System.Array?displayProperty=nameWithType, xref:System.Span`1?displayProperty=nameWithType, xref:System.Memory`1?displayProperty=nameWithType가 있어요. 또 컬렉션의 요소를 차례로 순회할 수 있게 해 주는 xref:System.Collections.Generic.IEnumerable`1?displayProperty=nameWithType 같은 인터페이스도 언어 차원에서 인식돼요.
컬렉션은 객체들의 묶음을 유연하게 다루는 방법을 제공해요. 다양한 컬렉션들은 다음 세 가지 특징으로 구분할 수 있어요.
- 요소 접근 방식: 모든 컬렉션은 순서대로 각 요소에 접근하도록 열거할 수 있어요. 어떤 컬렉션은 인덱스(정렬된 컬렉션 안에서 요소가 놓인 위치)로 접근하는데, 가장 대표적인 예가 xref:System.Collections.Generic.List`1?displayProperty=nameWithType예요. 어떤 컬렉션은 키(key)로 접근하는데, 하나의 키에 하나의 값이 연결되는 구조예요. 그 대표적인 예가 xref:System.Collections.Generic.Dictionary`2?displayProperty=nameWithType죠. 앱이 요소에 어떻게 접근하는지에 따라 적절한 컬렉션 타입을 고르게 돼요.
- 성능 특성: 컬렉션마다 요소를 추가하거나, 찾거나, 제거하는 동작마다 성능 특성이 달라요. 그래서 앱에서 가장 많이 쓰는 연산을 기준으로 컬렉션 타입을 고를 수 있어요.
- 동적 확장·축소: 대부분의 컬렉션은 요소를 동적으로 추가하거나 제거하는 걸 지원해요. 다만 xref:System.Array, xref:System.Span`1?displayProperty=nameWithType, xref:System.Memory`1?displayProperty=nameWithType는 예외적으로 그렇게 하지 않아요.
이런 특징들에 더해, 런타임은 컬렉션의 요소를 추가·제거·수정하지 못하게 막는 특수한 컬렉션도 제공해요. 또 멀티스레드 앱에서 동시 접근에 안전하도록 해 주는 특수한 컬렉션도 따로 있어요.
모든 컬렉션 타입은 .NET API 참조에서 찾을 수 있어요. 더 자세한 내용은 Commonly Used Collection Types와 Selecting a Collection Class를 참고하세요.
[!NOTE] 이 문서의 예제를 실행하려면
System.Collections.Generic과System.Linq네임스페이스에 대한 using 지시문을 추가해야 할 수 있어요.
배열(Array)은 xref:System.Array?displayProperty=fullName로 표현되고 C# 언어 차원의 문법 지원을 받아요. 이 문법 덕분에 배열 변수를 더 간결하게 선언할 수 있죠.
xref:System.Span`1?displayProperty=nameWithType은 ref struct 타입으로, 요소 시퀀스를 복사하지 않고 그대로 바라보는 스냅샷(snapshot)을 제공해요. 컴파일러는 Span이 참조하는 시퀀스가 더 이상 유효 범위에 없을 때는 접근할 수 없도록 안전 규칙을 강제해요. 그래서 성능을 높이기 위해 많은 .NET API에서 사용돼요. ref struct 타입을 쓸 수 없는 상황이라면 xref:System.Memory`1이 비슷한 동작을 제공해 줘요.
C# 12부터는 모든 컬렉션 타입을 컬렉션 식(Collection expression)으로 초기화할 수 있어요.
인덱스 기반 컬렉션 (Indexable collections)
인덱스 기반 컬렉션은 인덱스를 사용해 각 요소에 접근할 수 있는 컬렉션이에요. 요소의 인덱스는 시퀀스에서 그 요소보다 앞에 있는 요소의 개수예요. 그래서 인덱스 0이 첫 번째 요소를, 인덱스 1이 두 번째 요소를 가리키는 식이죠. 아래 예제들은 가장 흔한 인덱스 기반 컬렉션인 xref:System.Collections.Generic.List`1 클래스를 사용할게요.
다음 예제는 문자열 리스트를 만들고 초기화한 뒤, 요소 하나를 제거하고 리스트 끝에 요소를 추가해요. 그리고 수정이 있을 때마다 foreach 문이나 for 루프로 문자열들을 순회해요.
// <SnippetCreateList>
// Create a list of strings by using a
// collection initializer.
List<string> salmons = ["chinook", "coho", "pink", "sockeye"];
// Iterate through the list.
foreach (var salmon in salmons)
{
Console.Write(salmon + " ");
}
// Output: chinook coho pink sockeye
// Remove an element from the list by specifying
// the object.
salmons.Remove("coho");
// Iterate using the index:
for (var index = 0; index < salmons.Count; index++)
{
Console.Write(salmons[index] + " ");
}
// Output: chinook pink sockeye
// Add the removed element
salmons.Add("coho");
// Iterate through the list.
foreach (var salmon in salmons)
{
Console.Write(salmon + " ");
}
// Output: chinook pink sockeye coho
// </SnippetCreateList>
다음 예제는 리스트에서 인덱스로 요소를 제거해요. foreach 대신 내림차순으로 순회하는 for 문을 사용하는데, 그 이유가 있어요. xref:System.Collections.Generic.List`1.RemoveAt* 메서드가 요소를 제거하면 그 뒤에 있던 요소들이 한 칸씩 앞으로 밀리면서 인덱스 값이 낮아지거든요. 그래서 앞에서부터 지우면 인덱스가 꼬이니, 뒤에서부터 지우는 거예요.
// <SnippetRemoveItemByIndex>
List<int> numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
// Remove odd numbers.
for (var index = numbers.Count - 1; index >= 0; index--)
{
if (numbers[index] % 2 == 1)
{
// Remove the element by specifying
// the zero-based index in the list.
numbers.RemoveAt(index);
}
}
// Iterate through the list.
// A lambda expression is placed in the ForEach method
// of the List(T) object.
numbers.ForEach(
number => Console.Write(number + " "));
// Output: 0 2 4 6 8
// </SnippetRemoveItemByIndex>
xref:System.Collections.Generic.List`1이 담는 요소 타입은 직접 만든 클래스일 수도 있어요. 아래 예제에서는 Galaxy 클래스를 직접 정의해서 리스트에 사용해요.
// <SnippetCustomList>
private static void IterateThroughList()
{
var theGalaxies = new List<Galaxy>
{
new (){ Name="Tadpole", MegaLightYears=400},
new (){ Name="Pinwheel", MegaLightYears=25},
new (){ Name="Milky Way", MegaLightYears=0},
new (){ Name="Andromeda", MegaLightYears=3}
};
foreach (Galaxy theGalaxy in theGalaxies)
{
Console.WriteLine(theGalaxy.Name + " " + theGalaxy.MegaLightYears);
}
// Output:
// Tadpole 400
// Pinwheel 25
// Milky Way 0
// Andromeda 3
}
public class Galaxy
{
public string Name { get; set; }
public int MegaLightYears { get; set; }
}
// </SnippetCustomList>
인덱스와 범위에 대해 더 알고 싶다면 Explore indexes and ranges 문서를 참고하세요.
키/값 쌍 컬렉션 (Key/value pair collections)
아래 예제들은 가장 흔한 사전(dictionary) 컬렉션인 xref:System.Collections.Generic.Dictionary`2 클래스를 사용해요. 사전 컬렉션은 각 요소의 키를 이용해 컬렉션의 요소에 접근할 수 있게 해 줘요. 사전에 추가되는 각 항목은 값과 그와 연결된 키로 이루어져 있죠.
먼저 Dictionary 컬렉션을 만들고 foreach 문으로 사전을 순회하는 예제를 볼게요.
// <SnippetDictionary>
private static void IterateThruDictionary()
{
Dictionary<string, Element> elements = BuildDictionary();
foreach (KeyValuePair<string, Element> kvp in elements)
{
Element theElement = kvp.Value;
Console.WriteLine("key: " + kvp.Key);
Console.WriteLine("values: " + theElement.Symbol + " " +
theElement.Name + " " + theElement.AtomicNumber);
}
}
public class Element
{
public required string Symbol { get; init; }
public required string Name { get; init; }
public required int AtomicNumber { get; init; }
}
private static Dictionary<string, Element> BuildDictionary() =>
new ()
{
{"K",
new (){ Symbol="K", Name="Potassium", AtomicNumber=19}},
{"Ca",
new (){ Symbol="Ca", Name="Calcium", AtomicNumber=20}},
{"Sc",
new (){ Symbol="Sc", Name="Scandium", AtomicNumber=21}},
{"Ti",
new (){ Symbol="Ti", Name="Titanium", AtomicNumber=22}}
};
// </SnippetDictionary>
다음 예제는 xref:System.Collections.Generic.Dictionary`2.ContainsKey* 메서드와 Dictionary의 xref:System.Collections.Generic.Dictionary`2.Item* 속성을 사용해 키로 항목을 빠르게 찾아요. Item 속성은 C#에서 elements[symbol]처럼 써서 elements 컬렉션의 항목에 접근할 수 있게 해 주죠.
// <SnippetFindInDictionary>
if (elements.ContainsKey(symbol) == false)
{
Console.WriteLine(symbol + " not found");
}
else
{
Element theElement = elements[symbol];
Console.WriteLine("found: " + theElement.Name);
}
// </SnippetFindInDictionary>
아래 예제는 xref:System.Collections.Generic.Dictionary`2.TryGetValue* 메서드로 키에 해당하는 항목을 빠르게 찾아요. 키가 있으면 값을 out 파라미터로 받아오고, 없으면 false를 돌려주기 때문에 안전하게 처리할 수 있어요.
// <SnippetFindInDictionary2>
if (elements.TryGetValue(symbol, out Element? theElement) == false)
Console.WriteLine(symbol + " not found");
else
Console.WriteLine("found: " + theElement.Name);
// </SnippetFindInDictionary2>
반복자 (Iterators)
*반복자(Iterator)*는 컬렉션에 대해 사용자 정의 순회(custom iteration)를 수행할 때 사용해요. 반복자는 메서드일 수도 있고 get 접근자일 수도 있어요. 반복자는 yield return 문을 사용해 컬렉션의 각 요소를 하나씩 돌려줘요.
반복자는 foreach 문으로 호출해요. foreach 루프의 각 반복이 반복자를 호출하고, 반복자 안에서 yield return 문에 도달하면 표현식이 반환되면서 코드의 현재 위치가 유지돼요. 다음에 반복자가 다시 호출되면 실행은 그 위치에서 다시 시작돼요.
더 자세한 내용은 Iterators (C#)를 참고하세요.
다음 예제는 반복자 메서드를 사용해요. 반복자 메서드 안에는 for 루프 안에 yield return 문이 있어요. ListEvenNumbers 메서드에서 foreach 문의 본문이 반복될 때마다 반복자 메서드가 호출되고, 다음 yield return 문으로 진행해요. 그 결과 짝수만 골라서 하나씩 돌려주게 되죠.
// <SnippetIteratorMethod>
private static void ListEvenNumbers()
{
foreach (int number in EvenSequence(5, 18))
{
Console.Write(number.ToString() + " ");
}
Console.WriteLine();
// Output: 6 8 10 12 14 16 18
}
private static IEnumerable<int> EvenSequence(
int firstNumber, int lastNumber)
{
// Yield even numbers in the range.
for (var number = firstNumber; number <= lastNumber; number++)
{
if (number % 2 == 0)
{
yield return number;
}
}
}
// </SnippetIteratorMethod>
LINQ와 컬렉션
언어 통합 쿼리(LINQ)를 사용해서도 컬렉션에 접근할 수 있어요. LINQ 쿼리는 필터링, 정렬, 그룹화 기능을 제공해요. 더 자세한 내용은 Getting Started with LINQ in C#를 참고하세요.
아래 예제는 제네릭 List에 LINQ 쿼리를 실행해요. 이 LINQ 쿼리는 결과를 담은 별도의 새 컬렉션을 반환하는데, 원래 리스트는 그대로 두고 쿼리 결과만 새로 만들어 내는 방식이에요.
// <ShowLINQ>
private static void ShowLINQ()
{
List<Element> elements = BuildList();
// LINQ Query.
var subset = from theElement in elements
where theElement.AtomicNumber < 22
orderby theElement.Name
select theElement;
foreach (Element theElement in subset)
{
Console.WriteLine(theElement.Name + " " + theElement.AtomicNumber);
}
// Output:
// Calcium 20
// Potassium 19
// Scandium 21
}
private static List<Element> BuildList() => new()
{
{ new(){ Symbol="K", Name="Potassium", AtomicNumber=19}},
{ new(){ Symbol="Ca", Name="Calcium", AtomicNumber=20}},
{ new(){ Symbol="Sc", Name="Scandium", AtomicNumber=21}},
{ new(){ Symbol="Ti", Name="Titanium", AtomicNumber=22}}
};
// </ShowLINQ>
더 알아보기
- 배열(Array) — C# 언어의 문법 지원을 받는 배열형 컬렉션
- 컬렉션 식(Collection expressions) — C# 12부터 모든 컬렉션 타입을 초기화하는 방법
- Commonly Used Collection Types — 자주 쓰는 컬렉션 타입 모음
- Selecting a Collection Class — 상황에 맞는 컬렉션 클래스 고르기
- Iterators (C#) — 반복자 자세히 알아보기
- Getting Started with LINQ in C# — LINQ 입문
- Explore indexes and ranges — 인덱스와 범위
- .NET API 참조 — collection — 모든 컬렉션 타입 목록