Collections
Collections
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, string은 null, bool은 false).
방법 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}"); // 출력: 배열 길이: 5Rank(속성): 배열의 차원 수를 반환합니다. (1차원 배열은 1, 2차원 배열은 2)
int[,] arr2d = { { 1, 2 }, { 3, 4 } };
Console.WriteLine($"배열 차원: {arr.Rank}"); // 출력: 배열 차원: 1
Console.WriteLine($"2차원 배열 차원: {arr2d.Rank}"); // 출력: 2차원 배열 차원: 2GetLength(int dimension)(메서드): 다차원 배열에서 특정 차원의 길이를 반환합니다. 인덱스는 0부터 시작합니다.
// arr2d 는 2x2 배열
Console.WriteLine($"0차원(행) 길이: {arr2d.GetLength(0)}"); // 출력: 0차원(행) 길이: 2
Console.WriteLine($"1차원(열) 길이: {arr2d.GetLength(1)}"); // 출력: 1차원(열) 길이: 2Array.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의 인덱스: -1Array.Clear(Array array, int index, int length)(정적 메서드): 배열의 특정 범위에 있는 요소들을 해당 타입의 기본값으로 초기화합니다. (예:int는 0,string은null)
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):^연산자를 사용하여 배열의 끝에서부터의 위치를 나타낼 수 있습니다.^0은Length와 같고,^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, G2차원 배열 (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크기 확인
Rank와 Length 속성은 동일하게 작동합니다. 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, Hashtable은 System.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}"); // 출력: 1Stack- 가장 나중에 들어온 데이터가 가장 먼저 나가는 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}"); // 출력: 1Hashtable- 키(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>.Current는T타입,IEnumerator.Current는object타입)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 기능을 자연스럽게 활용할 수 있습니다.