C# 3.2 · 3부. 중급 문법

컬렉션 심화

Dictionary · HashSet · Queue · Stack 을 선택해 사용합니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 중급
개념 설명

컬렉션마다 잘하는 일이 다릅니다

1.7에서 배열과 List<T> 를 다뤘습니다. 순서대로 담고 순서대로 꺼내는 데는 그것으로 충분합니다. 그런데 요구가 조금만 달라지면 금세 불편해집니다.

  • 이름으로 값을 바로 찾고 싶다 — 목록은 앞에서부터 하나씩 비교해야 합니다.
  • 같은 것을 두 번 담지 않고 싶다 — 담을 때마다 이미 있는지 확인해야 합니다.
  • 먼저 온 것을 먼저 처리하거나, 마지막 것을 먼저 되돌리고 싶다.

이 요구마다 그 일을 잘하도록 만들어진 컬렉션이 따로 있습니다. Dictionary·HashSet·Queue·Stack 입니다. 전부 3.1에서 본 제네릭 형식이라 담을 형식을 사용하는 쪽이 정합니다.

선택을 잘못해도 코드는 동작합니다. 다만 자료가 늘어날 때 느려지거나, 없어야 할 중복을 손으로 막게 됩니다. 그래서 이 단원은 문법보다 선택 기준에 무게를 둡니다.

최소 예제 · 편집 가능

이름으로 값 찾기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
// 키(이름) 로 값(가격) 을 찾는 컬렉션입니다
var prices = new Dictionary<string, int> {
    ["사과"] = 1500,
    ["바나나"] = 3000,
    ["포도"] = 8000
};

Console.WriteLine(prices["사과"]);
Console.WriteLine(prices.Count);

// 없을 수도 있는 키는 TryGetValue 로 물어봅니다
if (prices.TryGetValue("수박", out var price))
    Console.WriteLine($"수박은 {price}원");
else
    Console.WriteLine("수박은 없습니다");

// 키와 값을 함께 꺼내며 탐색합니다
foreach (var (name, won) in prices)
    Console.WriteLine($"{name} : {won}원");
출력
1500 3 수박은 없습니다 사과 : 1500원 바나나 : 3000원 포도 : 8000원

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

Dictionary<TKey, TValue> 는 형식 매개 변수를 둘 받습니다 — 찾을 때 사용할 키담을 값입니다. 위에서는 string 으로 찾아 int 를 꺼냅니다.

상세 사용법

목록으로 찾을 때와 무엇이 다른가

List 로 찾으면
var items = new List<Product> { … };

// 앞에서부터 하나씩 비교합니다
var found = items
    .FirstOrDefault(x => x.Name == "사과");

// 없으면 null 입니다
if (found is not null)
    Console.WriteLine(found.Price);

// 담을 때 중복도 막지 않습니다
Dictionary 로 찾으면
var prices = new Dictionary<string, int> { … };

// 키를 셈해 자리를 바로 찾습니다
if (prices.TryGetValue("사과", out var price))
    Console.WriteLine(price);

// 같은 키는 하나뿐입니다
prices["사과"] = 1600;   // 덮어씁니다



Dictionary 는 키를 해시라는 숫자로 셈해 자리를 바로 계산합니다. 담긴 것이 몇 개든 비교 횟수가 늘어나지 않습니다. 목록은 앞에서부터 하나씩 비교하므로 자료가 늘어난 만큼 느려집니다.

없는 키를 물어볼 때

[] 로 꺼내는 것은 있다고 확신할 때만 사용하세요. 없으면 예외가 발생합니다.

Console.WriteLine(prices["수박"]);
// KeyNotFoundException

// ① 물어보고 꺼내기 — 두 번 찾습니다
if (prices.ContainsKey("수박"))
    Console.WriteLine(prices["수박"]);

// ② TryGetValue — 한 번에 끝냅니다(권장)
if (prices.TryGetValue("수박", out var price))
    Console.WriteLine(price);

