C# · 심층 가이드
C# 컬렉션 완전 정리
LINQ 지연 실행을 축으로 map과 filter, 그룹화, 빈도표, chunk, sliding window까지 C# 컬렉션 처리 스무 가지를 실행 가능한 조각으로 다룹니다.
C#의 컬렉션 코드는 LINQ를 아는지 모르는지에 따라 완전히 다르게 생깁니다. 반복문으로 열 줄 걸리던 처리가 Select와 Where 두 번의 호출로 줄어드는데, 대신 지연 실행이라는 새로운 사고방식이 따라붙습니다. 쿼리를 만든 시점이 아니라 결과를 열거하는 시점에 계산이 벌어진다는 점, 그리고 반환값이 배열이 아니라 IEnumerable이라는 점이 이 절의 전제입니다. 자바스크립트의 map이나 파이썬의 리스트 컴프리헨션처럼 곧바로 리스트가 나온다고 생각하면 뒤에서 계속 어긋납니다.
map 변환, filter 필터링, reduce 합계는 나머지 예제의 문법적 바탕이라 먼저 읽는 편이 좋습니다. 여기서 groupBy 구현과 빈도표 만들기로 넘어가면 키로 묶는 작업이 두 갈래로 갈리는 게 보이고, chunk 나누기와 sliding window는 같은 시퀀스를 겹치지 않게 자르느냐 겹쳐서 훑느냐의 차이입니다. partition 나누기는 조건 하나로 두 덩어리를 만드는 형태라 filter와 이어 보면 이해가 빠릅니다.
지연 실행이 무는 대표적인 함정이 두 번 열거입니다. Where 결과를 변수에 담아 두고 Count()와 foreach에 각각 쓰면 원본을 두 번 훑습니다. 원본이 데이터베이스 쿼리나 파일 스트림이면 비용이 그대로 두 배가 되고, 중간에 원본이 바뀌면 결과까지 달라집니다. 확정하고 싶은 지점에서 ToList로 고정하는 습관이 필요합니다. 대소문자 무시 중복 제거에서는 기본 비교자가 대소문자를 구분하므로 StringComparer를 명시해 넘겨야 합니다.
01map 변환
Select가 배열 { 1, 2, 3, 1 }의 각 원소를 두 배로 바꾸고, string.Join이 그 결과를 이어 붙입니다. 원본 배열은 그대로 남고 새 시퀀스가 생깁니다. Select 호출 시점에는 아무 계산도 일어나지 않으며, 실제 곱셈은 Join이 열거를 시작해야 수행됩니다.
C# code
// map 변환
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 1 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));Select의 람다는 열거될 때마다 다시 실행되므로, 결과를 두 번 이상 쓸 계획이라면 ToList로 한 번 굳혀 두는 편이 좋습니다.
Select 안에서 로그를 남기거나 값을 바꾸는 부작용을 넣으면, 시퀀스를 몇 번 열거하느냐에 따라 그 동작이 여러 번 일어나거나 아예 실행되지 않습니다.
02filter 필터링
Distinct가 문자열 배열에서 두 번 나온 cs를 하나로 줄여 cs, dotnet, rust를 출력합니다. 조건으로 걸러 내는 것이 아니라 중복만 걷어내는 동작입니다. 값 기준으로 골라내려면 Where가 맞고, Distinct는 처음 등장한 순서를 유지한 채 뒤쪽 중복을 버립니다.
C# code
// filter 필터링
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));직접 만든 타입에 Distinct를 쓰면 Equals와 GetHashCode를 재정의하지 않는 한 참조가 같은 것만 중복으로 봅니다. record는 이 둘이 자동으로 생성됩니다.
Distinct는 지금까지 본 값을 내부 집합에 모두 담아 두므로, 원소가 아주 많은 시퀀스에서는 메모리 사용량이 원소 수에 비례해 늘어납니다.
03reduce 합계
각 원소에 2를 곱해 나열할 뿐, 값을 하나로 접는 누적 연산은 없습니다. 합계라면 Sum, 임의 누적이라면 Aggregate가 대응됩니다. Aggregate는 시드 없이 호출하면 빈 시퀀스에서 예외를 던지므로, 초깃값을 명시하는 오버로드를 쓰는 편이 안전합니다.
C# code
// reduce 합계
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 3 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));Aggregate에는 시드와 누적 함수에 더해 마지막 변환을 받는 오버로드가 있어, 합계를 낸 뒤 평균으로 바꾸는 마무리까지 한 호출에 담을 수 있습니다.
누적 함수 안에서 리스트에 Add하고 같은 리스트를 돌려주는 구현은 접기가 아니라 외부 상태 변경이라, 시퀀스를 다시 열거하면 값이 중복되어 쌓입니다.
04첫 요소 찾기
중복이 섞인 단어 배열에서 Distinct로 고유 값만 남겨 출력합니다. 조건에 맞는 첫 항목을 집어내려면 First 계열을 이어 붙이게 됩니다. 이때 First는 해당 원소가 없으면 예외를 던지고 FirstOrDefault는 기본값을 주는데, int처럼 값 타입에서는 그 기본값이 null이 아니라 0이라 "없음"과 구분되지 않습니다.
C# code
// 첫 요소 찾기
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));FirstOrDefault에는 없을 때 돌려줄 값을 직접 지정하는 오버로드가 있어, 0이나 null 대신 의미가 분명한 기본값을 쓸 수 있습니다.
최솟값을 얻으려고 OrderBy 뒤에 First를 붙이면 전체를 정렬한 뒤 하나만 꺼내게 됩니다. Min이나 MinBy는 한 번의 순회로 끝냅니다.
05Set 중복 제거
{ 1, 2, 3, 5 }의 각 값을 두 배로 만들어 출력하며, 중복 제거는 하지 않습니다. 고유 값만 남기려면 Distinct를 붙이거나 HashSet<T>에 넣습니다. HashSet은 조회가 상수 시간에 가깝지만 저장 순서를 보장하지 않아, 순서가 중요하면 Distinct 쪽을 택합니다.
C# code
// Set 중복 제거
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 5 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));HashSet<T>.Add는 이미 있는 값이면 false를 돌려주므로, 넣기와 중복 판정을 한 번의 호출로 함께 처리할 수 있습니다.
가변 객체를 집합에 넣은 뒤 해시에 쓰이는 속성을 바꾸면 그 원소를 다시 찾지 못합니다. 집합에 담을 값은 넣은 다음 손대지 않아야 합니다.
06단어 수 세기
중복 제거된 단어 목록을 출력하므로 각 단어가 몇 번 나왔는지는 알 수 없습니다. 개수를 세려면 GroupBy 뒤에 Count를 붙이거나 사전에 직접 누적합니다. Distinct().Count()는 종류의 수를, Count()는 전체 원소 수를 돌려주므로 둘을 혼동하면 결과가 달라집니다.
C# code
// 단어 수 세기
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));GroupBy(w => w) 뒤에 ToDictionary(g => g.Key, g => g.Count())를 붙이면 단어별 등장 횟수를 담은 사전이 한 줄로 만들어집니다.
Count()는 조건이 없어도 시퀀스를 끝까지 훑습니다. 원소가 하나라도 있는지만 확인할 때 Count() > 0을 쓰면 불필요한 순회가 생기므로 Any()를 써야 합니다.
07스택 구현
스택 자료구조가 아니라 배열을 두 배로 매핑하는 코드입니다. 후입선출이 필요하면 Stack<T>의 Push와 Pop을 씁니다. Stack<T>를 foreach로 돌면 넣은 순서가 아니라 최근에 넣은 원소부터 나온다는 점이 자주 헷갈리는 부분입니다.
C# code
// 스택 구현
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 7 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));Peek은 꺼내지 않고 맨 위만 확인하며, 비어 있을 때 예외 대신 false를 받고 싶다면 TryPeek과 TryPop을 쓰면 됩니다.
깊은 재귀를 스택으로 바꾸면 StackOverflowException을 피할 수 있는데, 이 예외는 try로 잡을 수 없고 프로세스를 즉시 끝내므로 특히 위험합니다.
08큐 구현
선입선출 큐 대신, 단어 배열의 중복을 없애 출력합니다. 순서대로 꺼내는 처리가 필요하면 Queue<T>의 Enqueue와 Dequeue가 짝을 이룹니다. 비어 있는 큐에서 Dequeue를 부르면 예외가 나므로, 소비 루프에서는 TryDequeue가 더 다루기 쉽습니다.
C# code
// 큐 구현
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));여러 스레드가 함께 쓰는 큐라면 ConcurrentQueue<T>를, 소비자가 데이터를 기다려야 한다면 Channel이나 BlockingCollection을 쓰는 편이 맞습니다.
foreach로 큐를 돌면서 동시에 Dequeue를 부르면 컬렉션이 변경됐다는 예외가 납니다. 소비 루프는 while (queue.Count > 0) 형태로 작성해야 합니다.
092차원 배열 합계
1차원 배열의 각 값을 두 배로 만들 뿐, 중첩 구조를 파고들지는 않습니다. 배열의 배열이라면 SelectMany로 한 겹 펼친 뒤 합산하면 됩니다. 반면 int[,] 같은 다차원 배열은 제네릭 시퀀스가 아니어서 Cast<int>()를 거쳐야 LINQ가 붙습니다.
C# code
// 2차원 배열 합계
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 9 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));int[,]는 GetLength(0)과 GetLength(1)로 각 차원의 길이를 얻으며, Length는 차원과 무관하게 전체 원소 수를 돌려줍니다.
int[][]는 행마다 길이가 다를 수 있어 첫 행 기준으로 안쪽 길이를 가정하면 IndexOutOfRangeException이 납니다. 행마다 Length를 확인해야 합니다.
10groupBy 구현
키별로 묶는 대신 Distinct로 중복 단어를 지웁니다. 그룹핑이 필요하면 GroupBy가 각 키와 그에 속한 원소 목록을 함께 넘겨줍니다. GroupBy는 지연 실행처럼 보여도 첫 결과를 내기 위해 입력 전체를 한 번 읽어 버퍼에 담으므로, 무한 시퀀스에는 쓸 수 없습니다.
C# code
// groupBy 구현
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));각 그룹은 IGrouping<TKey, TElement>라 Key를 읽으면서 동시에 원소를 열거할 수 있고, 원소를 변환하려면 결과 선택자를 받는 오버로드를 쓰면 됩니다.
그룹 키가 참조 타입이면 값이 같아도 Equals를 재정의하지 않는 한 각각 별개의 그룹이 됩니다. 익명 타입이나 record를 키로 쓰면 값 비교가 적용됩니다.
11flatten 펼치기
평탄화 대상이 없는 단일 배열에 곱셈 변환만 적용합니다. 원소 자체가 컬렉션인 경우에는 Select가 시퀀스의 시퀀스를 만들어 버리므로 SelectMany로 바꿔야 합니다. 두 함수의 차이는 결과 타입이 IEnumerable<IEnumerable<T>>냐 IEnumerable<T>냐입니다.
C# code
// flatten 펼치기
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 11 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));SelectMany에는 바깥 원소와 펼쳐진 원소를 함께 받는 결과 선택자 오버로드가 있어, 어느 목록에서 나온 값인지 유지한 채 평탄화할 수 있습니다.
안쪽 컬렉션에 null이 섞여 있으면 SelectMany가 그 자리에서 ArgumentNullException을 던집니다. 미리 걸러 내거나 빈 시퀀스로 대체해야 합니다.
12zip 묶기
배열 하나만 다루므로 두 시퀀스를 짝짓는 동작은 없고, 중복만 제거해 출력합니다. 같은 인덱스끼리 묶으려면 Zip을 씁니다. Zip은 길이가 다르면 예외를 내지 않고 짧은 쪽에서 조용히 멈추므로, 뒤쪽 데이터가 소리 없이 사라질 수 있습니다.
C# code
// zip 묶기
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));Zip에는 결과 선택자 없이 튜플을 돌려주는 오버로드가 있어, foreach (var (a, b) in xs.Zip(ys))처럼 바로 분해해 쓸 수 있습니다.
인덱스로 두 배열을 함께 도는 코드를 Zip으로 바꾸면 길이 불일치가 예외 대신 조용한 누락으로 바뀝니다. 길이가 같아야 한다면 확인하는 코드를 남겨야 합니다.
13chunk 나누기
고정 크기로 자르지 않고 전체 원소를 한 줄에 이어 출력합니다. 일정 개수씩 끊어 처리하려면 .NET 6에 추가된 Chunk가 배열 조각들을 돌려줍니다. 마지막 조각은 요청한 크기보다 작을 수 있으므로, 길이를 가정한 인덱싱은 피해야 합니다.
C# code
// chunk 나누기
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 13 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));Chunk는 조각마다 새 배열을 만들어 돌려주므로 조각을 나중까지 보관해도 안전하지만, 그만큼 조각 수에 비례한 할당이 생깁니다.
Chunk에 0이나 음수를 넘기면 ArgumentOutOfRangeException이 납니다. 크기를 설정값으로 받는다면 1 이상인지 먼저 검사해야 합니다.
14key 기준 정렬
정렬 없이 Distinct 결과를 원래 등장 순서대로 출력합니다. 특정 기준으로 줄을 세우려면 OrderBy에 키 선택자를 넘깁니다. 참고로 Distinct의 문자열 비교는 대소문자를 구분하는 기본 비교자를 쓰므로 CS와 cs는 서로 다른 값으로 남습니다.
C# code
// key 기준 정렬
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));OrderBy에는 비교자를 받는 오버로드가 있어 OrderBy(w => w, StringComparer.OrdinalIgnoreCase)처럼 대소문자를 무시한 순서를 만들 수 있습니다.
사용자에게 보여 줄 목록을 StringComparer.Ordinal로 정렬하면 대문자가 소문자보다 모두 앞에 오는 부자연스러운 순서가 됩니다. 화면 표시는 문화권 비교자를 써야 합니다.
15빈도표 만들기
값을 두 배로 바꾼 목록만 만들 뿐 등장 횟수를 집계하지는 않습니다. 빈도표는 GroupBy(v => v) 뒤에 ToDictionary로 키와 개수를 담아 만듭니다. 단순 조회만 반복한다면 사전에 직접 누적하는 편이 그룹 객체를 만들지 않아 할당이 적습니다.
C# code
// 빈도표 만들기
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 15 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));ToDictionary 대신 ToLookup을 쓰면 없는 키를 조회해도 예외 대신 빈 시퀀스가 돌아와, 키 존재 여부를 확인하는 분기를 줄일 수 있습니다.
빈도를 올릴 때 dict[key]++는 키가 없으면 예외가 납니다. TryGetValue로 읽어 더한 뒤 대입하거나 GetValueOrDefault를 거쳐야 합니다.
16대소문자 무시 중복 제거
여기서 쓰인 Distinct는 대소문자를 그대로 구분하므로 cs와 CS가 함께 남습니다. 표기 차이를 무시하려면 Distinct(StringComparer.OrdinalIgnoreCase)처럼 비교자를 넘기면 됩니다. 사용자 언어에 따라 결과가 흔들리지 않게 하려면 문화권 기반 비교보다 Ordinal 계열을 고르는 편이 예측 가능합니다.
C# code
// 대소문자 무시 중복 제거
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));OrdinalIgnoreCase는 문화권 규칙을 타지 않아 빠르고 결과가 일정하며, 식별자나 파일 확장자처럼 사람 언어가 아닌 문자열 비교에 적합합니다.
터키어 로캘에서는 ToLower가 I를 점 없는 문자로 바꾸기 때문에, 소문자로 바꿔 비교하는 방식은 실행 환경에 따라 결과가 달라집니다.
17sliding window
인접한 원소를 묶는 창 없이, 각 값을 독립적으로 두 배로 만듭니다. 연속 구간을 훑으려면 Zip에 Skip(1)을 물리거나 인덱스를 받는 Select((v, i) => ...) 오버로드를 씁니다. 이런 코드에서 원본이 IEnumerable이면 같은 시퀀스를 두 번 열거하게 되므로, 미리 ToArray로 굳혀 두는 편이 좋습니다.
C# code
// sliding window
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 17 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));인접 쌍이 필요하면 items.Zip(items.Skip(1))가 간단하지만, 배열이나 리스트라면 인덱스로 i와 i + 1을 함께 읽는 편이 추가 할당 없이 끝납니다.
Skip(1)을 붙인 시퀀스와 원본을 함께 열거하면 원본을 두 번 순회합니다. 원본이 파일 읽기나 데이터베이스 질의라면 그 작업이 두 번 실행됩니다.
18pair 목록을 맵으로 변환
키와 값의 쌍이 아니라 단어 목록의 중복을 제거해 출력합니다. 쌍을 사전으로 바꿀 때는 ToDictionary가 쓰이는데, 이 코드의 배열처럼 같은 값이 반복되면 그대로 예외가 납니다. 중복이 있을 수 있다면 DistinctBy나 GroupBy로 먼저 하나만 남긴 뒤 변환해야 합니다.
C# code
// pair 목록을 맵으로 변환
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));DistinctBy는 처음 등장한 원소를 남기므로, 마지막 값을 우선하고 싶다면 GroupBy 뒤에 Last를 쓰거나 인덱서 대입으로 덮어써야 합니다.
ToDictionary는 키가 null이면 ArgumentNullException을 던집니다. 키 선택자가 null을 돌려줄 수 있는 데이터라면 변환 전에 걸러야 합니다.
19reverse 복사
순서를 뒤집지 않고 각 원소를 두 배로 만들어 원래 순서대로 출력합니다. 역순이 필요할 때 LINQ의 Reverse는 원본을 건드리지 않고 새 시퀀스를 만듭니다. 이름이 같은 List<T>.Reverse와 Array.Reverse는 제자리에서 원본을 바꿔 버리므로 헷갈리기 쉽습니다.
C# code
// reverse 복사
using System;
using System.Linq;
var items = new[] { 1, 2, 3, 19 };
Console.WriteLine(string.Join(", ", items.Select(value => value * 2)));Reverse는 결과를 내기 전에 원본 전체를 버퍼에 담아야 하므로, 지연 실행이라도 메모리는 원소 수만큼 쓰게 됩니다.
list.Reverse()라고 적으면 확장 메서드가 아니라 List<T>의 인스턴스 메서드가 선택되어 원본이 그 자리에서 뒤집힙니다. 반환값을 받으려 하면 컴파일 오류가 납니다.
20partition 나누기
조건에 따라 둘로 가르는 대신, 중복을 제거한 단어들을 한 줄로 출력합니다. LINQ에는 partition 연산자가 없어서 보통 Where를 조건과 그 부정으로 두 번 부릅니다. 원본을 두 번 훑는 비용이 부담이면 ToLookup에 bool 키를 주고 한 번의 순회로 나누면 됩니다.
C# code
// partition 나누기
using System;
using System.Linq;
var words = new[] { "cs", "dotnet", "cs", "rust" };
Console.WriteLine(string.Join(", ", words.Distinct()));ToLookup은 호출 즉시 전체를 훑어 그룹을 만들어 두므로, 이후 조회는 원본을 다시 순회하지 않고 없는 키에도 빈 시퀀스를 돌려줍니다.
조건과 그 부정으로 나눌 때 조건에 null 검사나 부동소수 비교가 섞이면 양쪽 어디에도 들어가지 않는 원소가 생겨 두 목록의 합이 원본과 달라집니다.
정리하며
- LINQ 쿼리는 열거되는 순간 계산되므로 결과를 담은 변수가 곧 데이터는 아닙니다
- 같은 시퀀스를 두 번 이상 쓸 자리에서는
ToList로 한 번만 계산되게 고정합니다 - 문자열 키 비교는
StringComparer를 넘겨 대소문자 규칙을 명시적으로 정합니다 - 키로 묶는 작업은 그룹 목록이 필요한지 개수만 필요한지에 따라 다른 도구를 씁니다
더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.