[PART9.컬렉션 기본 사용법(4/8)] Queue<T>·Stack<T> — FIFO와 LIFO, 순서가 곧 의도다
왜 List<T>로도 가능한 일에 굳이 Queue/Stack을 쓰는가 / 원형 버퍼 vs 단순 배열 / TryDequeue·TryPop이 InvalidOperationException을 끝내는 법
목차
한 줄 요약
Queue<T>는 먼저 들어간 것이 먼저 나오는 FIFO(First-In, First-Out), Stack<T>는 마지막에 들어간 것이 먼저 나오는 LIFO(Last-In, First-Out) 컬렉션입니다. 둘 다 Enqueue/Dequeue(큐)와 Push/Pop(스택)을 평균 O(1) 으로 처리하며, "순서대로만 꺼낼 수 있다"는 제약을 자료형 자체로 강제해 코드의 의도를 드러냅니다.
왜 이 주제를 다루는가
C# 입문자가 컬렉션을 처음 접하면 거의 모든 것을 List<T>로 해결하려는 유혹에 빠집니다. 실제로 List<T>만으로도 줄 세우기(맨 앞 제거)와 쌓아두기(맨 끝 제거)는 다 됩니다. 그런데 .NET은 왜 Queue<T>와 Stack<T>를 별도로 제공할까요?
이유는 두 가지입니다.
- 의도(intent) 표현 —
Queue<int>라고 선언하는 순간 동료 개발자는 "이 컬렉션의 중간 요소는 건드리지 않겠다"는 약속을 읽어냅니다.List<int>라면 누가list[5]로 꽂아 넣어도 막을 길이 없습니다. - 성능 시멘틱 —
Queue<T>는 내부적으로 원형 버퍼(circular buffer) 를 써서Dequeue(맨 앞 제거)를 O(1)로 만듭니다.List<T>.RemoveAt(0)은 뒤에 있는 모든 요소를 한 칸씩 앞으로 당기는 O(N) 연산입니다. 1000개를 처리하는 이벤트 큐를 List로 짜면 100만 번의 메모리 이동이 발생합니다.
이 글은 Unity 신입을 포함한 C# 입문 개발자를 대상으로, Queue와 Stack의 동작 원리·내부 구현·실전 활용을 한 번에 정리합니다.
ADEPT 1단계 — Analogy (비유)
Queue = 편의점 계산대 줄
편의점 계산대 줄을 떠올려 보세요.
- 먼저 줄을 선 사람이 먼저 계산을 마치고 빠져나갑니다.
- 새로 온 사람은 무조건 줄의 맨 뒤에 섭니다.
- 새치기는 없습니다. 공정한 순서가 핵심입니다.
이것이 FIFO(First-In, First-Out)입니다. C#에서는 queue.Enqueue(손님)으로 줄 끝에 세우고, queue.Dequeue()로 줄 맨 앞 손님을 보냅니다.
Stack = 식당 접시 더미
이번엔 식당 주방의 접시 더미입니다.
- 설거지가 끝난 접시는 맨 위에 쌓입니다.
- 요리사가 접시를 쓸 때도 맨 위 접시부터 집어 듭니다.
- 맨 아래에 있는 접시는 위에 쌓인 것을 다 치우기 전에는 못 꺼냅니다.
이것이 LIFO(Last-In, First-Out)입니다. C#에서는 stack.Push(접시)로 위에 쌓고, stack.Pop()으로 맨 위 접시를 꺼냅니다.
핵심 직관: Queue는 "공정함"(먼저 온 게 먼저), Stack은 "최근성"(가장 최근 것부터). 어떤 도메인이든 이 둘 중 하나로 설명되면 컬렉션 선택은 끝납니다.
ADEPT 2단계 — Diagram (시각화)
다이어그램 1 — FIFO와 LIFO의 데이터 흐름 비교

다이어그램 2 — Queue 내부의 원형 버퍼(Circular Buffer)

