1. 왜 보통의 lock이 부족할까?
생각해보자: 우리에겐 공용 리소스가 있다 — 예를 들어, 멀티스레드 애플리케이션 어디엔가 저장된 고객 목록. 대부분의 시간은 이 목록을 읽기만 한다 — UI에 데이터를 보여주거나, 리포트를 만들거나, 필터/정렬을 하는 식으로. 드물게 누군가가 새 고객을 추가하거나 삭제한다.
일반적인 lock을 쓰면, 데이터를 읽으려는 어떤 스레드든 다른 스레드가 읽거나(또는 더 나쁘게는 쓰기를) 끝낼 때까지 기다려야 한다. 그런데 읽기가 전체 작업의 약 99%라면? 우리는 모든 리더들을 불필요하게 막고 있는 거다 — 사실상 병렬로 동작할 수 있는데도.
이럴 때 도움이 되는 게 바로 특별한 타입의 락 — Reader-Writer Lock이다. 핵심 아이디어: 여러 스레드가 동시에 읽을 수 있지만, 누군가 변경하려면 독점 접근을 잡아 리더들을 막는다.
2. 이론: ReaderWriterLockSlim이란?
클래스에 관해 짧게
- ReaderWriterLock — 오래된 버전, 느리고 deadlock을 유발할 수 있음. .NET Framework 2.0을 지원해야 하는 경우에만 사용하세요.
- ReaderWriterLockSlim — 빠르고 현대적인 대안 (Slim: «슬림», «경량»). 이 클래스는 빈번한 읽기/쓰기 경쟁에 맞게 최적화되어 있고, 스레드가 많고 읽기가 쓰기보다 훨씬 잦은 애플리케이션에서 권장됩니다.
동작 원리
ReaderWriterLockSlim은 세 가지 락 모드를 구현합니다:
- Read Lock (읽기 락): 여러 스레드가 동시에 읽을 수 있습니다.
- Write Lock (쓰기 락): 단 하나의 스레드만 데이터를 변경할 수 있고, 다른 어떤 스레드도 읽을 수 없습니다.
- Upgradeable Read Lock (업그레이드 가능한 읽기 락): 이런 락을 동시에 갖는 스레드는 하나뿐이며, 읽다가 필요하면 write lock으로 '업그레이드'할 수 있습니다.
락 호환도 표
| 리더 스레드 | 라이터 스레드 | Upgradeable Read 락을 가진 스레드 | |
|---|---|---|---|
| 리더 | ✅ | ⛔ | ✅/⛔ |
| 라이터 | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — 허용
⛔ — 차단됨
— upgradeable read lock이 write로 '업그레이드'되지 않은 경우에만
언제 사용해야 (또는 사용하지 말아야) 할까
권장 상황:
- 동시에 많은 스레드가 존재하고, 주로 공통 데이터를 읽는 경우(참조 데이터, 캐시, 설정 등).
- 쓰기 작업은 드물게 발생한다(예: 초당/분당/시간당 한 번 정도).
- 읽기 성능을 극대화해야 하고, 스레드들끼리 방해를 최소화하고 싶을 때.
권장하지 않는 상황:
- 읽기/쓰기 비율이 거의 같다면(이럴 땐 일반적인 lock이 더 낫다).
- 데이터가 자주 변경된다면(성능 이점이 크지 않음).
3. 실무에서의 ReaderWriterLockSlim 사용
학습용 애플리케이션을 확장해보자! 이전에 간단한 고객 디렉터리를 만들고 컬렉션에 대한 멀티스레드 접근을 실험했었다. 이제 시나리오를 조금 복잡하게 만들자: 여러 스레드가 고객 목록에 접근한다(예: 어떤 서비스는 모든 고객에게 공지를 보내고, 다른 스레드는 새 고객을 추가).
먼저 우리 고객 컬렉션을 설명해보자:
// 클라이언트 클래스 — 지난 강의에서 익숙한 주인공
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
이제 ReaderWriterLockSlim을 통해 스레드로부터 안전한 접근을 제공하는 래퍼를 만들어보자:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// 읽기/쓰기용 슬림 락
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 클라이언트 추가 (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // 클라이언트를 추가할 수 있는 스레드는 한 개뿐
try
{
_clients.Add(client);
Console.WriteLine($"[스레드 {Thread.CurrentThread.ManagedThreadId}] 클라이언트 {client.Name} 추가됨");
}
finally
{
_lock.ExitWriteLock();
}
}
// 클라이언트 리스트의 복사본 반환 (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // 여러 스레드가 동시에 읽을 수 있음
try
{
// 원본을 누군가 실수로 변경하지 않도록 복사본을 반환
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. 멀티스레드 읽기와 주기적 쓰기
예를 들어, 5개의 스레드가 주기적으로 모든 고객을 읽는다(«리포트를 만든다»거나 단순히 누가 있는지 본다)고 하고, 따로 가끔 새 고객을 추가하는 스레드가 있다고 하자.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// 클라이언트 추가용 스레드
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"클라이언트 {i}" });
Thread.Sleep(700); // 긴 작업을 흉내내기
}
});
// 여러 리더들
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[리더 {readerId}][스레드 {Thread.CurrentThread.ManagedThreadId}] 총 클라이언트 수: {clients.Count}");
Thread.Sleep(200); // 쓰기보다 더 자주 읽음
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// 리더들이 마무리할 시간을 줌:
Thread.Sleep(3000);
}
}
거의 대부분의 경우 여러 스레드가 동시에 고객 리스트를 읽을 수 있고(서로 기다리지 않음), 하지만 어떤 스레드가 고객을 추가할 때는 다른 스레드들이 그 쓰기 작업이 끝날 때까지 기다려야 한다. 덕분에 리더들을 불필요하게 막지 않고 많은 요청을 처리할 수 있다.
5. 업그레이드 가능한 락: UpgradeableReadLock
가끔은 스레드가 데이터를 읽고, 어떤 조건이 충족되면 가끔 그 데이터를 변경하고 싶을 때가 있다. 그냥 read lock을 잡고 조건을 확인한 뒤 read를 풀고 write lock을 잡으면 될 것 같지만, 그 사이 다른 스레드가 데이터를 바꿔버릴 수도 있다! 이럴 때 도움이 되는 게 업그레이드 가능한 락이다.
사용법은 간단하다:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // 이런 락을 동시에 갖는 스레드는 하나뿐
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // write lock으로 업그레이드
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
처음엔 읽기만 하다가, 변경이 필요하면 리스트를 다른 스레드에게 내주지 않고 안전하게 업그레이드해서 수정한다.
6. 유용한 팁들
비교: ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| 동시 읽기 | 아니오 | 예 |
| 동시 쓰기 | 아니오 | 아니오 |
| 업그레이드 가능한 읽기 지원 | 아니오 | 예 (EnterUpgradeableReadLock) |
| 속도 | 아주 빠름 | 읽기가 많은 경우 더 빠름 |
| 메모리 | 최소 | 조금 더많음 |
| 단순성 | 쉬움 | 조금 복잡, 유의사항 있음 |
실제 프로젝트에서의 모습
대규모 애플리케이션에서, 수백 개의 스레드가 읽고 거의 안 바뀌는 큰 테이블이나 데이터 캐시가 있을 때, ReaderWriterLockSlim을 쓰면 읽기에서의 '병목'을 피할 수 있다. 예를 들면:
- 마이크로서비스들이 공유하는 설정 캐시.
- 금융 계산용 참조 데이터 저장소.
- 메시지 라우팅을 위한 내부 캐시.
강조하자면 — 특히 멀티스레딩을 막 배우는 입장이라면 일반적인 lock이 더 쉽고 안전한 경우가 많다. ReaderWriterLockSlim은 읽기가 정말로 빈번할 때 고려할 만한 도구다.
시각화: 접근 흐름
flowchart TD
A[읽기 스레드 1] --Read--> D[ReaderWriterLockSlim]
B[읽기 스레드 2] --Read--> D
C[쓰기 스레드] --Write--> D
D --Read 허용--> E[공유 컬렉션 읽기]
D --Write가 읽기/쓰기를 블록함--> F[공유 컬렉션 쓰기]
읽기만 있는 동안은 모두 접근 가능; 쓰기를 시도하면 쓰기가 끝날 때까지 모두 대기한다.
7. ReaderWriterLockSlim: 미묘한 점과 흔한 실수
Lock Recursion (락 재귀): 기본적으로 ReaderWriterLockSlim은 같은 스레드가 같은 종류의 락을 반복해서 획득하는 것을 허용하지 않습니다. 즉, 스레드가 이미 read lock을 가지고 있는데 다시 read lock을 얻으려 하면 예외가 발생합니다. write lock도 마찬가지입니다. 다만, UpgradeableReadLock는 내부에서 write lock으로 올릴 수 있습니다.
순서를 헷갈리지 말 것: EnterUpgradeableReadLock()를 획득한 후 그 안에서 EnterWriteLock()를 호출하세요. 그 반대는 하지 마세요!
예외 처리: 락을 해제하지 않으면(예: ExitWriteLock()를 호출하지 않으면) 다른 스레드들이 영원히 대기하게 됩니다. 항상 try { ... } finally { ... } 구조를 사용하세요.
오래 잡아두지 말기: 락 안에서 긴 IO 작업을 하지 마세요. 락을 오래 잡을수록 다른 스레드들이 지연됩니다. 가능한 빨리 락을 해제하세요.
복잡성 평가: 작은 작업 보호를 위해 ReaderWriterLockSlim을 쓰지 마세요 — 간단한 경우엔 일반적인 lock이 더 쉽고 빠릅니다.
GO TO FULL VERSION