Collections

Collections

properties
Published

April 6, 2025

Abstract

C#에서 여러 데이터를 효율적으로 관리하기 위한 기본적인 구조인 배열(Array)과 이를 기반으로 더 다양한 기능을 제공하는 컬렉션(Collection)의 기초 개념, 그리고 객체 내의 데이터에 배열처럼 접근할 수 있게 해주는 인덱서(Indexer)에 대해 알아봅니다.

All for one, one for all

프로그래밍을 하다 보면 여러 개의 데이터를 하나의 이름으로 묶어서 관리해야 할 때가 많습니다. 예를 들어, 한 반 학생들의 시험 점수, 여러 개의 상품 가격, 달력의 날짜 등을 생각해볼 수 있습니다. 이런 경우 각 데이터를 개별적인 변수로 선언하는 것은 매우 비효율적입니다.

// 비효율적인 방법: 각 점수를 개별 변수로 선언
int score1 = 80;
int score2 = 95;
int score3 = 77;
// ... 학생 수만큼 변수 선언 ...
int score30 = 88;

// 평균 계산? -> score1 + score2 + ... + score30 / 30  => 매우 번거로움

이런 문제를 해결하기 위해 배열(Array) 이라는 개념이 등장했습니다. 배열은 같은 타입의 데이터 여러 개를 메모리 상에 연속적으로 저장하고, 하나의 이름으로 관리할 수 있게 해주는 자료 구조입니다. 배열을 사용하면 인덱스(Index, 각 데이터의 위치를 나타내는 번호)를 통해 각 데이터에 쉽게 접근하고 반복문 등을 이용해 효율적으로 처리할 수 있습니다.

// 효율적인 방법: 배열 사용
int[] scores = new int[30]; // 30개의 int 데이터를 저장할 수 있는 배열 선언

// 데이터 저장 (인덱스는 0부터 시작)
scores[0] = 80;
scores[1] = 95;
scores[2] = 77;
// ...
scores[29] = 88;

// 평균 계산
int sum = 0;
for (int i = 0; i < scores.Length; i++) // Length 속성: 배열의 크기
{
    sum += scores[i];
}
double average = (double)sum / scores.Length;

Console.WriteLine($"총점: {sum}, 평균: {average:F2}");

C#에서는 기본적인 배열 외에도 List<T>, Dictionary<TKey, TValue> 등 다양한 종류의 컬렉션(Collection) 클래스를 제공합니다. 컬렉션은 배열과 유사하게 여러 데이터를 관리하지만, 크기 변경이 자유롭거나 특정 방식(키-값 쌍 등)으로 데이터를 관리하는 등 더 유연하고 강력한 기능을 제공합니다. 하지만 많은 컬렉션들이 내부적으로 배열을 사용하거나 배열의 개념을 확장한 것이므로, 배열을 이해하는 것은 컬렉션을 이해하는 데 중요한 기초가 됩니다.

배열을 초기화하는 세 가지 방법

C#에서 배열을 선언하고 초기화(초기값을 할당하는 것)하는 방법은 크게 세 가지가 있습니다.

방법 1: 배열의 크기를 지정하여 선언하고, 각 요소에 값을 할당

가장 기본적인 방법입니다. new 키워드와 함께 배열의 타입과 크기를 지정하여 배열 객체를 생성한 후, 인덱스를 사용하여 각 요소에 값을 할당합니다.

// 1. 크기가 5인 int 배열 선언
int[] numbers = new int[5];

// 2. 각 요소에 값 할당 (인덱스는 0부터 시작)
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
numbers[4] = 50;

// 배열 요소 출력
Console.WriteLine("방법 1:");
foreach (int num in numbers)
{
    Console.Write($"{num} "); // 출력: 10 20 30 40 50
}
Console.WriteLine("\n");

배열을 선언만 하고 초기화하지 않으면, 각 요소는 해당 타입의 기본값으로 자동 초기화됩니다 (예: int는 0, stringnull, boolfalse).

방법 2: 선언과 동시에 중괄호 {}를 사용하여 초기값 목록 제공 (크기 생략)

배열을 선언하면서 동시에 초기값을 지정할 수 있습니다. 중괄호 {} 안에 초기값들을 콤마(,)로 구분하여 나열하면, 컴파일러가 값의 개수를 보고 배열의 크기를 자동으로 결정합니다. new 키워드와 타입은 여전히 필요합니다.

// 배열 선언과 동시에 초기화 (크기는 값의 개수인 3으로 자동 결정됨)
string[] fruits = new string[] { "Apple", "Banana", "Cherry" };

// 배열 요소 출력
Console.WriteLine("방법 2:");
foreach (string fruit in fruits)
{
    Console.Write($"{fruit} "); // 출력: Apple Banana Cherry
}
Console.WriteLine("\n");

방법 3: new 키워드와 타입 선언 생략 (선언과 동시에 초기화할 때만 가능)

배열 변수를 선언하는 동시에 중괄호를 사용하여 초기값을 제공할 경우, new 키워드와 배열 타입 부분을 생략할 수 있습니다. 컴파일러가 변수 선언부의 타입과 중괄호 안의 값들을 보고 타입을 추론합니다.

// new string[] 부분을 생략하고 초기화
string[] colors = { "Red", "Green", "Blue" }; // 컴파일러가 string[] 타입으로 추론

// 배열 요소 출력
Console.WriteLine("방법 3:");
foreach (string color in colors)
{
    Console.Write($"{color} "); // 출력: Red Green Blue
}
Console.WriteLine("\n");

// 주의: 이 방법은 반드시 선언과 동시에 초기화할 때만 사용할 수 있습니다.
// string[] animals;
// animals = { "Dog", "Cat" }; // 컴파일 오류 발생!

이 세 가지 방법 중 상황에 맞게 가장 편리하고 가독성이 좋은 방법을 선택하여 사용하면 됩니다.

알아두면 삶이 윤택해지는 System.Array

C#의 모든 배열(예: int[], string[,] 등)은 내부적으로 System.Array 라는 추상 클래스로부터 상속받습니다. 따라서 System.Array 클래스가 제공하는 다양한 정적(static) 메서드와 인스턴스 속성/메서드를 모든 배열에서 공통적으로 사용할 수 있습니다. 이들을 잘 활용하면 배열을 다루는 코드를 훨씬 간결하고 효율적으로 작성할 수 있습니다.

  • Length (속성): 배열의 총 요소 개수를 반환합니다. (1차원 배열의 길이는 Length로 충분합니다.)