②를 권합니다. ①은 같은 키를 두 번 찾습니다 — 있는지 한 번, 꺼내며 한 번입니다. TryGetValue 는 찾은 값을 out 매개 변수로 함께 내주므로 한 번이면 됩니다. 없으면 false 를 돌려주고 price 에는 그 형식의 기본값(int0)이 담깁니다.

out var price 라고 적으면 받을 변수를 그 자리에서 만듭니다(C# 7.0). 그 전에는 int price; 를 위에 따로 선언해야 했습니다.

중복을 담지 않는 HashSet

같은 값을 두 번 담지 않아야 한다면 HashSet<T> 입니다. 담는 쪽에서 확인할 필요가 없습니다 — 컬렉션이 알아서 막습니다.

var tags = new HashSet<string> { "C#", "닷넷", "C#" };

Console.WriteLine(tags.Count);          // 2 — 담을 때 이미 걸러졌습니다
Console.WriteLine(tags.Add("웹"));      // True  — 새로 담겼습니다
Console.WriteLine(tags.Add("C#"));      // False — 이미 있어 담기지 않았습니다

Console.WriteLine(string.Join(", ", tags));   // C#, 닷넷, 웹

Addbool 을 돌려주는 것이 쓸모 있습니다 — 처음 보는 것일 때만 무언가 하려면 그 값을 그대로 if 에 넣으면 됩니다. 다만 순서를 지켜 주지 않습니다. 순서가 필요하면 목록을 함께 두어야 합니다.

먼저 온 것을 먼저 — Queue

먼저 넣은 것이 먼저 나옵니다(FIFO). 처리 대기열, 보낼 메일 목록처럼 받은 순서를 지켜야 하는 작업에 사용합니다.

var waiting = new Queue<string>();

waiting.Enqueue("첫 번째");      // 넣기
waiting.Enqueue("두 번째");
waiting.Enqueue("세 번째");

Console.WriteLine(waiting.Dequeue());   // 첫 번째 — 꺼내면서 지웁니다
Console.WriteLine(waiting.Peek());      // 두 번째 — 보기만 합니다
Console.WriteLine(waiting.Count);       // 2

나중 것을 먼저 — Stack

마지막에 넣은 것이 먼저 나옵니다(LIFO). 되돌리기(실행 취소), 뒤로 가기, 괄호 짝 맞추기처럼 가장 최근 것부터 처리해야 하는 작업에 맞습니다.

var history = new Stack<string>();

history.Push("첫 화면");          // 넣기
history.Push("목록");
history.Push("상세");

Console.WriteLine(history.Pop());       // 상세 — 마지막 것이 먼저
Console.WriteLine(history.Peek());      // 목록

QueueStack 은 이름만 다를 뿐 꺼내는 순서 하나가 다릅니다. 둘 다 비어 있을 때 Dequeue·Pop 을 부르면 예외가 발생하므로, Count 를 먼저 보거나 TryDequeue·TryPop 을 사용합니다.

무엇을 선택할까

하고 싶은 일선택기억할 것
순서대로 담고 번호로 꺼내기List<T>찾기는 앞에서부터 하나씩
키로 값을 바로 찾기Dictionary<TKey, TValue>키는 하나뿐, 순서 없음
중복 없이 모으기 · 있는지만 확인HashSet<T>순서 없음
먼저 온 것부터 처리Queue<T>Enqueue · Dequeue
마지막 것부터 되돌리기Stack<T>Push · Pop

얼마나 차이가 나는가

10만 개를 담아 두고 맨 뒤에 있는 값을 1만 번 찾아 봤습니다.

var list = new List<int>(Enumerable.Range(0, 100_000));
var set = new HashSet<int>(list);

for (var i = 0; i < 10_000; i++) list.Contains(99_999);
for (var i = 0; i < 10_000; i++) set.Contains(99_999);

// List<int>.Contains    : 116 ms
// HashSet<int>.Contains : 0.01 ms

맨 뒤의 값을 찾은 것이라 목록에는 가장 불리한 조건입니다. 앞쪽에 있는 값이었다면 차이가 훨씬 작습니다. 그래도 방향은 분명합니다 — 목록은 담긴 수만큼 비교하고, HashSet 은 몇 개가 담겼든 한 번에 자리를 계산합니다. 정확한 수치는 컴퓨터마다 다르므로 비율이 아니라 성질을 기억하세요.

물론 열 개짜리 목록에서 찾는 것이라면 어느 쪽이든 상관없습니다. 담기는 수가 늘어날 자리인지가 선택의 기준입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
3.0
6.0
7.0
8.0
9.0
11.0
14.0
  • 2.0Dictionary · HashSet · Queue · Stack (제네릭 컬렉션)
  • 7.0out var — 받을 변수를 그 자리에서 선언
C# 2.0+

이 컬렉션들은 3.1의 제네릭과 함께 C# 2.0에서 도입되었습니다. 그 전에는 Hashtable 처럼 object 로 담는 것뿐이라 꺼낼 때마다 캐스트가 필요했습니다. C# 7.0의 out varTryGetValue 를 짧게 적게 해 준 문법으로, 이 컬렉션들을 다루는 코드의 모양을 크게 바꿨습니다.

실습 문제

직접 해보기

1. 단어가 몇 번 나왔는지 세기 난이도 하

단어 배열을 받아 각 단어가 몇 번 나왔는지 세어 출력해보세요. Dictionary<string, int> 를 사용합니다.

처음 보는 단어인지 if 로 나누지 않아도 됩니다. TryGetValue 는 없을 때 0 을 내주므로 거기에 1을 더해 담으면 그만입니다.
string[] words = ["사과", "배", "사과", "감", "사과", "배"]; var counts = new Dictionary<string, int>(); foreach (var word in words) { // 없으면 count 에 0 이 담깁니다 counts.TryGetValue(word, out var count); counts[word] = count + 1; } foreach (var (word, count) in counts) Console.WriteLine($"{word} : {count}번"); // 사과 : 3번 // 배 : 2번 // 감 : 1번
2. 괄호 짝이 맞는지 확인하기 난이도 중

문자열에서 () 의 짝이 맞는지 확인하는 메서드를 만들어보세요. "(a(b)c)"True, "(a))"False 입니다. 어느 컬렉션이 맞을지 먼저 생각해보세요.

닫는 괄호는 가장 최근에 열린 것과 짝을 이룹니다. 마지막에 넣은 것이 먼저 나오는 컬렉션이 필요합니다. 끝났을 때 그것이 비어 있어야 짝이 맞은 것입니다.
Console.WriteLine(IsBalanced("(a(b)c)")); // True Console.WriteLine(IsBalanced("(a))")); // False Console.WriteLine(IsBalanced("((a)")); // False static bool IsBalanced(string text) { var stack = new Stack<char>(); foreach (var c in text) { if (c == '(') stack.Push(c); else if (c == ')') { // 열린 것이 없는데 닫혔습니다 if (stack.Count == 0) return false; stack.Pop(); } } // 남아 있으면 닫히지 않은 것이 있습니다 return stack.Count == 0; }
요약
  • Dictionary키로 바로 찾습니다. 담긴 수가 늘어도 비교 횟수가 늘지 않습니다.
  • 없을 수 있는 키는 [] 가 아니라 TryGetValue 로 물어봅니다. ContainsKey 와 함께 사용하면 두 번 찾습니다.
  • HashSet중복을 컬렉션이 막습니다. Add 가 새로 담겼는지를 돌려줍니다.
  • Queue 는 먼저 온 것부터, Stack 은 마지막 것부터 꺼냅니다.
  • 담기는 수가 늘어날 자리인지가 선택의 기준입니다. 열 개짜리라면 무엇이든 상관없습니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.