ADEPT 3단계 — Example (예시)
예시 1 — Queue로 이벤트 처리하기
게임에서 한 프레임에 여러 이벤트가 동시에 발생할 수 있습니다. 발생한 순서대로 처리해야 한다면 Queue가 정답입니다.
using System.Collections.Generic;
class EventDispatcher
{
private readonly Queue<string> _events = new Queue<string>();
// 이벤트가 발생하면 줄 끝에 세움
public void Publish(string evt) => _events.Enqueue(evt);
// 매 프레임 호출 — 발생한 순서대로 처리
public void ProcessAll()
{
while (_events.Count > 0)
{
string evt = _events.Dequeue(); // 맨 앞 꺼냄
Handle(evt);
}
}
private void Handle(string evt)
{
System.Console.WriteLine($"처리: {evt}");
}
}
// 사용
var dispatcher = new EventDispatcher();
dispatcher.Publish("입력_점프");
dispatcher.Publish("적_피격");
dispatcher.Publish("아이템_획득");
dispatcher.ProcessAll();
// 출력 순서: 입력_점프 → 적_피격 → 아이템_획득
만약 이걸 List로 짠다면 _events.RemoveAt(0)을 매 반복마다 호출해야 하고, 매번 뒤의 모든 요소를 한 칸씩 당깁니다. 이벤트가 1만 개 쌓이면 5천만 번의 이동이 발생합니다.
예시 2 — Stack으로 Undo 구현하기
그림판이나 텍스트 에디터의 실행 취소(Undo) 는 "가장 최근 행동부터 되돌리기" — 그대로 LIFO입니다.
using System.Collections.Generic;
class UndoSystem
{
private readonly Stack<string> _history = new Stack<string>();
public void Do(string action)
{
// 실제 작업 수행 (생략)
_history.Push(action); // 위에 쌓음
}
public void Undo()
{
if (_history.TryPop(out string last)) // ← 안전한 꺼내기
{
System.Console.WriteLine($"되돌림: {last}");
// 실제 되돌리기 (생략)
}
else
{
System.Console.WriteLine("되돌릴 작업이 없습니다.");
}
}
}
var undo = new UndoSystem();
undo.Do("선 그리기");
undo.Do("색칠하기");
undo.Do("텍스트 추가");
undo.Undo(); // 되돌림: 텍스트 추가
undo.Undo(); // 되돌림: 색칠하기
undo.Undo(); // 되돌림: 선 그리기
undo.Undo(); // 되돌릴 작업이 없습니다.
예시 3 — Peek vs Dequeue/Pop
Peek은 "꺼내지 않고 살짝 보기"입니다. 다음에 처리할 것을 미리 확인하고 분기하고 싶을 때 사용합니다.
var queue = new Queue<int>(new[] { 10, 20, 30 });
int top = queue.Peek(); // 10 (맨 앞 — 제거 안 함)
System.Console.WriteLine(queue.Count); // 3 그대로
int popped = queue.Dequeue(); // 10 (맨 앞 — 제거함)
System.Console.WriteLine(queue.Count); // 2
// Stack도 동일한 패턴
var stack = new Stack<int>();
stack.Push(1); stack.Push(2); stack.Push(3);
System.Console.WriteLine(stack.Peek()); // 3 (보기만)
System.Console.WriteLine(stack.Pop()); // 3 (꺼냄)
System.Console.WriteLine(stack.Peek()); // 2
예시 4 — TryDequeue·TryPop으로 예외 끝내기
빈 컬렉션에 Dequeue()나 Pop()을 호출하면 InvalidOperationException이 던져져 게임이 멈출 수 있습니다.
// ❌ 위험 — 빈 큐에서 던짐
Queue<int> q = new Queue<int>();
int x = q.Dequeue(); // InvalidOperationException!
// ✅ .NET Core 2.1+ 권장 패턴
if (q.TryDequeue(out int value))
{
// value 사용
}
else
{
// 비어 있을 때 처리
}
// ✅ Stack도 동일
var s = new Stack<int>();
if (s.TryPop(out int top)) { /* ... */ }
if (s.TryPeek(out int peek)) { /* ... */ }
왜 try 패턴이 더 좋은가? if (q.Count > 0) q.Dequeue(); 패턴도 동작하지만, 멀티스레드 환경에서 Count 체크와 Dequeue 사이에 다른 스레드가 비울 수 있습니다(이 경우는 별도로 ConcurrentQueue가 필요함). 그리고 API가 의도를 한 줄로 표현한다는 점도 큽니다 — "비어 있을 수 있다는 사실을 인지하고 있다"는 신호입니다.
ADEPT 4단계 — Plain Words (원리 설명)
시간 복잡도 — 평균 O(1)인 이유
| 연산 | Queue<T> | Stack<T> | List<T> (대안) |
|---|---|---|---|
| 끝에 추가 | Enqueue O(1)* | Push O(1)* | Add O(1)* |
| 끝에서 제거 | — | Pop O(1) | RemoveAt(Count-1) O(1) |
| 앞에서 제거 | Dequeue O(1) | — | RemoveAt(0) O(N) |
| 제거 없이 보기 | Peek O(1) | Peek O(1) | this[0] O(1) |
| 검색 | Contains O(N) | Contains O(N) | Contains O(N) |
*는 상수 시간 분할상환(amortized O(1)) 을 의미합니다. 내부 배열이 꽉 차면 새 배열(보통 2배 크기)을 만들고 기존 데이터를 복사하는 O(N) 비용이 들지만, 이 비용을 전체 추가 횟수로 나누면 평균은 O(1)이 됩니다.
핵심 차이:List<T>.RemoveAt(0)이 O(N)인 것과 달리Queue<T>.Dequeue()는 O(1)입니다. 이게 Queue가 존재하는 가장 큰 이유 중 하나입니다.
Queue의 원형 버퍼 — 왜 head만 움직이면 되는가
Queue<T>는 내부적으로 T[] _array, int _head, int _tail, int _size 네 개의 필드를 갖습니다.
Enqueue(item)→_array[_tail] = item; _tail = (_tail + 1) % _array.Length;Dequeue()→T item = _array[_head]; _head = (_head + 1) % _array.Length; return item;
즉, 데이터를 이동시키지 않고 인덱스만 옮깁니다. _tail이 배열 끝에 도달하면 % Length 연산 덕에 0으로 되돌아오고, _head가 그 자리를 비워 두면 그 칸을 다시 씁니다. 이게 원형 버퍼입니다.
배열이 꽉 차면(_size == _array.Length) 새 배열(보통 2배)을 만들고, 데이터를 복사하면서 head를 0으로 정렬합니다.
Stack의 단순 배열 — head·tail 같은 거 필요 없음
Stack<T>는 더 단순합니다. T[] _array, int _size 두 개면 끝입니다.
Push(item)→_array[_size] = item; _size++;Pop()→_size--; T item = _array[_size]; _array[_size] = default; return item;
배열의 끝(_size 위치)만 가지고 놀면 되므로 wrap-around 같은 게 필요 없습니다.
왜 Stack이 캐시 친화적인가 — ObjectPool 이야기
CPU는 메모리에서 데이터를 한 번 읽을 때 그 주변까지 함께 캐시 라인(보통 64바이트)에 올려 둡니다. 다음 접근이 같은 캐시 라인 안이면 매우 빠릅니다(L1 캐시 hit).
Stack<T>는 항상 배열의 끝(top) 만 다룹니다. 방금 Push한 객체를 곧바로 Pop하면 그 자리는 거의 100% 캐시에 남아 있습니다.
ObjectPool을 Stack으로 구현하면 방금 반환한 객체를 다음에 곧바로 재사용합니다. 이 객체는 캐시에 hot한 상태이므로 메모리 접근이 빠릅니다. Queue로 만들면 가장 오래 전에 반환된, 캐시에서 이미 쫓겨났을 가능성이 높은 객체부터 꺼내게 되어 같은 일을 하더라도 캐시 미스가 늘어납니다.
Unity의UnityEngine.Pool.ObjectPool<T>도 기본 컬렉션 타입이 Stack입니다(CollectionPool계열은collectionCheck=true옵션과 함께 내부적으로 Stack을 사용). 게임 핫패스에서 캐시 친화성이 누적되면 차이가 큽니다.
박싱은 사라졌다 — 제네릭 컬렉션의 가장 큰 공헌
옛날 비제네릭 System.Collections.Queue / Stack은 object를 다뤘기 때문에 int, struct 같은 값 타입을 넣으면 박싱(힙 할당)이 발생했습니다. 1만 개 int를 큐에 넣으면 1만 번의 GC 가비지가 생겼다는 뜻입니다.
Queue<int>나 Stack<int>처럼 제네릭을 쓰면 내부 배열이 int[]로 그대로 저장되어 박싱이 발생하지 않습니다.
또한 foreach (var x in queue)로 순회할 때 반환되는 Enumerator도 구조체(Queue<T>.Enumerator, Stack<T>.Enumerator)로 구현되어 있어 힙 할당이 추가로 생기지 않습니다. 단, 이 enumerator를 IEnumerator<T> 인터페이스에 담는 순간 박싱이 발생합니다 — foreach 자체로는 박싱 없지만, LINQ로 넘기는 식의 인터페이스 경유는 비용이 따릅니다.
List로도 흉내낼 수 있는데 왜 굳이?
기술적으로는 List<T>로 LIFO도 FIFO도 만들 수 있습니다. 하지만 자료구조의 핵심은 표현하는 의도입니다.
// ❌ 의도 불분명 — List는 중간 접근이 가능해서 누군가 list[5]를 꺼낼 수 있음
List<string> tasks = new List<string>();
tasks.Add("작업1");
tasks.Add("작업2");
string next = tasks[0]; // 맨 앞? 그냥 인덱스 접근?
tasks.RemoveAt(0); // O(N)!
// ✅ 의도 명확 — Queue는 "순서대로만 처리"라는 약속
Queue<string> tasks2 = new Queue<string>();
tasks2.Enqueue("작업1");
tasks2.Enqueue("작업2");
string next2 = tasks2.Dequeue(); // 누가 봐도 "맨 앞 꺼냄", O(1)
시니어가 Queue/Stack을 쓰는 이유: 코드 리뷰 한 줄에 "이건 줄(Queue)이다, 중간에 손대지 마라"라고 써 두는 효과입니다. 자료형이 곧 문서입니다.
ADEPT 5단계 — Technical Depth (IL 분석으로 내부 들여다보기)
IL로 보는 Enqueue·Dequeue의 본질
public class Demo
{
public int Sample()
{
var q = new System.Collections.Generic.Queue<int>();
q.Enqueue(10);
q.Enqueue(20);
return q.Dequeue(); // 10 반환
}
}
위 코드의 핵심 IL은 다음과 같습니다.
.method public hidebysig instance int32 Sample() cil managed
{
.locals init (class Queue`1<int32> q)
newobj instance void class Queue`1<int32>::.ctor()
stloc.0
ldloc.0
ldc.i4.s 10
callvirt instance void class Queue`1<int32>::Enqueue(!0)
ldloc.0
ldc.i4.s 20
callvirt instance void class Queue`1<int32>::Enqueue(!0)
ldloc.0
callvirt instance !0 class Queue`1<int32>::Dequeue()
ret
}
읽을 때 핵심 포인트는 두 가지입니다.
box명령이 보이지 않는다 —ldc.i4.s 10으로 스택에 그대로 정수 10이 올라가고,callvirt Enqueue(!0)로 직접 전달됩니다.!0이 곧int로 치환된 제네릭 타입입니다. 박싱이 일어나지 않은 증거입니다.Dequeue()는!0을 반환 — 즉int를 그대로 반환합니다. 비제네릭이었다면unbox.any int32명령이 추가됐어야 합니다.
만약 옛날 비제네릭 System.Collections.Queue를 썼다면 IL이 이렇게 됐을 겁니다(비교용).
ldc.i4.s 10
box [mscorlib]System.Int32 ; ← 힙 할당!
callvirt instance void System.Collections.Queue::Enqueue(object)
callvirt instance object System.Collections.Queue::Dequeue()
unbox.any [mscorlib]System.Int32 ; ← 다시 풀어내기
box가 한 번 일어날 때마다 힙에 작은 객체가 생기고 GC가 이를 청소해야 합니다. 게임 핫패스에서 이런 박싱이 프레임마다 누적되면 GC 스파이크의 원인이 됩니다. 제네릭 도입 이후 사라진 비용입니다.
foreach가 박싱을 만들지 않는다는 증거
public int Sum()
{
var s = new System.Collections.Generic.Stack<int>();
s.Push(1); s.Push(2); s.Push(3);
int sum = 0;
foreach (int v in s) sum += v;
return sum;
}
이 foreach의 IL을 보면 다음 패턴이 나옵니다.
callvirt instance valuetype Stack`1/Enumerator<!0> Stack`1<int32>::GetEnumerator()
stloc.s enumerator
try
{
br.s moveNext_check
loop:
ldloca.s enumerator
call instance !0 Stack`1/Enumerator<int32>::get_Current()
...
moveNext_check:
ldloca.s enumerator
call instance bool Stack`1/Enumerator<int32>::MoveNext()
brtrue.s loop
}
finally { ldloca.s enumerator call instance void Enumerator<int32>::Dispose() }
핵심은 두 가지입니다.
valuetype Stack1/Enumerator<!0>— **Enumerator가class가 아니라valuetype`(struct)** 입니다. 지역변수로 저장되며 힙에 할당되지 않습니다.ldloca.s enumerator— 주소(by-ref)로 메서드를 호출합니다. 박싱 없음.
만약 IEnumerator<int> 인터페이스에 담아 넘기면 box 명령이 추가되고 힙 할당이 발생합니다. 이게 LINQ가 게임에서 GC 가비지를 양산한다고 자주 비판받는 이유입니다.
더 자세한 박싱 IL 분석은 /il-analysis 스킬로 본인이 작성한 코드를 직접 확인해 보시면 좋습니다.
실전 활용 — Unity와 일반 .NET
1) 이벤트 큐 — Queue 사용
게임 입력, 네트워크 메시지, AI 알림 등 발생 순서가 중요한 비동기 신호는 Queue로 모았다가 한 번에 처리합니다. 프레임 안정성이 올라가고, 한 번에 너무 많이 처리되지 않도록 batch 크기로 throttle도 걸 수 있습니다.
public class MessageBus
{
private readonly Queue<Message> _pending = new Queue<Message>();
private const int MaxPerFrame = 16;
public void Send(Message m) => _pending.Enqueue(m);
public void Flush()
{
int processed = 0;
while (processed < MaxPerFrame && _pending.TryDequeue(out var m))
{
Dispatch(m);
processed++;
}
}
private void Dispatch(Message m) { /* ... */ }
}
public struct Message { public int Id; public int Code; }
2) Undo·Redo — Stack 두 개
Undo·Redo는 Stack 두 개의 정석 패턴입니다.
public class Editor
{
private readonly Stack<Command> _undo = new Stack<Command>();
private readonly Stack<Command> _redo = new Stack<Command>();
public void Execute(Command c)
{
c.Do();
_undo.Push(c);
_redo.Clear(); // 새 작업이 들어오면 redo는 비움
}
public void Undo()
{
if (_undo.TryPop(out var c)) { c.Undo(); _redo.Push(c); }
}
public void Redo()
{
if (_redo.TryPop(out var c)) { c.Do(); _undo.Push(c); }
}
}
public abstract class Command { public abstract void Do(); public abstract void Undo(); }
3) DFS와 BFS — 자료구조가 알고리즘을 결정한다
같은 그래프 순회라도 Stack을 쓰면 DFS(깊이 우선), Queue를 쓰면 BFS(너비 우선)가 됩니다. 자료구조 한 줄 바꿨을 뿐인데 알고리즘이 통째로 바뀝니다.
// BFS — 가까운 노드부터 (Queue)
public IEnumerable<Node> Bfs(Node start)
{
var visited = new HashSet<Node> { start };
var q = new Queue<Node>();
q.Enqueue(start);
while (q.TryDequeue(out var cur))
{
yield return cur;
foreach (var nx in cur.Neighbors)
if (visited.Add(nx)) q.Enqueue(nx);
}
}
// DFS — 깊이 먼저 (Stack)
public IEnumerable<Node> Dfs(Node start)
{
var visited = new HashSet<Node> { start };
var s = new Stack<Node>();
s.Push(start);
while (s.TryPop(out var cur))
{
yield return cur;
foreach (var nx in cur.Neighbors)
if (visited.Add(nx)) s.Push(nx);
}
}
public class Node { public List<Node> Neighbors = new(); }
길찾기, 적 탐색 시야, 인벤토리 슬롯 검색 등 게임에서 자주 마주치는 패턴입니다.
4) ObjectPool — 왜 Stack인가
Unity에서 총알·이펙트·UI 카드처럼 자주 만들어지고 파괴되는 객체는 풀링(미리 만들어 두고 재사용)합니다. 풀의 내부 자료구조로 Stack을 쓰는 이유는 앞서 말한 캐시 친화성 때문입니다. 가장 최근에 반환한(Push) 객체를 곧바로 다시 꺼내(Pop) 쓰므로, CPU 캐시에 hot한 상태로 재사용할 수 있습니다.
public class SimplePool<T> where T : class, new()
{
private readonly Stack<T> _items = new Stack<T>();
public T Rent() => _items.TryPop(out var item) ? item : new T();
public void Return(T item) => _items.Push(item);
}
Unity 2021.1+ 에는 UnityEngine.Pool.ObjectPool<T>가 표준으로 들어와 있어 직접 만들 일은 줄었지만, 내부 구조는 위와 동일합니다.
자주 하는 실수와 회피법
실수 1 — if (Count > 0) 후 Dequeue
// ⚠️ 멀티스레드에서 race condition 가능
if (queue.Count > 0)
{
int x = queue.Dequeue();
// ...
}
// ✅ 단일 스레드라면 TryDequeue로 의도 명확하게
if (queue.TryDequeue(out var x)) { /* ... */ }
// ✅ 멀티스레드라면 ConcurrentQueue<T> 사용
var cq = new System.Collections.Concurrent.ConcurrentQueue<int>();
cq.TryDequeue(out var v);
실수 2 — Dequeue/Pop으로 "찾기"
큐나 스택에서 특정 값을 찾으려고 Dequeue를 반복하면 안 됩니다. 그건 자료구조 잘못 고른 겁니다.
// ❌ Queue로 검색 — O(N)이고 의도도 이상함
while (queue.TryDequeue(out var x)) { if (x == target) /* ... */ }
// ✅ 검색이 자주 필요하면 List 또는 HashSet
실수 3 — Stack을 "역순 List"로 쓰기
// ⚠️ Stack을 List처럼 굴림 — 의미 흐려짐
foreach (var item in stack.Reverse()) { /* ... */ } // LINQ — 박싱 발생
// ✅ 정말 역순이 필요하면 List를 역순 순회
for (int i = list.Count - 1; i >= 0; i--) { /* ... */ }
실수 4 — Capacity를 모른 체하기
대용량을 넣을 줄 알면서 기본 Capacity(보통 0 또는 작게)로 시작하면 내부 배열을 여러 번 재할당하게 됩니다.
// ⚠️ 나쁨 — 1만 개 넣으면서 작은 기본값에서 시작
var q = new Queue<int>();
for (int i = 0; i < 10000; i++) q.Enqueue(i); // 14번 정도 재할당
// ✅ 좋음 — 초기 용량 지정 (한 번 할당)
var q2 = new Queue<int>(10000);
for (int i = 0; i < 10000; i++) q2.Enqueue(i);
실수 5 — TrimExcess를 매번 호출
TrimExcess()는 내부 배열을 현재 Count에 맞게 줄여주는 메서드인데, 이는 새 배열 할당 + 복사 비용이 듭니다. 한참 쓴 뒤 비워질 일이 많을 때만 한 번씩 호출합니다.
// ⚠️ 매 프레임 호출 — 오히려 성능 저하
queue.Dequeue();
queue.TrimExcess(); // 매번 재할당!
// ✅ 큰 작업이 끝난 뒤 한 번
ProcessLargeBatch();
queue.TrimExcess(); // 메모리 회수
실수 6 — Queue의 Contains를 자주 호출
Queue<T>.Contains와 Stack<T>.Contains는 O(N) 입니다. 자주 검색해야 하면 HashSet<T>을 함께 두는 식으로 보강하세요.
마무리 — 정리
| 질문 | 답 |
|---|---|
| 처리 순서가 발생 순서대로? | Queue (FIFO) — 이벤트, 메시지, BFS |
| 처리 순서가 가장 최근부터? | Stack (LIFO) — Undo, DFS, 객체 풀 |
| 빈 컬렉션에서 안전하게 꺼내고 싶다 | TryDequeue / TryPop (.NET Core 2.1+) |
| 꺼내지 않고 다음만 보고 싶다 | Peek 또는 TryPeek |
| 박싱이 걱정된다 | 제네릭이면 박싱 없음, foreach도 안전 |
| List로도 되는데? | 의도가 흐려지고 RemoveAt(0)은 O(N) |
| 캐시 친화적인 풀이 필요하다 | Stack 기반 ObjectPool — 방금 반환한 게 hot |
한 문장 요약: Queue와 Stack은 단순한 컬렉션이 아니라 "이 데이터는 순서대로만 다뤄진다"는 약속을 코드로 표현하는 도구입니다. 적절히 쓰면 성능과 가독성을 동시에 얻습니다.
다음 글에서는 IEnumerable·ICollection·IList 인터페이스 계층을 다룹니다 — 지금까지 배운 List·Dictionary·HashSet·Queue·Stack이 어떤 인터페이스로 묶여서 함께 다뤄질 수 있는지를 정리합니다.
