C#에서 큐, 스택, 딕셔너리를 완벽하게 다루는 비결! | Bald. Bearded. Builder


queue stack dictionary hashtable csharp

핵심 요약

Queue, Stack, Dictionary 세 가지 C# 데이터 구조의 올바른 사용법과 실무 적용 방법을 다룹니다. List 남용의 문제점을 해결하고, 각 자료구조의 성능 특성과 실제 사용 사례를 통해 최적의 선택 기준을 제시합니다. .NET 6 이상에서 제공하는 안전한 메서드들과 성능 최적화 팁을 포함하여 프로덕션 코드 품질을 향상시킬 수 있습니다.


상세 요약

1. Queue (큐) - FIFO 구조

개념 및 작동 원리

Queue는 First-In-First-Out(FIFO) 방식의 자료구조로, 마치 마트의 계산대 줄처럼 먼저 들어온 데이터가 먼저 나갑니다. 예측 가능한 순서와 공정성이 필요한 상황에서 활용됩니다.

핵심 메서드

  • Enqueue: 큐에 요소 추가
  • Dequeue: 큐에서 첫 번째 요소 제거 및 반환
  • Peek: 첫 번째 요소를 확인하되 제거하지 않음
  • TryDequeue / TryPeek (.NET 6+): 안전한 버전으로, 큐가 비어있을 때도 예외 발생 없이 boolean 반환

실제 코드 예제

var waitList = new Queue<Subscriber>();
waitList.Enqueue(new Subscriber { FirstName = "Ada" });
waitList.Enqueue(new Subscriber { FirstName = "Grace" });
waitList.Enqueue(new Subscriber { FirstName = "Linus" });

while (waitList.Count > 0)
{
    var nextUp = waitList.Dequeue();
    Console.WriteLine($"Now serving {nextUp.FirstName}");
}

Peek와 Dequeue의 차이점

  • Peek: 요소를 조회만 함 (Count 변화 없음)
  • Dequeue: 요소를 반환하면서 동시에 제거 (자동으로 Count 감소)

안전한 사용 방법 (.NET 6+)

if (waitList.TryDequeue(out var served))
{
    Console.WriteLine($"Served: {served.FirstName}");
}
else
{
    Console.WriteLine("No one left");
}

실무 활용 사례

  • 작업 큐 관리 (print queue, task queue)
  • 고객 대기열 관리 시스템
  • 메시지 브로커 및 비동기 작업 처리

주의사항

  • InvalidOperationException: Dequeue/Peek를 빈 큐에서 호출하면 예외 발생
  • .NET 6 미만: Try 메서드가 없으므로 Count 확인 필수
  • 멀티스레드 환경: ConcurrentQueue 사용 권장 (System.Collections.Concurrent)

2. Stack (스택) - LIFO 구조

개념 및 작동 원리

Stack은 Last-In-First-Out(LIFO) 방식으로, 가장 최근에 들어온 데이터가 가장 먼저 나갑니다. 브라우저의 뒤로 가기 버튼이나 실행 취소(Undo) 기능의 기초가 됩니다.

핵심 메서드

  • Push: 스택에 요소 추가
  • Pop: 스택에서 최상단 요소 제거 및 반환
  • Peek: 최상단 요소를 확인하되 제거하지 않음
  • TryPop / TryPeek (.NET 6+): 안전한 버전

실제 코드 예제

var history = new Stack<string>();
history.Push("/home");
history.Push("/products");
history.Push("/cart");

var lastIn = history.Pop();  // "/cart" 반환 및 제거
Console.WriteLine(lastIn);

스택 Peek 활용

var current = history.Peek();  // "/products" 확인 (제거 안 함)

안전한 사용 방법 (.NET 6+)

if (history.TryPop(out var page))
{
    Console.WriteLine($"Going back to: {page}");
}

if (history.TryPeek(out var nextPage))
{
    Console.WriteLine($"Next page: {nextPage}");
}

실무 활용 사례

  • 브라우저 방문 이력 관리
  • 실행 취소/다시 하기(Undo/Redo) 기능
  • 깊이 우선 탐색(DFS) 알고리즘
  • 수식 평가 및 파싱
  • 괄호 균형 검사

괄호 균형 검사 예제

문자열 내 괄호의 균형을 확인하는 알고리즘이 스택을 활용한 대표적인 예입니다.

주의사항

  • InvalidOperationException: Pop/Peek를 빈 스택에서 호출하면 예외 발생
  • .NET 6 미만: Try 메서드 부재로 사전 Count 확인 필수

3. Hash 기반 컬렉션 - 빠른 조회

3.1 HashTable (레거시)

개념: 오래된 비제네릭 해시 기반 자료구조로 System.Collections에 속합니다. 현대 코드에서는 거의 사용되지 않으며 레거시 코드에만 남아있습니다.

문제점:

  • 모든 값이 object로 boxed되어 타입 안전성 부재
  • Boxing/Unboxing으로 인한 성능 저하 및 오류 위험
  • 값 추출 시 형변환 필수