int[] arr = { 1, 2, 3, 4, 5 };
Console.WriteLine($"배열 길이: {arr.Length}"); // 출력: 배열 길이: 5
  • Rank (속성): 배열의 차원 수를 반환합니다. (1차원 배열은 1, 2차원 배열은 2)
int[,] arr2d = { { 1, 2 }, { 3, 4 } };
Console.WriteLine($"배열 차원: {arr.Rank}");    // 출력: 배열 차원: 1
Console.WriteLine($"2차원 배열 차원: {arr2d.Rank}"); // 출력: 2차원 배열 차원: 2
  • GetLength(int dimension) (메서드): 다차원 배열에서 특정 차원의 길이를 반환합니다. 인덱스는 0부터 시작합니다.
// arr2d 는 2x2 배열
Console.WriteLine($"0차원(행) 길이: {arr2d.GetLength(0)}"); // 출력: 0차원(행) 길이: 2
Console.WriteLine($"1차원(열) 길이: {arr2d.GetLength(1)}"); // 출력: 1차원(열) 길이: 2
  • Array.Sort(Array array) (정적 메서드): 배열의 요소들을 오름차순으로 정렬합니다. (문자열은 사전 순)
int[] numbersToSort = { 5, 1, 4, 2, 3 };
Array.Sort(numbersToSort);
Console.Write("정렬된 배열: ");
foreach (int n in numbersToSort) Console.Write($"{n} "); // 출력: 정렬된 배열: 1 2 3 4 5
Console.WriteLine();
  • Array.Reverse(Array array) (정적 메서드): 배열 요소들의 순서를 뒤집습니다.
Array.Reverse(numbersToSort); // 이미 정렬된 배열을 뒤집음
Console.Write("뒤집힌 배열: ");
foreach (int n in numbersToSort) Console.Write($"{n} "); // 출력: 뒤집힌 배열: 5 4 3 2 1
Console.WriteLine();
  • Array.IndexOf(Array array, object? value) (정적 메서드): 배열에서 지정된 값을 찾아 가장 처음 발견되는 요소의 인덱스를 반환합니다. 값이 없으면 -1을 반환합니다.
int index = Array.IndexOf(numbersToSort, 4); // 뒤집힌 배열 {5, 4, 3, 2, 1} 에서 4 찾기
Console.WriteLine($"값 4의 인덱스: {index}"); // 출력: 값 4의 인덱스: 1
index = Array.IndexOf(numbersToSort, 100);
Console.WriteLine($"값 100의 인덱스: {index}"); // 출력: 값 100의 인덱스: -1
  • Array.Clear(Array array, int index, int length) (정적 메서드): 배열의 특정 범위에 있는 요소들을 해당 타입의 기본값으로 초기화합니다. (예: int는 0, stringnull)
Array.Clear(numbersToSort, 1, 2); // 인덱스 1부터 2개 요소(4, 3)를 0으로 초기화
Console.Write("Clear 후 배열: ");
foreach (int n in numbersToSort) Console.Write($"{n} "); // 출력: Clear 후 배열: 5 0 0 2 1
Console.WriteLine();
  • Array.Copy(Array sourceArray, int sourceIndex, Array destinationArray, int destinationIndex, int length) (정적 메서드): 한 배열의 특정 범위 요소를 다른 배열의 특정 위치로 복사합니다.
int[] source = { 1, 2, 3, 4, 5 };
int[] destination = new int[5];
Array.Copy(source, 1, destination, 2, 3); // source의 인덱스 1부터 3개(2, 3, 4)를 destination의 인덱스 2부터 복사
Console.Write("복사된 배열: ");
foreach (int n in destination) Console.Write($"{n} "); // 출력: 복사된 배열: 0 0 2 3 4
Console.WriteLine();

배열 분할하기 (Slicing Arrays)

배열의 일부 요소들만 선택하여 새로운 배열처럼 사용하고 싶을 때가 있습니다. 이를 배열 분할 또는 슬라이싱(Slicing)이라고 합니다. C# 8.0부터 도입된 인덱스(Index) 와 범위(Range) 기능을 사용하면 매우 간결하게 배열을 분할할 수 있습니다.

  • 인덱스 (Index): ^ 연산자를 사용하여 배열의 끝에서부터의 위치를 나타낼 수 있습니다. ^0Length와 같고, ^1은 마지막 요소를 의미합니다.
  • 범위 (Range): .. 연산자를 사용하여 시작 인덱스와 끝 인덱스를 지정하여 배열의 특정 범위를 나타냅니다.
    • start..end: start 인덱스부터 end 인덱스 바로 앞까지의 요소를 포함합니다 (end는 포함되지 않음)
    • ..end: 처음부터 end 인덱스 바로 앞까지
    • start..: start 인덱스부터 끝까지
    • ..: 배열 전체

이 인덱스와 범위 연산자를 배열의 인덱서 [] 안에 사용하면, 해당 범위의 요소들을 포함하는 새로운 배열 (얕은 복사) 또는 Span<T>/ReadOnlySpan<T> (메모리 할당 없이 원본의 일부를 가리킴)를 얻을 수 있습니다. 여기서는 새로운 배열을 얻는 방법을 보여줍니다.

string[] alphabet = { "A", "B", "C", "D", "E", "F", "G" };

// 1. 인덱스 1부터 3까지 (B, C, D) - 끝 인덱스 4는 포함 안 됨
string[] slice1 = alphabet[1..4];
Console.WriteLine($"slice1 (1..4): {string.Join(", ", slice1)}"); // 출력: slice1 (1..4): B, C, D

// 2. 처음부터 인덱스 3까지 (A, B, C) - 끝 인덱스 3은 포함 안 됨
string[] slice2 = alphabet[..3];
Console.WriteLine($"slice2 (..3): {string.Join(", ", slice2)}"); // 출력: slice2 (..3): A, B, C

// 3. 인덱스 3부터 끝까지 (D, E, F, G)
string[] slice3 = alphabet[3..];
Console.WriteLine($"slice3 (3..): {string.Join(", ", slice3)}"); // 출력: slice3 (3..): D, E, F, G

// 4. 끝에서 3번째 요소부터 끝까지 (^3은 인덱스 Length-3 = 7-3 = 4 인 'E' 위치)
//    실제로는 ^3 == 인덱스 4 를 의미. 따라서 인덱스 4부터 끝까지 (E, F, G)
string[] slice4 = alphabet[^3..];
Console.WriteLine($"slice4 (^3..): {string.Join(", ", slice4)}"); // 출력: slice4 (^3..): E, F, G

