C#에서 Stack<T>와 Queue<T>는 각각 LIFO, FIFO 패턴을 구현하는 대표 컬렉션입니다. 두 자료구조는 핵심 연산이 상수 시간으로 매우 빠르며, 시나리오에 맞는 선택이 성능과 코드 가독성을 모두 높입니다. 본 글은 성능 특성, 올바른 활용 사례, 미세 최적화 팁, 간단한 벤치마크 예시를 다룹니다.
1. 핵심 요약
Stack<T>는 마지막에 들어온 요소를 먼저 처리(LIFO)하고, Queue<T>는 먼저 들어온 요소를 먼저 처리(FIFO)합니다. Push/Pop, Enqueue/Dequeue, Peek는 모두 평균 O(1)입니다. 초기 용량 지정과 예외 없는 Try 계열 메서드가 실무 성능에 유의미한 차이를 만듭니다.
2. 시간·공간 복잡도와 내부 동작
Stack<T>와 Queue<T>는 내부적으로 배열 기반이며 용량이 꽉 차면 보통 2배로 확장합니다. 확장 시 복사 비용이 발생하지만, 암ORTized 관점에서 Push/Pop, Enqueue/Dequeue, Peek 모두 O(1)입니다. Contains, ToArray 등 순회 연산은 O(n)입니다. 생성 시 용량을 지정하거나 EnsureCapacity, TrimExcess로 메모리 사용을 조절하면 안정적인 지연 시간과 힙 압박 감소에 도움이 됩니다.
3. 언제 Stack, 언제 Queue
Stack<T>: 되돌리기(Undo), 수식 평가, DFS, 콜스택 유사 처리처럼 최근 항목을 우선 처리할 때 적합합니다. Queue<T>: 작업 큐, 메시지 처리, BFS, 너비 우선 탐색처럼 도착 순서를 보장해야 할 때 적합합니다. 목적에 맞는 자료구조를 선택하면 불필요한 복사를 줄이고 코드 의도를 명확히 할 수 있습니다.
4. 기본 사용 예시 (Push/Pop, Enqueue/Dequeue)
using System;
using System.Collections.Generic;
class Basics
{
static void Main()
{
var stack = new Stack<int>();
stack.Push(1);
stack.Push(2);
Console.WriteLine(stack.Peek()); // 2
Console.WriteLine(stack.Pop()); // 2
var queue = new Queue<int>();
queue.Enqueue(1);
queue.Enqueue(2);
Console.WriteLine(queue.Peek()); // 1
Console.WriteLine(queue.Dequeue()); // 1
// 예외 없는 Try 계열 (빈 컬렉션 대비)
if (stack.TryPop(out var v))
Console.WriteLine(v);
if (queue.TryDequeue(out var w))
Console.WriteLine(w);
}
}
5. 실전 활용: DFS(깊이 우선) with Stack<T>
using System;
using System.Collections.Generic;
class DFS
{
static void Run(Dictionary<int, List<int>> graph, int start)
{
var visited = new HashSet<int>();
var stack = new Stack<int>();
stack.Push(start);
while (stack.Count > 0)
{
int node = stack.Pop();
if (!visited.Add(node)) continue;
Console.WriteLine($"Visit: {node}");
// 인접 노드를 역순으로 넣으면 작은 번호를 우선 탐색하는 등 순서 제어 가능
var neighbors = graph.TryGetValue(node, out var list) ? list : Array.Empty<int>();
for (int i = neighbors.Count - 1; i >= 0; i--)
stack.Push(neighbors[i]);
}
}
}
6. 실전 활용: BFS(너비 우선) with Queue<T>
using System;
using System.Collections.Generic;
class BFS
{
static void Run(Dictionary<int, List<int>> graph, int start)
{
var visited = new HashSet<int> { start };
var q = new Queue<int>();
q.Enqueue(start);
while (q.Count > 0)
{
int node = q.Dequeue();
Console.WriteLine($"Visit: {node}");
if (!graph.TryGetValue(node, out var neighbors)) continue;
foreach (var next in neighbors)
{
if (visited.Add(next))
q.Enqueue(next);
}
}
}
}
7. 미세 최적화 팁
1) 예상 크기가 있다면 생성자에 용량을 지정하거나 EnsureCapacity를 사용합니다. 2) 빈 컬렉션에서 Pop/Dequeue는 예외가 발생하므로 TryPop, TryDequeue를 사용해 분기 예측 실패와 예외 비용을 피합니다. 3) 빈번한 Contains 호출은 O(n)이므로 필요 시 별도 HashSet과 병행합니다. 4) 컬렉션을 재사용하면 할당과 GC를 줄일 수 있습니다. 5) FIFO/LIFO 외 목적(중간 삭제/삽입)이면 다른 컬렉션(List, LinkedList 등)을 검토합니다.
8. BenchmarkDotNet으로 간단 성능 비교
아래 벤치마크는 동일한 N개 요소에 대해 Stack Push/Pop, Queue Enqueue/Dequeue를 비교합니다. 두 컬렉션 모두 평균 O(1)이므로 결과는 비슷하게 나오며, 런타임/CPU/데이터 패턴에 따라 미세한 차이가 납니다.
using System;
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class StackQueueBench
{
[Params(1_000, 100_000)]
public int N;
private int[] data;
[GlobalSetup]
public void Setup()
{
data = new int[N];
for (int i = 0; i < N; i++) data[i] = i;
}
[Benchmark]
public int Stack_PushPop()
{
var s = new Stack<int>(N); // 초기 용량 지정으로 재할당 최소화
for (int i = 0; i < N; i++) s.Push(data[i]);
int sum = 0;
while (s.Count > 0) sum += s.Pop();
return sum; // 결과를 반환하여 최적화 방지
}
[Benchmark]
public int Queue_EnqueueDequeue()
{
var q = new Queue<int>(N);
for (int i = 0; i < N; i++) q.Enqueue(data[i]);
int sum = 0;
while (q.Count > 0) sum += q.Dequeue();
return sum;
}
}
public class Program
{
public static void Main(string[] args)
=> BenchmarkRunner.Run<StackQueueBench>();
}
Tip: 로컬 환경에서 릴리스 빌드/64비트/서버 GC 설정으로 측정하세요. 또한 다양한 N과 데이터 패턴(랜덤, 정렬 등)으로 실험하면 편차를 파악할 수 있습니다.
9. 스레드 환경에서의 선택
Stack<T>와 Queue<T>는 스레드 안전하지 않습니다. 멀티스레드에서는 ConcurrentStack<T>와 ConcurrentQueue<T>를 사용합니다. 블로킹이 필요한 경우 Channel<T> 또는 BlockingCollection<T>(ConcurrentQueue 기반)을 고려하면 생산자-소비자 시나리오를 간단히 구현할 수 있습니다.
10. 결론
두 컬렉션은 O(1) 평균 성능과 간결한 API로 대부분의 LIFO/FIFO 문제를 빠르게 해결합니다. 올바른 선택과 간단한 최적화(용량 사전 지정, Try 메서드, 재사용)만으로도 지연 시간을 안정화하고 힙 압박을 줄일 수 있습니다. 업무 시나리오에 맞춰 Stack과 Queue를 명확히 구분해 사용하시기 바랍니다.
'C#' 카테고리의 다른 글
| C# SemaphoreSlim으로 비동기 동시성 제한 구현하기 (0) | 2026.07.22 |
|---|---|
| C# 숫자 서식 지정(Custom Numeric Format) 사용하기 (1) | 2026.07.21 |
| C# Mutex를 이용한 프로세스 간 동기화 (0) | 2026.07.16 |
| C# XML 직렬화와 역직렬화 고급 기법 (0) | 2026.07.16 |
| C# async/await에서 ConfigureAwait(false) 사용 이유 (0) | 2026.07.15 |