본문 바로가기

C#

C# ConcurrentDictionary를 이용한 안전한 데이터 공유

여러 스레드/태스크가 동시에 읽고 쓰는 환경에서는 일반 Dictionary가 경쟁 상태를 일으켜 예외나 데이터 손상을 유발합니다. ConcurrentDictionary는 키 단위로 원자적 연산을 제공해 락을 직접 관리하지 않고도 안전하게 공유 상태를 다룰 수 있습니다.

1. ConcurrentDictionary란?

ConcurrentDictionary는 멀티스레드 환경에서 안전한 추가/조회/업데이트/삭제를 제공합니다. 대부분의 읽기 연산은 락 프리(또는 미세한 락)로 동작하며, 키별 연산은 원자적으로 보장합니다.

2. 기본 사용법: TryAdd, TryGetValue, AddOrUpdate

가장 많이 쓰는 패턴은 항목을 없으면 추가, 있으면 업데이트하는 AddOrUpdate와, 없으면 생성하는 GetOrAdd입니다.

using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;

var dict = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);

string[] words = new[] { "a", "B", "a", "c", "b", "A" };

Parallel.ForEach(words, word =>
{
    // 없으면 1로 추가, 있으면 1 증가
    dict.AddOrUpdate(word, 1, static (_, current) => current + 1);
});

// 조회
if (dict.TryGetValue("a", out var count))
{
    Console.WriteLine($"a count = {count}");
}

// 정렬 출력 (스냅샷 기반)
foreach (var kv in dict.OrderBy(kv => kv.Key))
{
    Console.WriteLine($"{kv.Key} = {kv.Value}");
}

3. 카운터/집계 패턴

동시 업데이트가 많은 카운터는 AddOrUpdate가 간단합니다. 키별로 원자적으로 증가합니다.

using System.Collections.Concurrent;
using System.Threading.Tasks;

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

Parallel.For(0, 1_000_000, i =>
{
    var key = (i % 10).ToString();
    counters.AddOrUpdate(key, 1, static (_, v) => v + 1);
});

복합 타입을 안전하게 수정하려면 TryUpdate 루프를 사용하거나, 값 자체를 불변(immutable)으로 두고 교체하는 방식을 권장합니다.

public record UserScore(int Score);
var scores = new ConcurrentDictionary<string, UserScore>();

void AddScore(string user, int delta)
{
    while (true)
    {
        if (!scores.TryGetValue(user, out var old))
        {
            if (scores.TryAdd(user, new UserScore(delta)))
                break;
        }
        else
        {
            var updated = old with { Score = old.Score + delta };
            if (scores.TryUpdate(user, updated, old))
                break;
        }
    }
}

4. 캐시 패턴: Lazy<T> + GetOrAdd

GetOrAdd의 팩토리는 동시에 여러 번 호출될 수 있습니다. 무거운 초기화를 단 한 번만 실행하려면 Lazy를 함께 씁니다.

using System;
using System.Collections.Concurrent;
using System.Threading;

var cache = new ConcurrentDictionary<string, Lazy<Product>>(StringComparer.OrdinalIgnoreCase);

Product GetProduct(string id)
{
    return cache.GetOrAdd(
        id,
        key => new Lazy<Product>(
            () => LoadProduct(key),
            LazyThreadSafetyMode.ExecutionAndPublication)
    ).Value;
}

Product LoadProduct(string id)
{
    Console.WriteLine($"Loading {id}...");
    Thread.Sleep(500); // 무거운 작업 흉내
    return new Product(id, DateTime.UtcNow);
}

public record Product(string Id, DateTime LoadedAt);

// 사용 예시
var p1 = GetProduct("P-001");
var p2 = GetProduct("P-001"); // 초기화는 한 번만 실행

5. 삭제와 열거

삭제는 TryRemove를 사용합니다. 열거는 순간 스냅샷을 기준으로 하며, 최신 변경이 즉시 반영되지 않을 수 있습니다. 확정된 스냅샷이 필요하면 ToArray를 사용합니다.

if (dict.TryRemove("a", out var removed))
{
    Console.WriteLine($"removed: {removed}");
}

foreach (var kv in dict.ToArray())
{
    // 스냅샷 순회
    Console.WriteLine(kv);
}

6. 성능과 주의사항

1) GetOrAdd/AddOrUpdate의 valueFactory는 동시에 여러 번 호출될 수 있고, 최종 승자만 저장됩니다. 무거운 작업일 경우 Lazy로 감싸거나, 팩토리 내부를 가볍게 유지하세요.

2) 키 단위 원자성만 보장합니다. 여러 키를 아우르는 트랜잭션이 필요하면 별도 락이나 다른 자료구조를 고려하세요.

3) 값이 참조형이고 내부 상태를 외부에서 변경하면 전체 스레드 안전성이 깨질 수 있습니다. 가능하면 불변 모델을 사용하고 교체(Replace) 패턴을 쓰세요.

4) 열거는 락을 잡지 않으며, 순간 스냅샷에 기반합니다. 실시간 일관성이 필요하면 별도 동기화가 필요합니다.

5) 커스텀 IEqualityComparer는 스레드 안전하고 불변이어야 합니다.

6) 읽기 위주이며 드물게 쓰는 시나리오에서는 ImmutableDictionary 스냅샷 교체가 더 빠를 수 있습니다.

7. 초기 용량/동시성 조정 팁

대량 삽입이 예상되면 초기 용량과 동시성 수준을 지정하세요. 해시 충돌을 줄이려면 적절한 비교자를 사용합니다.

using System;
using System.Collections.Concurrent;

var tuned = new ConcurrentDictionary<string, int>(
    concurrencyLevel: Environment.ProcessorCount,
    capacity: 10_000,
    comparer: StringComparer.Ordinal);

요약: ConcurrentDictionary는 멀티스레드 안전성을 단순화합니다. AddOrUpdate, GetOrAdd, TryUpdate 루프, Lazy 캐시 패턴을 숙지하면 락 없이도 예측 가능한 동시성 코드를 작성할 수 있습니다.