// 5. 처음부터 끝에서 2번째 요소까지 (^2는 인덱스 Length-2 = 7-2 = 5 인 'F' 위치)
//    ..^2 는 인덱스 5 '미만'까지이므로, 인덱스 0~4 (A, B, C, D, E)
string[] slice5 = alphabet[..^2];
Console.WriteLine($"slice5 (..^2): {string.Join(", ", slice5)}"); // 출력: slice5 (..^2): A, B, C, D, E

// 6. 인덱스 1부터 끝에서 1번째 요소까지 (인덱스 1부터 ^1 미만까지)
//    ^1은 마지막 요소(G, 인덱스 6)을 의미. ^1 미만은 인덱스 6 미만이므로 1~5 (B, C, D, E, F)
string[] slice6 = alphabet[1..^1];
Console.WriteLine($"slice6 (1..^1): {string.Join(", ", slice6)}"); // 출력: slice6 (1..^1): B, C, D, E, F

// 전체 복사
string[] sliceAll = alphabet[..];
Console.WriteLine($"sliceAll (..): {string.Join(", ", sliceAll)}"); // 출력: sliceAll (..): A, B, C, D, E, F, G

// 원본 배열은 변경되지 않음
Console.WriteLine($"Original: {string.Join(", ", alphabet)}"); // 출력: Original: A, B, C, D, E, F, G

주의: 배열 슬라이싱(array[range])은 해당 범위의 요소들을 새로운 배열에 복사하여 반환합니다 (얕은 복사). 따라서 슬라이싱으로 얻은 배열을 수정해도 원본 배열에는 영향을 주지 않습니다. 만약 메모리 할당 없이 원본 배열의 일부를 직접 참조하고 싶다면 Span<T> 또는 ReadOnlySpan<T>를 사용해야 합니다.

// Span<T> 사용 예시 (메모리 할당 없음)
Span<string> spanSlice = alphabet.AsSpan()[1..4]; // 인덱스 1, 2, 3 (B, C, D)
spanSlice[0] = "Z"; // spanSlice의 첫번째 요소(원본 배열의 인덱스 1) 변경

// 원본 배열 확인 -> 변경됨!
Console.WriteLine($"Original after Span modification: {string.Join(", ", alphabet)}");
// 출력: Original after Span modification: A, Z, C, D, E, F, G

2차원 배열 (2D Arrays)

1차원 배열이 데이터를 한 줄로 나열하는 것이라면, 2차원 배열은 데이터를 행(row)과 열(column)으로 이루어진 표(table) 또는 격자(grid) 형태로 저장하는 배열입니다. 수학의 행렬(matrix)과 유사한 구조입니다. C#에서는 콤마(,)를 사용하여 차원을 구분하는 사각형 배열(Rectangular Array) 형태로 2차원 배열을 선언합니다.

선언 및 초기화

// 방법 1: 크기 지정 후 요소 할당
int[,] matrix = new int[2, 3]; // 2행 3열 크기의 int 2차원 배열 선언

matrix[0, 0] = 1; matrix[0, 1] = 2; matrix[0, 2] = 3; // 첫 번째 행 (인덱스 0)
matrix[1, 0] = 4; matrix[1, 1] = 5; matrix[1, 2] = 6; // 두 번째 행 (인덱스 1)

// 방법 2: 선언과 동시에 초기화 (중괄호 사용)
int[,] matrix2 = new int[,]
{
    { 1, 2, 3 }, // 첫 번째 행
    { 4, 5, 6 }  // 두 번째 행
};

// 방법 3: new int[,] 생략 (선언과 동시에 초기화 시)
int[,] matrix3 =
{
    { 1, 2, 3 },
    { 4, 5, 6 }
};

요소 접근

2차원 배열의 요소에 접근할 때는 [행 인덱스, 열 인덱스] 형식을 사용합니다. 두 인덱스 모두 0부터 시작합니다.

Console.WriteLine($"matrix[0, 1]: {matrix[0, 1]}"); // 출력: matrix[0, 1]: 2
Console.WriteLine($"matrix[1, 2]: {matrix[1, 2]}"); // 출력: matrix[1, 2]: 6

크기 확인

  • Rank 속성은 차원 수 (2차원이므로 2)를 반환합니다.
  • Length 속성은 전체 요소 개수 (행 * 열)를 반환합니다.
  • GetLength(dimension) 메서드를 사용하여 특정 차원의 길이를 알 수 있습니다.
    • GetLength(0): 행(row)의 개수
    • GetLength(1): 열(column)의 개수
Console.WriteLine($"matrix의 차원: {matrix.Rank}");         // 출력: matrix의 차원: 2
Console.WriteLine($"matrix의 총 요소 수: {matrix.Length}"); // 출력: matrix의 총 요소 수: 6
Console.WriteLine($"matrix의 행 개수: {matrix.GetLength(0)}"); // 출력: matrix의 행 개수: 2
Console.WriteLine($"matrix의 열 개수: {matrix.GetLength(1)}"); // 출력: matrix의 열 개수: 3

반복문 활용

2차원 배열의 모든 요소를 순회할 때는 보통 중첩된 for 루프를 사용합니다.

Console.WriteLine("\nmatrix3 요소 출력:");
for (int i = 0; i < matrix3.GetLength(0); i++) // 행 반복 (0부터 행 개수 - 1 까지)
{
    for (int j = 0; j < matrix3.GetLength(1); j++) // 열 반복 (0부터 열 개수 - 1 까지)
    {
        Console.Write($"{matrix3[i, j]} ");
    }
    Console.WriteLine(); // 한 행 출력이 끝나면 줄 바꿈
}
/* 출력:
matrix3 요소 출력:
1 2 3
4 5 6
*/

다차원 배열 (Multidimensional Arrays)

2차원 배열을 확장하여 3차원, 4차원 등 더 높은 차원의 배열도 만들 수 있습니다. 이를 다차원 배열이라고 합니다. C#에서는 2차원 배열과 마찬가지로 콤마(,)를 사용하여 차원을 구분합니다.

  • 3차원 배열: 데이터타입[,,]
  • 4차원 배열: 데이터타입[,,,]
  • N차원 배열: 콤마 (N-1)개 사용

선언 및 초기화 (3차원 배열 예시)

