1. 문제의 배경
단일 스레드 애플리케이션에서는 List<T>, Dictionary<T> 같은 컬렉션이 예측 가능하게 동작해. 하지만 같은 컬렉션에 여러 스레드가 동시에 접근하기 시작하면, 익숙한 문제가 생겨: 데이터 레이스(race conditions).
여러 스레드가 적절한 동기화 없이 같은 컬렉션을 읽고/쓰려고 하면, 다음과 같은 문제가 발생할 수 있어:
- 잘못된 데이터: 한 스레드가 요소를 삭제하는 동안 다른 스레드가 그걸 업데이트하려고 할 수 있어.
- 데이터 손실: 한 스레드가 요소를 추가했는데 다른 스레드가 이전 값을 모르고 덮어쓸 수 있어.
- 예외: 컬렉션이 무효 상태가 되어 InvalidOperationException(예: "Collection was modified; enumeration operation may not execute.")이나 심하면 NullReferenceException을 받을 수 있어.
예제 1: List<T>에서의 데이터 레이스 (간단한 증가)
두 개의 스레드가 동시에 리스트의 같은 요소를 증가시켜.
using System.Collections.Generic;
using System.Threading.Tasks; // Task.Run을 위해
class RaceConditionExample
{
static List<int> numbers = new List<int> { 0 }; // 요소 하나인 리스트
static void Main(string[] args)
{
Console.WriteLine("초기 값: " + numbers[0]); // 0
// 두 스레드를 실행해서 각각 numbers[0]을 증가시킨다
Task task1 = Task.Run(() => IncrementNumbers(500_000));
Task task2 = Task.Run(() => IncrementNumbers(500_000));
Task.WaitAll(task1, task2); // 두 스레드가 끝날 때까지 기다림
Console.WriteLine("최종 값: " + numbers[0]); // 1_000_000을 기대하지만...
// 결과는 거의 항상 1_000_000보다 작다!
}
static void IncrementNumbers(int count)
{
for (int i = 0; i < count; i++)
{
// 이 연산 numbers[0]++ 는 사실 3단계로 이루어져 있어:
// 1. numbers[0] 읽기
// 2. 값에 1 더하기
// 3. 새로운 값을 numbers[0]에 쓰기
numbers[0]++;
}
}
}
왜 레이스가 생기냐면? 스레드 A가 numbers[0]를 (0) 읽었고, A가 1을 쓰기 전에 스레드 B가 또다시 numbers[0]를 읽어서 역시 0을 보게 되면, 둘 다 0을 1로 만들고 쓰게 돼. 한 번의 증가가 사라지는 셈이지. 즉 numbers[0]++는 원자적이지 않아.
예제 2: Dictionary를 수정할 때 발생하는 InvalidOperationException
한 스레드가 사전을 반복(이터레이션)하는 동안 다른 스레드가 수정하면 일어나는 상황이야.
using System.Collections.Generic;
using System.Threading; // Thread.Sleep 사용
class DictionaryRaceExample
{
static Dictionary<int, string> users = new Dictionary<int, string>();
static void Main(string[] args)
{
// 사전 초기화
for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");
// 읽기 스레드
Thread readerThread = new Thread(() =>
{
try
{
foreach (var user in users) // 사전 반복
{
Console.WriteLine($"읽는 스레드: {user.Key} - {user.Value}");
Thread.Sleep(10); // 작업 흉내
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"읽는 스레드: 오류! {ex.Message}");
}
});
// 쓰기 스레드
Thread writerThread = new Thread(() =>
{
Thread.Sleep(5); // 읽는 쪽이 시작할 시간을 조금 줌
for (int i = 5; i < 10; i++)
{
users.Add(i, $"New User {i}"); // 요소 추가
Console.WriteLine($"쓰는 스레드: User {i} 추가");
Thread.Sleep(15);
}
});
readerThread.Start();
writerThread.Start();
readerThread.Join(); // 스레드 완료 대기
writerThread.Join();
Console.WriteLine("예제 종료.");
}
}
왜 예외가 발생하냐면? Dictionary<TKey, TValue> (그리고 List<T>도) 는 별도의 동기화 없이 여러 스레드가 동시에 읽고 쓰는 걸 염두에 두고 만들어지지 않았어. 쓰는 스레드가 내부 구조를 바꾸면, 읽는 쪽은 이미 변경된 데이터를 가지고 foreach를 계속하려다가 InvalidOperationException이 발생하게 돼.
2. 왜 단순한 lock이 항상 최적이 아닌가?
"모든 걸 lock으로 감싸자"는 생각은 간단해 보이지만 단점이 있어:
// 안 좋은 예: 너무 넓게 잡는 lock
// (데모용, 실제로는 이렇게 하지 마)
static object _lock = new object();
static List<int> _sharedList = new List<int>();
void AddItem(int item)
{
lock (_lock)
{
_sharedList.Add(item);
}
}
int GetItemCount()
{
lock (_lock)
{
return _sharedList.Count;
}
}
- 성능(bottleneck): lock은 컬렉션 전체에 대한 접근을 막아. 스레드가 100개면 99개는 하나가 끝날 때까지 기다려야 해, 설사 연산들이 직접 충돌하지 않더라도.
- 복잡성: 컬렉션을 사용하는 모든 곳에서 lock을 기억해야 해. 하나라도 빼먹으면 데이터 레이스가 도로 나타나지.
- 교착 상태: 서로 다른 객체에 대해 여러 lock을 걸면 쉽게 deadlock이 생길 수 있어.
- 이터레이터: 다른 스레드가 컬렉션을 수정하면 foreach가 안전하지 않아.
그래서 .NET에는 특별히 스레드-세이프한 컬렉션들이 도입되었어.
원자적 연산
스레드-세이프 컬렉션은 사용자가 외부에서 락을 걸지 않아도 여러 스레드의 동시 접근을 안전하게 보장해. 핵심은 원자적 연산: 동작이 전부 수행되거나 전혀 수행되지 않는 식으로, 다른 스레드가 "중간 상태"를 보지 못하게 해.
- 추가, 삭제, 읽기 등이 마치 하나씩 순차적으로 수행되는 것처럼 동작해.
- 내부적으로는 저수준 기술들을 사용해: Interlocked 같은 인터록 연산, Compare-And-Swap (CAS), 그리고 전체 컬렉션을 잠그는 대신 더 얇은 락들 같은 방식으로 구현돼.
3. System.Collections.Concurrent 개요
네임스페이스 System.Collections.Concurrent는 멀티스레드용으로 처음부터 설계된 컬렉션들을 제공해. 철학은 최대한 병렬성 확보, 락 최소화야.
- 성능: 코어 수가 늘어날수록 잘 확장돼.
- 단순성: 각 연산에 대해 일일이 lock을 걸 필요가 없어.
- 오류 감소: 수동 동기화에서 오는 실수들이 줄어들어.
- 경쟁 상황에 최적화: 동시 추가/삭제에 효율적이야.
4. 주요 클래스들
ConcurrentQueue<T> (스레드-세이프 큐)
원칙: FIFO — "먼저 들어온 것이 먼저 나간다". 시나리오: producer–consumer, 로깅, 작업 큐 등.
using System.Collections.Concurrent;
ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();
void Producer() => messageQueue.Enqueue("메시지 1");
void Consumer()
{
if (messageQueue.TryDequeue(out string message))
{
Console.WriteLine($"처리됨: {message}");
}
else
{
Console.WriteLine("큐가 비어있음.");
}
}
ConcurrentStack<T> (스레드-세이프 스택)
원칙: LIFO — "나중에 들어온 것이 먼저 나간다". 시나리오: 작업 취소 히스토리, DFS 탐색, 오브젝트 풀 등.
using System.Collections.Concurrent;
ConcurrentStack<int> historyStack = new ConcurrentStack<int>();
void PushAction(int value) => historyStack.Push(value);
void PopAction()
{
if (historyStack.TryPop(out int action))
{
Console.WriteLine($"작업 취소됨: {action}");
}
else
{
Console.WriteLine("스택이 비어있음.");
}
}
ConcurrentBag<T> (스레드-세이프 '가방')
순서가 보장되지 않는 비정렬 컬렉션이야. "스레드는 자신이 넣은 걸 주로 꺼내는" 시나리오에 최적화돼 있어. 오브젝트 풀에 특히 좋음.
using System.Collections.Concurrent;
ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();
void AddObject() => objectPool.Add(System.Guid.NewGuid());
void TakeObject()
{
if (objectPool.TryTake(out System.Guid obj))
{
Console.WriteLine($"오브젝트 가져옴: {obj}");
}
else
{
Console.WriteLine("풀 비어있음.");
}
}
ConcurrentDictionary<TKey, TValue> (스레드-세이프 딕셔너리)
키에 대한 값 추가, 업데이트, 조회 같은 원자적 연산을 지원해. 캐시, 세션, 카운터에 아주 좋아.
using System.Collections.Concurrent;
ConcurrentDictionary<string, int> userScores = new ConcurrentDictionary<string, int>();
void UpdateScore(string user, int score)
{
// 없으면 원자적으로 추가, 있으면 원자적으로 업데이트
userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
Console.WriteLine($"점수 {user}: {userScores[user]}");
}
void GetScore(string user)
{
if (userScores.TryGetValue(user, out int score))
{
Console.WriteLine($"현재 점수 {user}: {score}");
}
else
{
Console.WriteLine($"사용자 {user}를 찾을 수 없음.");
}
}
5. 언제 일반 컬렉션 대신 이 컬렉션들을 사용할까?
- 애플리케이션이 멀티스레드인 경우: 단일 스레드면 일반 컬렉션이 더 빠름(오버헤드 없음).
- 여러 스레드가 하나의 공유 컬렉션을 쓸 때: 이게 System.Collections.Concurrent를 써야 하는 핵심 신호야.
- 높은 성능과 확장성이 필요할 때: 이 컬렉션들은 대기 시간을 최소화하도록 설계돼 있어.
- 코드를 단순하게 하고 싶을 때: 매번 수동으로 lock을 걸 필요가 없어.
- 원자적 연산이 필요할 때: 추가/삭제/조회가 컬렉션을 불일치 상태로 남기지 않아.
다음 경우에는 Concurrent-컬렉션을 쓰지 마
- 애플리케이션이 엄격히 단일 스레드일 때.
- 여러 연관된 작업들의 '트랜잭션' 같은 원자성이 필요할 때(이 경우 외부 동기화나 다른 메커니즘이 필요할 수 있음).
- 순서가 엄격히 보장되어야 하는데 그것이 보장되지 않는 경우(예: ConcurrentBag<T>).
GO TO FULL VERSION