var table = new System.Collections.Hashtable();
table.Add(1, 42);           // int 키, int 값
table.Add(2, "100");        // int 키, string 값 (혼합 가능하지만 위험)

// 문제: 무엇의 타입인지 불명확
var value = table[1];

3.2 Dictionary<TKey, TValue> (권장)

개념: 제네릭 기반 해시 테이블로, 강타입 안전성을 제공하는 현대적 자료구조입니다. 대부분의 현대 C# 코드에서 사용되어야 합니다.

장점:

  • 강타입 안전성으로 오류 방지
  • Boxing/Unboxing 없음
  • 빠른 조회 성능 (평균 O(1))
  • 명확한 타입 시스템

실제 코드 예제

var table = new Dictionary<string, int>();
table.Add("one", 1);
table.Add("two", 2);

// 키 존재 여부 확인 후 안전하게 추출
if (table.TryGetValue("two", out int user))
{
    Console.WriteLine($"Found: {user}");
}
else
{
    Console.WriteLine("Key not found");
}

Dictionary 성능 최적화 팁

  • 초기 크기 지정: 용량을 미리 알면 할당 횟수 감소
    var dict = new Dictionary<string, int>(1000);  // 초기 용량 지정
    

주의사항

1. 타입 안전성: 키/값 타입 명시 필수

// 좋음
var goodDict = new Dictionary<string, int>();

// 피해야 함
var badDict = new Dictionary<object, object>();

2. 커스텀 키 타입 사용 시:
반드시 **GetHashCode()**와 Equals() 오버라이드 구현 필수

public class User
{
    public int Id { get; set; }
    
    public override int GetHashCode() => Id.GetHashCode();
    
    public override bool Equals(object obj) =>
        obj is User user && user.Id == this.Id;
}

3. 성능 주의:

  • Contains(value): 모든 값을 순회하므로 선형 탐색 (O(n))
  • Contains(key): 빠른 해시 조회 (O(1))

Dictionary 주요 메서드

  • Add(key, value): 키-값 쌍 추가 (키 중복 시 예외)
  • TryGetValue(key, out value): 안전한 값 추출
  • ContainsKey(key): 키 존재 여부 확인 (빠름)
  • ContainsValue(value): 값 존재 여부 확인 (느림)
  • Remove(key): 키-값 쌍 제거

멀티스레드 환경

var concurrentDict = new ConcurrentDictionary<string, int>();

4. 자료구조 선택 가이드

자료구조 특징 사용 시기 시간복잡도
Queue FIFO, 예측 가능한 순서 작업 큐, 대기열 O(1)
Stack LIFO, 최근 순서 Undo/Redo, DFS, 파싱 O(1)
Dictionary 고속 조회, 강타입 키-값 매핑, 캐싱 O(1)
HashTable 레거시, 비타입 안전 레거시 코드만 O(1)

5. 성능 최적화 팁

List 남용을 피해야 하는 이유

일반적인 List는 다양한 용도에 사용되지만, 특정 작업 패턴에는 더 적합한 자료구조들이 있습니다.

개선 사항들

  1. 초기 크기 할당: Stack/Queue 생성 시 용량을 미리 지정하면 재할당 감소

    var queue = new Queue<Item>(1000);  // 초기 용량 지정
    
  2. Contains(value) 피하기: 대규모 데이터셋에서는 선형 탐색으로 성능 저하

    • 대신 Dictionary와 ContainsKey 사용
  3. 불변 타입(Immutable Type) 선호: 키로 사용하는 타입은 변경되지 않아야 함

    • 문자열(String)이 이상적
    • 커스텀 타입은 반드시 GetHashCode/Equals 구현
  4. Boxing 피하기: 비제네릭 컬렉션(HashTable) 사용 금지

    • 항상 Dictionary<TKey, TValue> 사용

멀티스레드 환경 최적화

// 안전한 동시성
using System.Collections.Concurrent;

var concurrentQueue = new ConcurrentQueue<Item>();
var concurrentDict = new ConcurrentDictionary<string, int>();

6. 실무 권장 사항

순서가 중요한 경우

  • 공정성(Fairness) 필요Queue 사용
  • 최근 항목 우선Stack 사용

빠른 조회가 필요한 경우

  • 키-값 매핑Dictionary<TKey, TValue> 사용
  • 멀티스레드ConcurrentDictionary 사용

피해야 할 패턴

  • 시간 순서 관리에 List 사용 → Queue 또는 Stack 사용
  • HashTable 사용 → Dictionary 사용
  • 커스텀 키 타입에 GetHashCode/Equals 미구현 → 성능 저하 및 오류 발생

7. 학습 리소스

추가 탐구 주제

  • System.Collections.Concurrent: 멀티스레드 환경의 동시성 컬렉션
  • 깊이 우선 탐색(DFS): Stack을 활용한 그래프/트리 알고리즘
  • 수식 평가: Stack을 활용한 파싱 및 계산
  • GetHashCode/Equals: 효율적인 해시 함수 설계
1개의 좋아요