// 2개의 층(depth), 각 층은 3개의 행(row), 각 행은 4개의 열(column)로 구성된 3차원 배열
int[,,] cube = new int[2, 3, 4]; // 크기: 2 x 3 x 4

// 요소 할당 예시
cube[0, 0, 0] = 1;
cube[0, 1, 2] = 5;
cube[1, 2, 3] = 10;

// 선언과 동시에 초기화
int[,,] cube2 = new int[,,]
{
    { // 층 0 (인덱스 0)
        { 1, 2, 3, 4 },   // 행 0
        { 5, 6, 7, 8 },   // 행 1
        { 9, 10, 11, 12 } // 행 2
    },
    { // 층 1 (인덱스 1)
        { 13, 14, 15, 16 }, // 행 0
        { 17, 18, 19, 20 }, // 행 1
        { 21, 22, 23, 24 }  // 행 2
    }
};

// new int[,,] 생략 가능
int[,,] cube3 =
{
    { { 1, 2, 3, 4 }, { 5, 6, 7, 8 }, { 9, 10, 11, 12 } },
    { { 13, 14, 15, 16 }, { 17, 18, 19, 20 }, { 21, 22, 23, 24 } }
};

요소 접근

해당 차원의 개수만큼 인덱스를 콤마로 구분하여 지정합니다.

Console.WriteLine($"cube2[0, 1, 2]: {cube2[0, 1, 2]}"); // 출력: cube2[0, 1, 2]: 7
Console.WriteLine($"cube2[1, 2, 3]: {cube2[1, 2, 3]}"); // 출력: cube2[1, 2, 3]: 24

크기 확인

RankLength 속성은 동일하게 작동합니다. GetLength(dimension) 메서드를 사용하여 각 차원의 길이를 확인할 수 있습니다.

Console.WriteLine($"\ncube2의 차원: {cube2.Rank}");             // 출력: cube2의 차원: 3
Console.WriteLine($"cube2의 총 요소 수: {cube2.Length}");     // 출력: cube2의 총 요소 수: 24 (2*3*4)
Console.WriteLine($"cube2의 0차원 길이(층): {cube2.GetLength(0)}"); // 출력: cube2의 0차원 길이(층): 2
Console.WriteLine($"cube2의 1차원 길이(행): {cube2.GetLength(1)}"); // 출력: cube2의 1차원 길이(행): 3
Console.WriteLine($"cube2의 2차원 길이(열): {cube2.GetLength(2)}"); // 출력: cube2의 2차원 길이(열): 4

반복문 활용

다차원 배열을 순회하려면 차원의 개수만큼 for 루프를 중첩하여 사용합니다.

Console.WriteLine("\ncube3 요소 출력:");
for (int i = 0; i < cube3.GetLength(0); i++) // 층 반복
{
    Console.WriteLine($"Layer {i}:");
    for (int j = 0; j < cube3.GetLength(1); j++) // 행 반복
    {
        Console.Write("  Row " + j + ": [ ");
        for (int k = 0; k < cube3.GetLength(2); k++) // 열 반복
        {
            Console.Write($"{cube3[i, j, k]} ");
        }
        Console.WriteLine("]");
    }
}
/* 출력:
cube3 요소 출력:
Layer 0:
  Row 0: [ 1 2 3 4 ]
  Row 1: [ 5 6 7 8 ]
  Row 2: [ 9 10 11 12 ]
Layer 1:
  Row 0: [ 13 14 15 16 ]
  Row 1: [ 17 18 19 20 ]
  Row 2: [ 21 22 23 24 ]
*/

다차원 배열은 특정 구조(예: 3D 공간 좌표, 다년간의 월별 데이터 등)를 표현하는 데 유용할 수 있지만, 차원이 높아질수록 구조가 복잡해지고 관리하기 어려워질 수 있습니다. 꼭 필요한 경우가 아니라면 클래스나 구조체를 활용하여 데이터를 더 명확하게 모델링하는 것이 좋을 수 있습니다.

가변 배열 (Jagged Arrays)

앞서 살펴본 2차원 또는 다차원 배열(Rectangular Array)은 모든 행이 동일한 개수의 열을 가지는 직사각형 형태였습니다. 하지만 때로는 각 행(row)마다 다른 개수의 열(column)을 가져야 하는 경우가 있습니다. 예를 들어, 각 학급의 학생 수가 다른 경우 학생들의 시험 점수를 저장하거나, 들쭉날쭉한 데이터를 표현해야 할 때 유용합니다.

이럴 때 사용하는 것이 가변 배열(Jagged Array)입니다. 이름에서 알 수 있듯이, 각 행(내부 배열)의 길이가 가변적일 수 있습니다. 가변 배열은 실제로는 배열의 배열(array of arrays) 형태로 구현됩니다. 즉, 외부 배열의 각 요소가 또 다른 배열을 참조하는 방식입니다.

선언

가변 배열은 대괄호 []를 차원만큼 반복해서 사용하여 선언합니다. 예를 들어, 2차원 가변 배열은 데이터타입[][]으로 선언합니다. 선언 시에는 가장 바깥쪽 배열(행의 개수)의 크기만 지정합니다.

// int 배열을 요소로 가지는 외부 배열 선언 (총 3개의 내부 배열을 가질 수 있음)
int[][] jaggedArray = new int[3][];

초기화:

가변 배열은 외부 배열 선언 후, 각 내부 배열을 개별적으로 new 키워드를 사용하여 생성하고 할당해야 합니다. 이때 각 내부 배열의 크기는 서로 달라도 됩니다.

// 각 내부 배열(행)을 서로 다른 크기로 초기화
jaggedArray[0] = new int[5];      // 첫 번째 행은 5개의 열을 가짐
jaggedArray[1] = new int[3];      // 두 번째 행은 3개의 열을 가짐
jaggedArray[2] = new int[4];      // 세 번째 행은 4개의 열을 가짐

// 이제 각 요소에 값을 할당할 수 있습니다.
jaggedArray[0][0] = 1;
jaggedArray[1][1] = 2;
jaggedArray[2][3] = 3;
// ... 나머지 요소들도 할당 가능

선언과 동시에 초기화할 수도 있습니다. 이때는 중첩된 중괄호 {}를 사용하며, 각 내부 배열 리터럴의 길이는 달라도 됩니다.

// 선언과 동시에 초기화 (각 행의 길이가 다름)
int[][] jaggedArray2 = new int[][]
{
    new int[] { 1, 2, 3 },         // 첫 번째 행 (길이 3)
    new int[] { 4, 5 },            // 두 번째 행 (길이 2)
    new int[] { 6, 7, 8, 9 }     // 세 번째 행 (길이 4)
};

