CodeGym /행동 /C# SELF /Concurrent 컬렉션 소개

Concurrent 컬렉션 소개

C# SELF
레벨 58 , 레슨 0
사용 가능

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을 보게 되면, 둘 다 01로 만들고 쓰게 돼. 한 번의 증가가 사라지는 셈이지. 즉 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>).
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION