읽기 비중이 높고 쓰기 비중이 낮은 공유 데이터 구조에서는 일반적인 lock보다 ReaderWriterLockSlim이 더 높은 처리량을 제공합니다. 동시에 여러 읽기 작업을 허용하고, 쓰기 시점에만 단독 진입을 보장하기 때문입니다. 실전에서 안전하고 빠르게 쓰는 패턴과 주의점을 정리합니다.
1. 언제 ReaderWriterLockSlim이 유리한가
다음 조건을 만족할 때 효과가 큽니다. 읽기 비율이 매우 높고 쓰기가 드뭅니다. 읽기 작업 시간이 짧습니다. 잠금 경합이 존재합니다. 반대로 쓰기 비율이 높거나 경합이 거의 없으면 lock이 더 단순하고 빠를 수 있습니다.
2. 기본 사용법: 다중 읽기, 단일 쓰기
ReaderWriterLockSlim은 세 가지 핵심 잠금이 있습니다. EnterReadLock 다중 동시 읽기 허용, EnterWriteLock 단독 쓰기, EnterUpgradeableReadLock 읽다가 필요 시 쓰기로 승격. 항상 try-finally로 Exit를 보장합니다.
using System;
using System.Collections.Generic;
using System.Threading;
public sealed class Catalog : IDisposable
{
private readonly Dictionary _store = new();
private readonly ReaderWriterLockSlim _rw = new(LockRecursionPolicy.NoRecursion);
// 다중 읽기 허용
public string? Get(int id)
{
_rw.EnterReadLock();
try
{
_store.TryGetValue(id, out var value);
return value;
}
finally
{
_rw.ExitReadLock();
}
}
// 단독 쓰기
public void AddOrUpdate(int id, string value)
{
_rw.EnterWriteLock();
try
{
_store[id] = value;
}
finally
{
_rw.ExitWriteLock();
}
}
public void Dispose() => _rw.Dispose();
}
3. 캐시 패턴: 업그레이더블 읽기 잠금
캐시 미스에서만 쓰기가 필요한 경우 EnterUpgradeableReadLock을 사용하면 대부분의 요청이 동시 읽기로 빠르게 처리되고, 미스일 때만 쓰기로 승격합니다. 중요한 점은 업그레이더블 읽기 잠금은 한 번에 하나의 스레드만 보유할 수 있으므로 남용하면 경합이 커질 수 있습니다.
public string GetOrAdd(int id, Func<int, string> factory)
{
_rw.EnterUpgradeableReadLock();
try
{
if (_store.TryGetValue(id, out var existing))
return existing;
// 미스일 때만 쓰기 승격
_rw.EnterWriteLock();
try
{
// 다른 쓰기가 먼저 채웠을 수 있으므로 재확인
if (!_store.TryGetValue(id, out existing))
{
existing = factory(id);
_store[id] = existing;
}
return existing;
}
finally
{
_rw.ExitWriteLock();
}
}
finally
{
_rw.ExitUpgradeableReadLock();
}
}
4. 타임아웃과 실패 대비
경합이 심한 구간에서는 TryEnterXxx를 사용해 대기 시간을 제한하고 우회하거나 백오프할 수 있습니다. 진입 실패 시 적절한 폴백을 적용합니다.
public bool TryGetWithTimeout(int id, out string? value)
{
value = null;
if (_rw.TryEnterReadLock(TimeSpan.FromMilliseconds(5)))
{
try
{
return _store.TryGetValue(id, out value);
}
finally
{
_rw.ExitReadLock();
}
}
// 폴백: 대기 또는 약간의 지연 후 재시도, 혹은 잠금 없는 근사값 반환 등
return false;
}
5. 성능 팁과 선택 기준
읽기 비중이 80% 이상, 데이터 구조가 메모리 내에 있고 읽기 작업이 짧을수록 유리합니다. 쓰기 비중이 높거나 잠금 범위 내에서 I O 네트워크 호출 같은 장시간 작업을 수행하면 전체 처리량이 급감합니다. 경합이 거의 없으면 lock을 선호하세요. ConcurrentDictionary는 키 단위 병렬성이 높은 시나리오에 좋고, 구조 전체에 대한 일관된 스냅샷 읽기에는 ReaderWriterLockSlim이 유리합니다.
6. 자주 하는 실수와 체크리스트
await를 잠금 내부에서 사용하지 않습니다. 잠금이 길게 유지되어 교착 또는 지연이 발생할 수 있습니다. 필요한 최소 범위만 잠그고, I O 작업은 잠금 밖으로 분리합니다. 항상 try-finally로 Exit를 보장합니다. 기본 NoRecursion을 사용하고, SupportsRecursion은 꼭 필요한 경우에만 선택합니다. 업그레이더블 읽기는 한 스레드만 보유 가능하므로 과도하게 사용하지 않습니다. Dispose를 호출하여 네이티브 리소스를 해제합니다.
7. 간단한 성능 점검 아이디어
BenchmarkDotNet 같은 도구로 읽기 90% 쓰기 10%와 같은 워크로드를 구성해 lock, ReaderWriterLockSlim, ConcurrentDictionary를 비교하세요. P95 지연시간과 처리량을 함께 확인하고, 실제 데이터 크기와 접근 패턴으로 측정하는 것이 중요합니다.
요약합니다. ReaderWriterLockSlim은 읽기 많은 시나리오에서 뛰어난 처리량을 제공하지만, 올바른 패턴과 짧은 임계구역이 핵심입니다. 캐시 미스 시 업그레이더블 읽기 패턴을 적용하고, 타임아웃과 폴백으로 경합을 제어하면 안정적인 성능을 얻을 수 있습니다.
'C#' 카테고리의 다른 글
| C# nameof 연산자로 리팩터링 안전성 높이기 (0) | 2026.07.24 |
|---|---|
| C# Lazy<T>를 활용한 지연 초기화 패턴 (0) | 2026.07.23 |
| C# SemaphoreSlim으로 비동기 동시성 제한 구현하기 (0) | 2026.07.22 |
| C# 숫자 서식 지정(Custom Numeric Format) 사용하기 (1) | 2026.07.21 |
| C# Stack<T>와 Queue<T> 성능 비교 및 활용 사례 (0) | 2026.07.21 |