// new int[][] 부분 생략 가능 (선언과 동시에 초기화 시)
int[][] jaggedArray3 =
{
    new int[] { 1, 2, 3 },
    new int[] { 4, 5 },
    new int[] { 6, 7, 8, 9 }
};

요소 접근

가변 배열의 요소에 접근할 때는 배열이름[행_인덱스][열_인덱스] 형식을 사용합니다. 첫 번째 인덱스는 외부 배열(행)을 선택하고, 두 번째 인덱스는 선택된 내부 배열 내에서의 요소 위치(열)를 나타냅니다.

Console.WriteLine($"jaggedArray2[0][1]: {jaggedArray2[0][1]}"); // 출력: jaggedArray2[0][1]: 2
Console.WriteLine($"jaggedArray2[2][3]: {jaggedArray2[2][3]}"); // 출력: jaggedArray2[2][3]: 9

길이 확인

  • 배열이름.Length: 외부 배열의 크기, 즉 행(내부 배열)의 개수를 반환합니다.
  • 배열이름[i].Length: i번째 내부 배열(행)의 길이(해당 행의 열 개수)를 반환합니다. 이 길이는 행마다 다를 수 있습니다.
Console.WriteLine($"jaggedArray2의 행 개수: {jaggedArray2.Length}"); // 출력: jaggedArray2의 행 개수: 3

Console.WriteLine($"첫 번째 행의 열 개수: {jaggedArray2[0].Length}"); // 출력: 첫 번째 행의 열 개수: 3
Console.WriteLine($"두 번째 행의 열 개수: {jaggedArray2[1].Length}"); // 출력: 두 번째 행의 열 개수: 2
Console.WriteLine($"세 번째 행의 열 개수: {jaggedArray2[2].Length}"); // 출력: 세 번째 행의 열 개수: 4

반복문 활용

가변 배열의 모든 요소를 순회하려면 중첩된 for 루프를 사용합니다. 중요한 점은 내부 루프의 반복 조건이 외부 루프의 현재 인덱스(i)에 해당하는 내부 배열의 길이(배열이름[i].Length)가 되어야 한다는 것입니다.

Console.WriteLine("\njaggedArray3 요소 출력:");
for (int i = 0; i < jaggedArray3.Length; i++) // 행 반복 (0부터 행 개수 - 1 까지)
{
    Console.Write($"Row {i}: [ ");
    // 내부 루프는 현재 행(jaggedArray3[i])의 길이만큼 반복
    for (int j = 0; j < jaggedArray3[i].Length; j++)
    {
        Console.Write($"{jaggedArray3[i][j]} ");
    }
    Console.WriteLine("]");
}
/* 출력:
jaggedArray3 요소 출력:
Row 0: [ 1 2 3 ]
Row 1: [ 4 5 ]
Row 2: [ 6 7 8 9 ]
*/

가변 배열은 각 행의 길이가 다른 데이터를 효율적으로 저장하고 관리할 수 있는 유연한 방법을 제공합니다. 하지만 구조가 직사각형 배열보다 복잡할 수 있으므로, 데이터의 특성을 고려하여 적절한 배열 타입을 선택하는 것이 중요합니다.

컬렉션 맛보기 (A Taste of Collections)

배열은 크기가 고정되어 있고 같은 타입의 데이터만 저장할 수 있다는 제약이 있습니다. 실제 프로그래밍에서는 크기가 동적으로 변하거나, 다양한 타입의 데이터를 저장하거나, 특정 방식(예: 키-값 쌍)으로 데이터를 효율적으로 관리해야 하는 경우가 많습니다. 이를 위해 C#은 컬렉션(Collection) 이라는 다양한 클래스들을 제공합니다.

컬렉션은 System.Collections 또는 System.Collections.Generic 네임스페이스에 정의되어 있습니다. 여기서는 기본적인 컬렉션 몇 가지를 간단히 살펴보겠습니다.

(주의) 아래 소개하는 ArrayList, Queue, Stack, HashtableSystem.Collections 네임스페이스에 속하는 비제네릭(Non-Generic) 컬렉션입니다. 이들은 모든 타입의 객체(object)를 저장할 수 있어 유연하지만, 값을 꺼내 사용할 때 원래 타입으로 형변환(casting)해야 하고, 컴파일 시 타입 오류를 잡기 어렵다는 단점이 있습니다. 또한 값 타입(value type, 예: int, struct)을 저장할 때 박싱(Boxing)/언박싱(Unboxing)이 발생하여 성능 저하의 원인이 될 수 있습니다.

