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 같은 인터페이스도 언어 차원에서 인식돼요.

컬렉션은 객체들의 묶음을 유연하게 다루는 방법을 제공해요. 다양한 컬렉션들은 다음 세 가지 특징으로 구분할 수 있어요.

이런 특징들에 더해, 런타임은 컬렉션의 요소를 추가·제거·수정하지 못하게 막는 특수한 컬렉션도 제공해요. 또 멀티스레드 앱에서 동시 접근에 안전하도록 해 주는 특수한 컬렉션도 따로 있어요.

모든 컬렉션 타입은 .NET API 참조에서 찾을 수 있어요. 더 자세한 내용은 Commonly Used Collection TypesSelecting a Collection Class를 참고하세요.

[!NOTE] 이 문서의 예제를 실행하려면 System.Collections.GenericSystem.Linq 네임스페이스에 대한 using 지시문을 추가해야 할 수 있어요.

배열(Array)xref:System.Array?displayProperty=fullName로 표현되고 C# 언어 차원의 문법 지원을 받아요. 이 문법 덕분에 배열 변수를 더 간결하게 선언할 수 있죠.

xref:System.Span`1?displayProperty=nameWithTyperef 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* 메서드와 Dictionaryxref: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>

더 알아보기