본문 바로가기

C#

C# Immutable Collections 사용과 장점

System.Collections.Immutable은 컬렉션을 불변(immutable)으로 유지하면서도 실용적인 성능을 제공하는 라이브러리입니다. 동시성 안전, 예측 가능성, 버그 감소에 큰 도움이 됩니다.

1. 왜 Immutable Collections인가

불변 컬렉션은 생성 후 상태가 바뀌지 않습니다. 변경이 필요하면 항상 새로운 인스턴스를 반환합니다. 이 특성은 다음을 보장합니다.

- 스레드 안전: 잠금 없이 공유해도 안전합니다.
- 예측 가능성: 사이드 이펙트가 줄어 디버깅이 쉬워집니다.
- 스냅샷 모델: 특정 시점의 데이터를 그대로 보존할 수 있습니다.

2. 설치와 기본 사용

NuGet 패키지 추가 후 using을 선언합니다.

// CLI: 프로젝트에 패키지 추가
// dotnet add package System.Collections.Immutable

using System.Collections.Immutable;

추가/삭제 등 모든 연산은 원본을 변경하지 않고 새 컬렉션을 반환합니다.

using System;
using System.Collections.Immutable;

var list1 = ImmutableList.Empty;
var list2 = list1.Add(1).Add(2);

Console.WriteLine(string.Join(",", list1)); // ""
Console.WriteLine(string.Join(",", list2)); // "1,2" (list1은 유지)

3. 주요 컬렉션 빠르게 보기

- ImmutableArray<T>: 값 타입처럼 가볍고 읽기/열거가 빠릅니다. 대량 읽기에 적합합니다.
- ImmutableList<T>: 중간 삽입/삭제, 다양한 편의 메서드 제공.
- ImmutableDictionary<TKey,TValue> / ImmutableHashSet<T>: 키/집합 기반 조회에 적합.

using System;
using System.Collections.Immutable;

// ImmutableArray
var arr = new[] { 1, 2, 3 }.ToImmutableArray();
var arr2 = arr.Add(4);
Console.WriteLine(arr[0]);   // 1 (원본 유지)
Console.WriteLine(arr2[^1]); // 4

// ImmutableDictionary
var dict1 = ImmutableDictionary<string, int>.Empty
    .Add("A", 1)
    .SetItem("A", 2); // 키가 있으면 대체
var dict2 = dict1.Remove("A");
Console.WriteLine(dict1["A"]);                 // 2
Console.WriteLine(dict2.ContainsKey("A"));     // False

// ImmutableHashSet (중복 자동 제거)
var set = ImmutableHashSet<string>.Empty.Add("a").Add("a");
Console.WriteLine(set.Count); // 1

4. 대량 변경은 Builder로

항목을 많이 추가/삭제해야 한다면 Builder를 사용해 할당을 줄입니다.

using System.Collections.Immutable;

var list = ImmutableList<int>.Empty;
var builder = list.ToBuilder();
for (int i = 0; i < 100_000; i++)
    builder.Add(i);
list = builder.ToImmutable(); // 한 번에 불변 리스트로 고정

5. 멀티스레드에서 안전한 업데이트 패턴

불변 컬렉션은 읽기 공유가 안전합니다. 쓰기는 새 인스턴스를 만들어 원자적으로 교체합니다. Interlocked.CompareExchange를 이용해 경합 없이 갱신할 수 있습니다.

using System;
using System.Collections.Immutable;
using System.Threading;

public sealed class Cache
{
    private ImmutableDictionary<string, int> _map = ImmutableDictionary<string, int>.Empty;

    public int GetOrAdd(string key, Func<string, int> factory)
    {
        while (true)
        {
            var snapshot = _map; // 스냅샷은 항상 안전
            if (snapshot.TryGetValue(key, out var value))
                return value;

            var computed = factory(key);
            var updated = snapshot.Add(key, computed);

            // snapshot이 여전히 현재이면 원자적으로 교체
            var original = Interlocked.CompareExchange(ref _map, updated, snapshot);
            if (ReferenceEquals(original, snapshot))
                return computed; // 교체 성공
            // 다른 스레드가 먼저 갱신했으면 재시도
        }
    }
}

참고: ImmutableInterlocked 유틸리티를 사용하면 사전용 TryAdd/TryRemove 같은 연산을 더 간단히 사용할 수 있습니다.

6. LINQ/변환 팁

LINQ 결과를 바로 불변으로 고정하면 이후 안전하게 공유할 수 있습니다.

using System.Linq;
using System.Collections.Immutable;

var evens = Enumerable.Range(1, 10)
    .Where(x => x % 2 == 0)
    .ToImmutableArray();

// 키가 고유해야 합니다.
var peopleById = people.ToImmutableDictionary(p => p.Id, p => p);

// 변경 후 다시 고정하는 패턴
var tmp = peopleById.ToBuilder();
tmp["42"] = tmp["42"] with { Name = "New Name" };
peopleById = tmp.ToImmutable();

7. 성능 팁과 언제 쓰면 좋은가

- 읽기 위주/스냅샷/구성 데이터: 강력 추천입니다.
- 멀티스레드 공유 캐시: 잠금 없이 안전하게 교체할 수 있어 적합합니다.
- 빈번한 개별 추가/삭제가 매우 많은 핫 루프: 일반 List/Dictionary로 작업 후 ToImmutableArray/ToImmutableDictionary로 고정하는 것이 더 낫습니다.
- 값 비교가 필요하면 SequenceEqual/SetEquals 등을 사용하세요(인스턴스 참조 비교는 값 동등성을 보장하지 않습니다).

요약: Immutable Collections는 코드 안전성과 가독성을 높이면서 동시성 문제를 줄여줍니다. 읽기 공유와 스냅샷이 중요한 곳부터 단계적으로 도입해 보시길 권합니다.