현대의 C# 프로그래밍에서는 타입 안정성(type safety)과 성능상의 이점 때문에 가능하면 System.Collections.Generic 네임스페이스의 제네릭(Generic) 컬렉션 (List<T>, Queue<T>, Stack<T>, Dictionary<TKey, TValue> 등)을 사용하는 것이 강력히 권장됩니다. 여기서는 컬렉션의 기본적인 개념을 소개하는 차원에서 비제네릭 컬렉션을 간단히 살펴봅니다.

  • ArrayList
    • 내부적으로 배열을 사용하여 데이터를 관리하지만, 필요에 따라 자동으로 크기가 조절되는 동적 배열입니다.
    • 어떤 타입의 데이터(object)든 저장할 수 있습니다.
    • Add() 메서드로 요소를 추가하고, 인덱스([])를 사용하여 요소에 접근합니다. Remove() 또는 RemoveAt()으로 요소를 제거할 수 있습니다.
    using System.Collections; // ArrayList 사용을 위해 필요
    
    // ArrayList 생성
    ArrayList list = new ArrayList();
    
    // 요소 추가 (다양한 타입 저장 가능)
    list.Add(10);
    list.Add("Hello");
    list.Add(3.14);
    list.Add(true);
    
    // 요소 접근 (형변환 필요)
    int firstItem = (int)list[0];
    string secondItem = (string)list[1];
    
    Console.WriteLine($"ArrayList 첫 번째 요소: {firstItem}"); // 출력: 10
    Console.WriteLine($"ArrayList 두 번째 요소: {secondItem}"); // 출력: Hello
    
    // 요소 개수 확인
    Console.WriteLine($"ArrayList 크기: {list.Count}"); // 출력: 4
    
    // 요소 순회
    Console.Write("ArrayList 요소: ");
    foreach (object item in list)
    {
        Console.Write($"{item} "); // 출력: 10 Hello 3.14 True
    }
    Console.WriteLine("\n");
  • Queue
    • 먼저 들어온 데이터가 먼저 나가는 FIFO(First-In, First-Out) 방식의 자료구조입니다. 마치 줄서기와 같습니다.
    • Enqueue() 메서드로 큐의 맨 뒤에 데이터를 추가합니다.
    • Dequeue() 메서드로 큐의 맨 앞에 있는 데이터를 꺼냅니다 (꺼낸 데이터는 큐에서 제거됨).
    • Peek() 메서드로 맨 앞의 데이터를 확인만 하고 제거하지는 않습니다.
    using System.Collections;
    
    Queue queue = new Queue();
    
    // 데이터 추가 (Enqueue)
    queue.Enqueue("Task 1");
    queue.Enqueue("Task 2");
    queue.Enqueue("Task 3");
    
    Console.WriteLine($"Queue 크기: {queue.Count}"); // 출력: 3
    
    // 데이터 꺼내기 (Dequeue)
    Console.WriteLine($"처리할 작업: {queue.Dequeue()}"); // 출력: Task 1
    Console.WriteLine($"다음 작업 확인: {queue.Peek()}");   // 출력: Task 2 (제거되지 않음)
    Console.WriteLine($"처리할 작업: {queue.Dequeue()}"); // 출력: Task 2
    Console.WriteLine($"Queue 크기: {queue.Count}"); // 출력: 1
  • Stack
    • 가장 나중에 들어온 데이터가 가장 먼저 나가는 LIFO(Last-In, First-Out) 방식의 자료구조입니다. 접시 쌓기와 비슷합니다.
    • Push() 메서드로 스택의 맨 위에 데이터를 추가합니다.
    • Pop() 메서드로 스택의 맨 위에 있는 데이터를 꺼냅니다 (꺼낸 데이터는 스택에서 제거됨).
    • Peek() 메서드로 맨 위의 데이터를 확인만 하고 제거하지는 않습니다.
    using System.Collections;
    
    Stack stack = new Stack();
    
    // 데이터 추가 (Push)
    stack.Push("Page 1");
    stack.Push("Page 2");
    stack.Push("Page 3"); // 가장 마지막에 추가됨
    
    Console.WriteLine($"Stack 크기: {stack.Count}"); // 출력: 3
    
    // 데이터 꺼내기 (Pop)
    Console.WriteLine($"현재 페이지: {stack.Pop()}");    // 출력: Page 3 (가장 마지막 추가)
    Console.WriteLine($"이전 페이지 확인: {stack.Peek()}"); // 출력: Page 2 (제거되지 않음)
    Console.WriteLine($"현재 페이지: {stack.Pop()}");    // 출력: Page 2
    Console.WriteLine($"Stack 크기: {stack.Count}"); // 출력: 1
  • Hashtable
    • 키(Key) 와 값(Value) 을 쌍으로 저장하는 컬렉션입니다. 사전(Dictionary)과 유사합니다.
    • 키를 사용하여 값을 빠르게 찾아올 수 있습니다 (내부적으로 해싱(Hashing) 기법 사용).
    • 키는 고유해야 하며, null을 키로 사용할 수 있습니다. 값은 중복되거나 null일 수 있습니다.
    • 인덱서([])에 키를 넣어 값을 추가하거나 가져옵니다.
    using System.Collections;
    
    Hashtable hashtable = new Hashtable();
    
    // 키-값 쌍 추가
    hashtable.Add("Name", "홍길동");
    hashtable["Age"] = 30; // 인덱서를 사용한 추가/수정
    hashtable["City"] = "서울";
    
    // 값 가져오기 (키 사용, 형변환 필요)
    string name = (string)hashtable["Name"];
    int age = (int)hashtable["Age"];
    
    Console.WriteLine($"이름: {name}, 나이: {age}"); // 출력: 이름: 홍길동, 나이: 30
    
    // 키 존재 여부 확인
    if (hashtable.ContainsKey("City"))
    {
        Console.WriteLine($"도시: {hashtable["City"]}"); // 출력: 도시: 서울
    }
    
    // 모든 키-값 쌍 순회
    Console.WriteLine("Hashtable 내용:");
    foreach (DictionaryEntry entry in hashtable)
    {
        Console.WriteLine($"  {entry.Key}: {entry.Value}");
    }
    /* 출력: (순서는 보장되지 않음)
      Hashtable 내용:
        City: 서울
        Age: 30
        Name: 홍길동
    */

이 외에도 SortedList, BitArray 등 다양한 비제네릭 컬렉션이 있지만, 앞서 강조했듯이 특별한 이유가 없다면 제네릭 컬렉션(List<T>, Dictionary<TKey, TValue> 등)을 사용하는 것이 좋습니다. 제네릭 컬렉션은 다음 장들에서 더 자세히 다루게 될 것입니다.

컬렉션을 초기화하는 방법

컬렉션을 생성한 후 Add()와 같은 메서드를 반복적으로 호출하여 요소를 추가하는 대신, C#에서는 컬렉션을 선언과 동시에 초기화할 수 있는 간결한 방법을 제공합니다. 이를 컬렉션 이니셜라이저(Collection Initializer) 라고 합니다.

컬렉션 이니셜라이저는 컬렉션 객체를 생성하는 new 표현식 뒤에 중괄호 {}를 붙이고, 그 안에 추가할 요소들을 콤마(,)로 구분하여 나열하는 방식입니다. 이 문법은 해당 컬렉션 클래스가 System.Collections.IEnumerable 인터페이스를 구현하고 Add 메서드를 가지고 있을 때 사용할 수 있습니다. (대부분의 표준 컬렉션이 이를 만족합니다.)

using System.Collections.Generic; // 제네릭 컬렉션 사용

// 기존 방식
List<int> numbersOld = new List<int>();
numbersOld.Add(1);
numbersOld.Add(2);
numbersOld.Add(3);

// 컬렉션 이니셜라이저 사용
List<int> numbersNew = new List<int> { 1, 2, 3 };

// 컬렉션 이니셜라이저 (var 키워드와 함께)
var names = new List<string> { "Alice", "Bob", "Charlie" };

Console.WriteLine("numbersNew 요소:");
foreach (int num in numbersNew)
{
    Console.Write($"{num} "); // 출력: 1 2 3
}
Console.WriteLine();

Console.WriteLine("\nnames 요소:");
foreach (string name in names)
{
    Console.Write($"{name} "); // 출력: Alice Bob Charlie
}
Console.WriteLine();

Dictionary와 같이 키-값 쌍을 저장하는 컬렉션의 경우, 중괄호 {} 안에 다시 중괄호 {}를 사용하여 각 키-값 쌍을 지정합니다. 각 내부 중괄호는 { key, value } 형태를 가집니다.

using System.Collections.Generic;

// Dictionary 초기화
var ages = new Dictionary<string, int>
{
    { "Alice", 30 }, // { 키, 값 }
    { "Bob", 25 },
    ["Charlie"] = 35 // C# 6.0부터 인덱서 문법도 사용 가능
};

Console.WriteLine("\nages 내용:");
foreach (var kvp in ages) // KeyValuePair<string, int>
{
    Console.WriteLine($"  {kvp.Key}: {kvp.Value}");
}
/* 출력:
ages 내용:
  Alice: 30
  Bob: 25
  Charlie: 35
*/

배열 초기화와의 비교

배열 초기화 문법과 매우 유사하지만, 컬렉션 이니셜라이저는 new 키워드와 컬렉션 타입 생성자 호출 뒤에 온다는 차이가 있습니다.

// 배열 초기화 (방법 3)
string[] colorsArray = { "Red", "Green", "Blue" };

// List<string> 컬렉션 초기화
List<string> colorsList = new List<string> { "Red", "Green", "Blue" };

컬렉션 이니셜라이저를 사용하면 코드가 훨씬 간결해지고 가독성이 높아지므로, 컬렉션 생성 시 초기값을 알고 있다면 적극적으로 활용하는 것이 좋습니다.

인덱서 (Indexers)

인덱서(Indexer) 는 객체의 멤버에 배열과 같이 대괄호 []를 사용하여 접근할 수 있게 해주는 특별한 종류의 속성(property)입니다. 클래스 내부에 배열이나 리스트 같은 컬렉션 데이터를 가지고 있을 때, 이 데이터를 외부에서 마치 해당 객체가 배열인 것처럼 편리하게 접근하도록 캡슐화하는 데 사용됩니다.

예를 들어, myObject[5] 와 같은 코드를 실행했을 때, 객체 내부의 특정 로직을 통해 5번째 데이터를 가져오거나 설정할 수 있게 만듭니다.

인덱서 정의

인덱서는 클래스 내부에 정의하며, 다음과 같은 형식을 가집니다.

public T this[IndexType index] // T: 반환 타입, IndexType: 인덱스 파라미터 타입
{
    get
    {
        // 인덱스를 사용하여 값을 반환하는 로직
        // return value;
    }
    set
    {
        // 인덱스를 사용하여 값을 설정하는 로직
        // value 키워드로 입력값을 받음
    }
}
  • this 키워드: 인덱서임을 나타내는 키워드입니다. 속성 이름 대신 사용됩니다.
  • T: 인덱서를 통해 접근할 요소의 데이터 타입입니다.
  • IndexType index: 인덱스로 사용될 파라미터의 타입과 이름입니다. 정수(int), 문자열(string) 등 다양한 타입을 사용할 수 있습니다.
  • get 접근자: 인덱서를 사용하여 값을 읽을 때 호출됩니다. 반드시 값을 반환해야 합니다.
  • set 접근자: 인덱서를 사용하여 값을 할당할 때 호출됩니다. value 키워드를 통해 할당되는 값을 참조할 수 있습니다. get 또는 set 중 하나만 정의할 수도 있습니다 (읽기 전용 또는 쓰기 전용 인덱서).

인덱서 사용 예시

간단한 정수 배열을 내부에 가지고 관리하는 클래스를 만들고 인덱서를 구현해 보겠습니다.

using System;

public class MyIntegerCollection
{
    private int[] data = new int[10]; // 내부적으로 int 배열 사용

    // 인덱서 정의 (int 인덱스를 받음)
    public int this[int index]
    {
        get
        {
            if (index < 0 || index >= data.Length)
            {
                throw new IndexOutOfRangeException("인덱스가 범위를 벗어났습니다.");
            }
            return data[index]; // 내부 배열의 요소 반환
        }
        set
        {
            if (index < 0 || index >= data.Length)
            {
                throw new IndexOutOfRangeException("인덱스가 범위를 벗어났습니다.");
            }
            data[index] = value; // 내부 배열에 값 설정 (value 키워드 사용)
        }
    }
}

// 인덱서 사용
public class IndexerExample
{
    public static void Main(string[] args)
    {
        MyIntegerCollection collection = new MyIntegerCollection();

        // 인덱서를 사용하여 값 설정 (set 접근자 호출)
        collection[0] = 100;
        collection[1] = 200;

        // 인덱서를 사용하여 값 읽기 (get 접근자 호출)
        Console.WriteLine($"collection[0]: {collection[0]}"); // 출력: collection[0]: 100
        Console.WriteLine($"collection[1]: {collection[1]}"); // 출력: collection[1]: 200
        // Console.WriteLine($"collection[10]: {collection[10]}"); // IndexOutOfRangeException 발생

        // 클래스 내부의 data 배열에 직접 접근할 수는 없음 (캡슐화)
        // collection.data[0] = 50; // 컴파일 오류! (private 멤버)
    }
}

인덱서 오버로딩

메서드 오버로딩처럼, 인덱서도 파라미터의 타입이나 개수를 다르게 하여 여러 개 정의할 수 있습니다. 예를 들어, 정수 인덱스와 문자열 인덱스를 모두 가질 수 있습니다.

public class DataStore
{
    private string[] names = { "Apple", "Banana", "Cherry" };
    private Dictionary<string, int> prices = new Dictionary<string, int>
    {
        {"Apple", 1000}, {"Banana", 500}, {"Cherry", 1500}
    };

    // int 인덱서 (이름 배열 접근)
    public string this[int index]
    {
        get { return names[index]; }
    }

    // string 인덱서 (가격 Dictionary 접근)
    public int this[string key]
    {
        get { return prices[key]; }
        set { prices[key] = value; }
    }
}

// 사용
DataStore store = new DataStore();
Console.WriteLine($"첫 번째 과일 이름: {store[0]}"); // 출력: Apple (int 인덱서 사용)
Console.WriteLine($"바나나 가격: {store["Banana"]}"); // 출력: 500 (string 인덱서 사용)
store["Apple"] = 1200; // 가격 변경
Console.WriteLine($"사과 변경된 가격: {store["Apple"]}"); // 출력: 1200

인덱서는 클래스 내부 구현을 숨기면서도 외부 사용자에게 편리하고 직관적인 데이터 접근 인터페이스를 제공하는 강력한 기능입니다. C#의 많은 컬렉션 클래스들(List<T>, Dictionary<TKey, TValue> 등)이 내부적으로 인덱서를 사용하여 요소 접근을 구현합니다.

foreach가 가능한 객체 만들기

foreach 반복문은 배열이나 컬렉션의 모든 요소를 간편하게 순회할 수 있는 매우 유용한 기능입니다. 그런데 우리가 직접 만든 클래스의 객체도 foreach 문으로 순회할 수 있게 만들 수는 없을까요? 예를 들어, 여러 개의 데이터를 내부적으로 관리하는 사용자 정의 컬렉션 클래스가 있다고 가정해 봅시다.

MyCustomCollection myCollection = new MyCustomCollection();
// ... 데이터 추가 ...

// 이렇게 사용하고 싶다!
foreach (var item in myCollection)
{
    Console.WriteLine(item);
}

이렇게 하려면, 해당 클래스가 IEnumerable 또는 IEnumerable<T> 인터페이스를 구현해야 합니다. 이 인터페이스들은 객체가 “열거 가능(Enumerable)”하다는 것을 나타내며, foreach 문은 이 인터페이스를 통해 객체의 요소들을 하나씩 가져옵니다.

  • System.Collections.IEnumerable: 비제네릭 인터페이스
  • System.Collections.Generic.IEnumerable<T>: 제네릭 인터페이스 (타입 안전성 제공, 권장됨)

IEnumerable / IEnumerable<T> 인터페이스

이 인터페이스들은 단 하나의 메서드, GetEnumerator()를 정의합니다.

// 비제네릭
public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

// 제네릭
public interface IEnumerable<out T> : IEnumerable
{
    new IEnumerator<T> GetEnumerator(); // 제네릭 버전의 GetEnumerator
}

GetEnumerator() 메서드는 IEnumerator 또는 IEnumerator<T> 인터페이스를 구현하는 객체(반복자, Iterator)를 반환해야 합니다.

IEnumerator / IEnumerator<T> 인터페이스

반복자(Iterator)는 컬렉션의 요소를 하나씩 순회하는 방법을 정의합니다. 주요 멤버는 다음과 같습니다.

  • MoveNext(): 다음 요소로 이동합니다. 다음 요소가 있으면 true를, 더 이상 요소가 없으면 false를 반환합니다. foreach 루프는 이 메서드가 false를 반환할 때까지 반복합니다.
  • Current: 현재 요소를 반환합니다. MoveNext()가 처음 호출되기 전이나 false를 반환한 후에는 정의되지 않을 수 있습니다. (IEnumerator<T>.CurrentT 타입, IEnumerator.Currentobject 타입)
  • Reset(): 반복자를 컬렉션의 처음 위치 이전으로 초기화합니다. (선택적으로 구현되거나 지원되지 않을 수 있음)

구현 방법 1: 수동으로 반복자 클래스 만들기 (복잡함)

IEnumerator<T> 인터페이스를 구현하는 별도의 클래스(주로 중첩 클래스)를 만들고, IEnumerable<T>를 구현하는 주 클래스의 GetEnumerator() 메서드에서 이 반복자 클래스의 인스턴스를 생성하여 반환하는 방식입니다. 반복자 클래스는 현재 순회 위치 등의 상태를 직접 관리해야 합니다.

구현 방법 2: yield return 사용하기 (간편함, 권장됨)

C# 컴파일러는 yield return 키워드를 사용하여 반복자를 훨씬 쉽게 구현할 수 있는 기능을 제공합니다. GetEnumerator() 메서드 본문 안에서 yield return 문을 사용하여 반환할 요소를 지정하면, 컴파일러가 자동으로 IEnumerator<T>를 구현하는 상태 머신(state machine) 클래스를 생성해줍니다. 개발자는 순회 상태 관리에 신경 쓸 필요 없이 순회 로직에만 집중하면 됩니다.

간단한 사용자 정의 컬렉션 클래스를 만들고 IEnumerable<T>yield return을 사용하여 구현해 보겠습니다.

using System;
using System.Collections;
using System.Collections.Generic;

// 간단한 사용자 정의 숫자 컬렉션
public class MyNumberCollection : IEnumerable<int> // IEnumerable<int> 구현
{
    private List<int> numbers = new List<int>();

    public void Add(int number)
    {
        numbers.Add(number);
    }

    // IEnumerable<T>의 GetEnumerator() 구현 (yield return 사용)
    public IEnumerator<int> GetEnumerator()
    {
        for (int i = 0; i < numbers.Count; i++)
        {
            // 컬렉션의 각 요소를 순서대로 반환
            yield return numbers[i];
        }
        // 또는 간단히:
        // foreach (int number in numbers)
        // {
        //     yield return number;
        // }
    }

    // IEnumerable의 GetEnumerator() 구현 (비제네릭 버전)
    // IEnumerable<T>가 IEnumerable을 상속하므로 명시적으로 구현해야 함
    // 보통 제네릭 버전의 GetEnumerator()를 호출하도록 구현
    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
}

// 사용 예시
public class ForeachExample
{
    public static void Main(string[] args)
    {
        MyNumberCollection myCollection = new MyNumberCollection();
        myCollection.Add(10);
        myCollection.Add(20);
        myCollection.Add(30);

        Console.WriteLine("MyCollection 요소 (foreach 사용):");
        // 이제 myCollection 객체를 foreach로 순회할 수 있음!
        foreach (int number in myCollection)
        {
            Console.WriteLine(number);
        }
        /* 출력:
        MyCollection 요소 (foreach 사용):
        10
        20
        30
        */
    }
}

yield return을 사용하면 IEnumerable<T> 인터페이스 구현이 매우 간단해집니다. foreach 루프가 시작되면 GetEnumerator() 메서드가 호출되고, yield return 문을 만날 때마다 해당 값이 foreach 루프의 현재 요소로 반환됩니다. 다음 반복에서는 이전에 멈췄던 yield return 문 다음부터 실행이 재개됩니다. 모든 yield return이 실행되거나 메서드가 끝나면 MoveNext()false를 반환하고 루프가 종료됩니다.

이처럼 IEnumerable<T> 인터페이스와 yield return을 활용하면 사용자 정의 클래스도 C#의 강력한 foreach 기능을 자연스럽게 활용할 수 있습니다.