[PART11.비동기와 동시성(9/12)] lock — 임계 구역을 보호하는 원리
lock 키워드의 실체 (Monitor.Enter/Exit) / SyncBlock과 Thin→Fat Lock 전환 / 데드락 4조건 / Unity 메인 스레드 블록킹을 피하는 법
목차
1. 왜 lock이 필요한가
Unity로 모바일 RPG를 만든다고 하자. 백그라운드 스레드에서 서버로부터 "골드 +500" 보상을 받고, 같은 순간 메인 스레드는 UI에서 "100골드로 포션 구매" 처리를 한다. 두 스레드가 동시에 player.Gold 필드에 접근하면 어떻게 될까.
C#에서 count++ 같은 한 줄짜리 코드는 사실 CPU 수준에서는 세 단계로 쪼개진다 — "메모리에서 값을 읽고 → 1을 더하고 → 메모리에 쓴다." 이 세 단계 사이에 다른 스레드가 끼어들면, 두 스레드가 모두 같은 값(예: 1000)을 읽은 뒤 각자 +1, +500을 더해 쓰는 바람에 최종 값이 예상과 달라진다.
이처럼 여러 스레드가 공유 데이터에 동시 접근할 때 실행 순서에 따라 결과가 달라지는 상태를 경쟁 상태(Race Condition)라 부른다. 이를 막으려면 공유 데이터를 건드리는 코드 블록, 즉 임계 구역(Critical Section)을 한 번에 오직 한 스레드만 실행할 수 있도록 보호해야 한다.
lock 키워드는 이 임계 구역 보호를 위해 C#이 제공하는 가장 기본적인 도구다. 그리고 컴파일러가 실제로 어떤 IL(Intermediate Language, C# 컴파일러가 생성하는 중간 언어. JIT이 이를 기계어로 변환한다)을 생성하는지를 들여다보면, lock이 "마법의 키워드"가 아니라 System.Threading.Monitor 클래스 호출로 확장되는 문법적 설탕이라는 사실을 알 수 있다.
2. 개념 정의 — lock은 무엇인가
비유: 한 명만 들어갈 수 있는 화장실
lock을 가장 쉽게 떠올리는 비유는 "공용 화장실 열쇠"다.
- 화장실 = 공유 데이터를 수정하는 코드 블록 (임계 구역)
- 열쇠 =
lock에 전달한 객체 - 사람 = 스레드
한 사람이 열쇠를 받고 문을 잠그고 들어가면, 다른 사람들은 그 사람이 나와서 열쇠를 반납할 때까지 밖에서 줄을 서서 기다린다. 어떤 사람이 들어갔는지, 얼마나 오래 있는지는 열쇠가 보장한다.
구조 시각화
기본 예시 코드
Unity 신입 개발자가 가장 많이 쓰게 될 형태는 이것이다.
using System.Threading;
public class Counter
{
private readonly object _lockObj = new object(); // 잠금 전용 객체
private int _count;
public void Increment()
{
lock (_lockObj)
{
_count++; // 임계 구역 — 한 스레드만 실행
}
}
}
readonly— 읽기 전용 (read-only) 필드 한정자 필드를 선언 시점 또는 생성자에서만 값을 할당할 수 있게 제한한다.lock객체가 중간에 바뀌면 서로 다른 객체를 잠그게 되어 동기화가 깨지므로, 잠금 객체는 반드시readonly로 선언한다.
예시:private readonly object _lockObj = new object();이후_lockObj = ...로 재할당 시 컴파일 오류가 발생해 실수로 잠금이 깨지는 것을 막아준다.
코드 한 줄짜리 _count++를 지키려고 lock까지 써야 하는 이유는 위에서 설명한 대로 — count++가 CPU 수준에서 원자적이 아니기 때문이다.
IL 분석 — lock의 실체
이 Increment 메서드를 C# 컴파일러가 어떻게 변환했는지 IL로 확인해 보자.
.method public hidebysig instance void Increment () cil managed
{
.maxstack 3
.locals init (
[0] object, // lock 대상 임시 변수
[1] bool // lockTaken 플래그
)
IL_0000: ldarg.0
IL_0001: ldfld object Counter::_lockObj // _lockObj 필드를 스택에 로드
IL_0006: stloc.0 // 임시 변수에 저장 (필드 변경 대비)
IL_0007: ldc.i4.0 // false 푸시
IL_0008: stloc.1 // lockTaken = false
.try
{
IL_0009: ldloc.0 // _lockObj 로드
IL_000a: ldloca.s 1 // lockTaken의 주소 (ref bool)
IL_000c: call Monitor::Enter(object, bool&) // ★ 락 획득
IL_0011: ldarg.0
IL_0012: ldarg.0
IL_0013: ldfld int32 Counter::_count
IL_0018: ldc.i4.1
IL_0019: add // _count + 1
IL_001a: stfld int32 Counter::_count // _count = _count + 1
IL_001f: leave.s IL_002b // try 블록 정상 탈출
}
finally
{
IL_0021: ldloc.1 // lockTaken 확인
IL_0022: brfalse.s IL_002a // false면 Exit 호출 생략
IL_0024: ldloc.0
IL_0025: call Monitor::Exit(object) // ★ 락 해제
IL_002a: endfinally
}
IL_002b: ret
}
IL을 한 줄씩 읽으면 lock 키워드가 어떻게 풀어지는지 보인다. 본질은 Monitor.Enter와 Monitor.Exit 한 쌍, 그리고 예외가 나도 반드시 해제되게 하는 try-finally다. 이를 C# 소스로 되돌리면 아래와 같다.
// lock(_lockObj) { _count++; } 의 실제 모습
object temp = _lockObj; // 필드가 중간에 바뀌어도 Enter/Exit이 같은 객체를 보도록 고정
bool lockTaken = false;
try
{
Monitor.Enter(temp, ref lockTaken); // 락 획득 시도
_count++; // 임계 구역
}
finally
{
if (lockTaken) Monitor.Exit(temp); // 획득했을 때만 해제
}
여기서 lockTaken이라는 ref bool 플래그가 핵심이다. Monitor.Enter 호출 도중에 ThreadAbortException 같은 비동기 예외가 발생하면 "락은 잡았는데 호출자에게 제어가 돌아오지 않아 Exit 호출을 영영 놓치는" 상황이 생길 수 있다. C# 4.0부터는 Enter가 락을 실제로 획득하기 직전에 lockTaken을 true로 세팅하는 ref bool 오버로드로 바꾸었고, finally에서는 이 플래그를 본 뒤에만 Exit을 호출하도록 컴파일한다. 이 덕분에 락 누수가 원천적으로 차단된다.
3. 내부 동작 — Monitor는 어떻게 작동하는가
모든 객체에 숨어 있는 동기화 슬롯
"lock에 아무 object나 넘길 수 있다"는 사실이 이상하게 느껴져야 정상이다. 어떻게 평범한 객체 하나에 "누가 들어갔는지", "몇 명이 줄 서 있는지"를 저장할 수 있을까.
답은 CLR(Common Language Runtime, .NET의 실행 환경)의 객체 레이아웃에 있다. 힙(Heap)에 할당되는 모든 참조 타입 객체는 머리에 객체 헤더(Object Header)라는 숨겨진 영역을 갖는다. 이 헤더에는 타입 정보 포인터(메서드 테이블 주소)뿐 아니라 동기화 블록 인덱스(Sync Block Index) 자리가 함께 들어있다.
구조 시각화
Thin Lock과 Fat Lock — 성능 최적화
CLR은 lock이 실제로 경합하는 경우가 드물다는 통계를 활용한다. 처음에는 Thin Lock으로 동작한다 — 객체 헤더의 Sync Block Index 자리를 그대로 사용해 "누가 들어갔는지"를 나타내는 스레드 ID만 비트 연산으로 CAS(Compare-And-Swap, CPU가 제공하는 원자적 비교-교환 명령)해서 기록한다. 이 경로는 커널 모드로 내려가지 않고 사용자 모드에서 10~20ns(나노초) 내에 끝난다.
다른 스레드가 같은 락을 잡으려 해서 경합(Contention)이 발생하면 CLR이 해당 객체를 Fat Lock으로 승격한다. 전역 SyncBlock 테이블에서 빈 슬롯을 찾아 인덱스를 연결하고, 여기에 대기 큐·재귀 카운트·커널 이벤트 핸들 같은 풍성한 정보를 담는다. 이때부터 대기 스레드는 OS 커널의 대기 큐로 내려가 스케줄러가 깨워 준다 — 비용은 수 마이크로초 수준으로 뛴다.
정리하면:
| 상태 | 저장 위치 | 비용(대략) | 언제 전환 |
|---|---|---|---|
| Thin Lock | 객체 헤더 비트 | 10~20 ns | 락을 처음 잡을 때 기본 |
| Fat Lock | SyncBlock 테이블 | 수백 ns ~ 수 μs | 경합·대기·재귀 시 승격 |
코드로 확인 — Monitor.Enter/Exit의 인자가 같아야 하는 이유
lock이 자동으로 만들어준 temp 지역 변수가 왜 필요한지는 다음 코드로 바로 드러난다.
// ❌ 필드를 바로 Enter/Exit에 넘기면 위험
object _lockObj = new object();
void DangerousSwap()
{
Monitor.Enter(_lockObj);
_lockObj = new object(); // 필드를 교체
Monitor.Exit(_lockObj); // 다른 객체를 Exit → SynchronizationLockException
}
// ✅ lock 컴파일 결과처럼 임시 변수에 고정
object _lockObj = new object();
void SafeEnterExit()
{
object temp = _lockObj;
bool taken = false;
try
{
Monitor.Enter(temp, ref taken);
// _lockObj = new object(); // 필드를 바꿔도
}
finally
{
if (taken) Monitor.Exit(temp); // Exit는 Enter와 같은 객체에 대해 호출
}
}
컴파일러가 임시 변수를 만들어 고정하기 때문에, lock 블록 내부에서 _lockObj 필드 자체가 다른 값으로 바뀌어도 Exit은 원래 잠근 객체에 대해 호출된다. 이 동작이 없다면 Monitor.Exit은 "락을 잡지도 않은 객체를 풀려 하네"라며 SynchronizationLockException을 던진다.
4. 실전 적용 — Unity에서 lock 쓰기
판단 기준
Unity 모바일 게임에서 lock이 실제로 필요한 상황은 많지 않다. 게임 로직 대부분은 메인 스레드(=Update/LateUpdate 루프)에서만 돌아가므로 애초에 공유 경합이 없다. 하지만 다음 케이스에서는 lock을 피할 수 없다.
- 네트워크 수신 스레드 ↔ 메인 스레드 — 서버에서 받은 이벤트를 큐에 쌓아 메인 스레드에서 소비
- 백그라운드 I/O 스레드 ↔ 메인 스레드 — 비동기 파일 저장/로딩 완료 플래그 공유
- 로깅 — 여러 스레드가 같은 로그 파일 스트림에 쓰기
- 오브젝트 풀 / 리소스 캐시 — 여러 워커 스레드가 공통 풀에서 가져오고 반환
Before/After — Thread-safe 큐 구현
서버 메시지를 받는 네트워크 스레드와 이를 처리하는 메인 스레드를 잇는 큐를 만들어 보자.
❌ Before — 락 없이 List<T> 공유
using System.Collections.Generic;
using UnityEngine;
public class NetEventBroker : MonoBehaviour
{
private List<string> _queue = new List<string>();
// 네트워크 스레드에서 호출
public void Enqueue(string msg)
{
_queue.Add(msg); // 다른 스레드가 동시에 Add/제거 → InvalidOperationException
}
// 메인 스레드의 Update에서 호출
void Update()
{
foreach (var m in _queue) Debug.Log(m); // foreach 도중 Add되면 예외
_queue.Clear();
}
}
List<T>는 스레드 안전하지 않다. 두 스레드가 동시에 Add를 호출하면 내부 배열 확장 중 인덱스가 꼬여 일부 데이터가 유실되거나, foreach 도중 컬렉션이 수정되면 InvalidOperationException: Collection was modified가 발생한다.
✅ After — lock으로 보호
using System.Collections.Generic;
using UnityEngine;
public class NetEventBroker : MonoBehaviour
{
private readonly object _queueLock = new object();
private readonly List<string> _queue = new List<string>();
private readonly List<string> _drainBuffer = new List<string>();
// 네트워크 스레드에서 호출
public void Enqueue(string msg)
{
lock (_queueLock)
{
_queue.Add(msg);
}
}
// 메인 스레드의 Update에서 호출
void Update()
{
lock (_queueLock)
{
// 락 구간 최소화 — 데이터만 스왑하고 바로 빠져나온다
(_drainBuffer.Clear, _queue.ForEach);
_drainBuffer.AddRange(_queue);
_queue.Clear();
}
// 무거운 로그 출력은 락 바깥에서
foreach (var m in _drainBuffer) Debug.Log(m);
}
}
포인트는 락 구간을 최소화한 것이다. Debug.Log는 문자열 포매팅·파일 I/O를 동반하므로 락 안에서 호출하면 수 밀리초 동안 메인 스레드가 네트워크 스레드를 블록킹할 수 있다. 위 After 코드처럼 큐 데이터만 버퍼로 옮기고, 실제 처리는 락 바깥에서 한다.
IL로 확인 — After 코드의 락 구간이 얼마나 짧은지
After 버전의 Enqueue 메서드만 떼어서 IL을 보면 락 블록의 실질 크기가 눈에 들어온다.
.method public hidebysig instance void Enqueue (string msg) cil managed
{
.try
{
IL_0009: ldloc.0 // _queueLock
IL_000a: ldloca.s 1
IL_000c: call Monitor::Enter(object, bool&)
IL_0011: ldarg.0
IL_0012: ldfld List`1 NetEventBroker::_queue
IL_0017: ldarg.1 // msg
IL_0018: callvirt void List`1::Add(!0) // List<string>.Add
IL_001d: leave.s IL_0029
}
finally
{
IL_001f: ldloc.1
IL_0020: brfalse.s IL_0028
IL_0022: ldloc.0
IL_0023: call Monitor::Exit(object)
IL_0028: endfinally
}
}
.try 블록 내부의 IL 명령어는 단 4개다 — 필드 로드, 인자 로드, callvirt List.Add, leave. 이 정도면 경합 없는 경로에서 Thin Lock이 수십 나노초 안에 끝난다. 반면 앞서 언급한 "나쁜 After"(락 안에서 Debug.Log 호출)는 callvirt 한 번이 수 밀리초로 뛸 수 있다. IL 레벨에서 .try 블록이 얼마나 얇은지를 보는 것이 락 최적화의 첫 단계다.
Unity 실전 — Monitor.TryEnter로 메인 스레드 프리징 방지
메인 스레드의 Update에서 락을 걸 때, 네트워크 스레드가 락을 오래 쥐고 있다면 게임 화면이 그 시간만큼 멈춘다(프리징). 60 FPS 기준으로 한 프레임 예산이 약 16.6ms인데, 락 대기만 5ms가 생기면 프레임 드랍이 눈에 띄게 드러난다.
using System.Threading;
using UnityEngine;
public class CacheConsumer : MonoBehaviour
{
private readonly object _cacheLock = new object();
void Update()
{
// 락을 0ms 동안만 시도 — 즉시 실패면 다음 프레임에 재시도
bool taken = false;
try
{
Monitor.TryEnter(_cacheLock, 0, ref taken);
if (taken)
{
// 캐시 스냅샷을 짧게 읽기만
}
// taken이 false면 이번 프레임은 건너뛰기
}
finally
{
if (taken) Monitor.Exit(_cacheLock);
}
}
}
Monitor.TryEnter— 타임아웃이 있는 락 획득 지정한 밀리초 안에 락을 얻으면lockTaken을true로 세팅하고, 실패하면false로 남겨둔 채 반환한다. 타임아웃 값을0으로 주면 "지금 당장 안 되면 포기"라는 의미로 해석된다.
예시:Monitor.TryEnter(lockObj, 0, ref taken);taken이false면 락을 잡지 않은 것이므로Exit을 호출하면 안 된다.
Monitor.TryEnter는 메인 스레드가 "이번 프레임에 못 하면 다음 프레임에 하자"고 결정할 수 있게 해 준다. 모바일에서 프리징을 줄이는 가장 현실적인 방법이다.
5. 함정과 주의사항
❌ 함정 1 — lock(this)는 절대 쓰지 않는다
외부 코드가 해당 인스턴스를 그대로 lock할 수 있기 때문에, 완전히 무관한 코드가 서로의 락을 방해할 수 있다.
❌ 잘못된 코드
public class BadService
{
private int _count;
public void Increment()
{
lock (this) // 외부에서 BadService 인스턴스를 lock할 수 있음
{
_count++;
}
}
}
// 어디선가 — 예: 별 상관없는 다른 클래스
var svc = new BadService();
lock (svc) // 내부 lock(this)와 같은 객체를 잠가 버림
{
Thread.Sleep(10_000); // svc.Increment()가 10초 동안 블록
}
✅ 올바른 코드
public class GoodService
{
private readonly object _lockObj = new object(); // 외부에서 접근 불가
private int _count;
public void Increment()
{
lock (_lockObj)
{
_count++;
}
}
}
IL 관점에서 lock(this)도 lock(_lockObj)와 같은 Monitor.Enter/Exit 쌍을 만든다 — 유일한 차이는 락 대상 객체뿐이다. 하지만 그 "대상이 무엇이냐"가 캡슐화를 살리느냐 깨느냐를 결정한다.
❌ 함정 2 — lock(typeof(T)), lock("문자열 리터럴"), lock(값 타입)
세 가지 금지 패턴 모두 전역 공유 객체를 잠그는 결과가 되거나, 매번 박싱(Boxing)으로 다른 객체를 잠그게 된다.
// ❌ 모두 금지
lock (typeof(MyClass)) { } // Type 객체는 AppDomain 전역 — 엉뚱한 코드까지 블록
lock ("GAME_LOCK") { } // 문자열 인터닝으로 같은 리터럴은 하나의 객체 — 전역 락과 동일
int id = 42;
lock (id) { } // 컴파일 오류 — 값 타입은 lock 대상이 될 수 없음
문자열 인터닝 (String Interning) 동일한 문자열 리터럴은 CLR이 하나의string인스턴스로 통합 관리한다. 코드 A의"GAME_LOCK"과 코드 B의"GAME_LOCK"이 같은 참조이므로,lock("GAME_LOCK")은 애플리케이션 전역 락이 된다.
C# 컴파일러는 lock에 값 타입을 넘기면 명시적으로 컴파일 오류(CS0185: 'int' is not a reference type as required by the lock statement)를 낸다. 과거에는 박싱된 object로 동작했지만, 매번 다른 박스 객체가 생성되어 동기화가 전혀 안 되는 미묘한 버그를 만들었다. 지금은 아예 막혀 있다.
❌ 함정 3 — lock 블록 안에서 await 사용 금지 (CS1996)
// ❌ 컴파일 오류: CS1996 Cannot await in the body of a lock statement
lock (_lockObj)
{
await SomeAsyncOp(); // 컴파일러가 거부
}
Monitor는 스레드 친화성(Thread Affinity)을 갖는다 — 락을 획득한 스레드만 해제할 수 있다. await는 현재 스레드를 반납하고 이후 코드를 다른 스레드 풀 스레드에서 재개할 수 있는데, 그러면 해제 스레드가 획득 스레드와 달라져 SynchronizationLockException이 터진다. C# 컴파일러는 이 위험을 원천 차단하려고 lock 블록 내부에 await가 나오면 CS1996 오류를 낸다.
✅ 해결책 — SemaphoreSlim
private readonly SemaphoreSlim _asyncGate = new SemaphoreSlim(1, 1);
public async Task SafeAsync()
{
await _asyncGate.WaitAsync();
try
{
await SomeAsyncOp(); // 이제 허용됨
}
finally
{
_asyncGate.Release();
}
}
SemaphoreSlim은 스레드 친화성 제약이 없다 — 누가 Release해도 된다.
❌ 함정 4 — 데드락의 4조건과 "락 순서"
데드락(Deadlock)은 두 개 이상의 스레드가 서로가 가진 락을 기다려 영영 진행하지 못하는 상태다. 운영체제 이론에서는 Coffman이 정리한 4가지 조건이 모두 충족될 때만 데드락이 발생한다고 말한다.
| 조건 | 의미 |
|---|---|
| 상호 배제 (Mutual Exclusion) | 자원을 한 번에 한 스레드만 점유 가능 |
| 점유와 대기 (Hold and Wait) | 자원을 쥔 채로 다른 자원을 추가로 요청 |
| 비선점 (No Preemption) | 다른 스레드의 자원을 강제로 빼앗을 수 없음 |
| 순환 대기 (Circular Wait) | A→B→C→A 처럼 대기 관계가 원형을 이룸 |
❌ 데드락이 발생하는 코드
private readonly object _lockA = new object();
private readonly object _lockB = new object();
void Worker1()
{
lock (_lockA)
{
Thread.Sleep(10);
lock (_lockB) { /* ... */ } // lockA 쥔 채 lockB 기다림
}
}
void Worker2()
{
lock (_lockB)
{
Thread.Sleep(10);
lock (_lockA) { /* ... */ } // lockB 쥔 채 lockA 기다림
}
}
// Worker1, Worker2를 다른 스레드에서 동시에 호출하면 거의 매번 데드락
✅ 해결 — 락 획득 순서 고정 + TryEnter 타임아웃
void Worker2_Fixed()
{
// 두 함수 모두 반드시 lockA → lockB 순서로 획득
lock (_lockA)
{
lock (_lockB) { /* ... */ }
}
}
// 또는 타임아웃으로 순환 대기 자체를 포기
void Worker2_Timeout()
{
bool gotB = false, gotA = false;
try
{
Monitor.TryEnter(_lockB, 100, ref gotB);
if (!gotB) return;
Monitor.TryEnter(_lockA, 100, ref gotA);
if (!gotA) return;
// ...
}
finally
{
if (gotA) Monitor.Exit(_lockA);
if (gotB) Monitor.Exit(_lockB);
}
}
락 순서 고정은 "순환 대기" 조건을 깨는 가장 단순하고 확실한 방법이다. 프로젝트 규모가 커지면 락을 번호로 관리하는 팀 규칙을 두기도 한다.
❌ 함정 5 — Unity 메인 스레드 블록킹과 API 호출 금지
// ❌ 절대 금지 — 락 안에서 UnityEngine API 호출
new Thread(() =>
{
lock (_lockObj)
{
transform.position = Vector3.zero; // UnityException: main thread only
}
}).Start();
Unity의 Transform, GameObject, 대부분의 UnityEngine.* API는 메인 스레드에서만 호출 가능하다. 워커 스레드가 락을 잡고 이걸 호출하면 UnityException: get_position can only be called from the main thread가 터지고 락도 풀리지 않아 시스템이 멈춘다. 락 안에서는 POCO(Plain Old CLR Object) 데이터 구조만 조작하고, Unity API 호출은 메인 스레드로 디스패치하는 큐로 넘겨야 한다.
6. C# 버전별 변화
lock은 C# 1.0부터 있던 키워드지만, 컴파일러가 생성하는 IL은 여러 차례 바뀌었다. 그리고 .NET 9에서 가장 큰 변화가 찾아왔다.
C# 1.0 ~ C# 3.0 — 단순 Monitor.Enter/Exit
// lock(_lockObj) { body } 의 초기 컴파일 결과
Monitor.Enter(_lockObj);
try
{
body
}
finally
{
Monitor.Exit(_lockObj);
}
문제는 Monitor.Enter가 락을 획득하고 돌아오기 직전에 ThreadAbortException이 발생하면, 락은 잡혔는데 try 블록에 진입하지 못해 finally에서 Exit이 호출되지 않는다는 점이었다. 이 경로로 락이 새면 해당 객체는 영영 풀리지 않는 좀비 락이 된다.
C# 4.0 — Enter(object, ref bool) 오버로드 도입
bool lockTaken = false;
try
{
Monitor.Enter(_lockObj, ref lockTaken);
body
}
finally
{
if (lockTaken) Monitor.Exit(_lockObj);
}
.NET Framework 4.0에서 Monitor.Enter(object, ref bool) 오버로드가 새로 추가되었고, C# 4.0 컴파일러부터 lock 키워드가 이 오버로드를 호출하도록 바뀌었다. 앞서 본 IL이 바로 이 형태다. 이 덕분에 락 획득 도중 비동기 예외가 터져도 누수가 없다.
.NET 9 / C# 13 — System.Threading.Lock 타입 등장
.NET 9(2024년 11월)에 새로운 동기화 프리미티브 System.Threading.Lock이 도입되었다. lock 키워드는 피연산자가 Lock 타입이면 Monitor 대신 Lock.EnterScope()를 호출하도록 확장되었다.
// .NET 9 이상
using System.Threading;
public class FastCounter
{
private readonly Lock _lock = new Lock(); // 새 Lock 타입
private int _count;
public void Increment()
{
lock (_lock) // 컴파일러가 Lock.EnterScope()를 호출하도록 확장
{
_count++;
}
}
}
이 코드의 IL은 Monitor.Enter 대신 Lock::EnterScope를 호출하고, try-finally 대신 using 패턴(IDisposable.Dispose)으로 해제한다.
.method public hidebysig instance void Increment () cil managed
{
.try
{
IL_0000: ldarg.0
IL_0001: ldfld valuetype [System.Threading]System.Threading.Lock FastCounter::_lock
IL_0006: callvirt instance valuetype Lock/Scope Lock::EnterScope() // ★ 새 방식
// ... _count++
IL_0022: leave.s IL_002e
}
finally
{
IL_0024: ldloca.s 0
IL_0026: call instance void Lock/Scope::Dispose() // ★ using 패턴
IL_002c: endfinally
}
IL_002d: ret
}
System.Threading.Lock의 장점
- 성능: Monitor의 SyncBlock 간접 참조를 제거하여 무경합 경로가 약 20% 빠르다.
- 타입 안전성:
lock대상이Lock인스턴스여야 한다는 의도가 타입으로 드러난다.lock(this),lock("str")같은 실수를 줄인다. - 디버깅: 소유자·대기자 정보를 Lock 인스턴스 자체에 담아 진단이 쉽다.
단, Unity의 현재 스크립팅 런타임은 .NET Standard 2.1 / .NET 4.x 수준이므로 System.Threading.Lock은 아직 쓸 수 없다. Unity 2023 LTS, Unity 6에서도 미지원이며, 향후 .NET 9 호환 런타임이 채택되면 사용 가능하다. 지금 단계에서는 "이런 방향으로 진화 중"이라는 맥락만 알아두자.
7. 정리
lock은 화려한 키워드처럼 보이지만, 결국 Monitor.Enter/Exit와 try-finally로 풀리는 문법적 설탕이다. 이 본질을 알고 나면 어떤 실수가 왜 문제가 되는지, 어떤 상황에서 어떤 대안을 선택해야 하는지가 선명해진다.
핵심 체크리스트
lock은 컴파일 시Monitor.Enter(obj, ref lockTaken)+try-finally { if (lockTaken) Monitor.Exit(obj); }로 확장된다. IL에서.try블록을 눈으로 보며 락 구간 크기를 가늠하라.- 락 객체는 반드시
private readonly object _lockObj = new object();.this,typeof(T), 문자열 리터럴, public 필드, 값 타입은 전부 금지. - 데드락을 피하려면 락 획득 순서를 프로젝트 전역에서 고정하거나
Monitor.TryEnter로 타임아웃을 준다. lock블록 안에서await는 컴파일 오류(CS1996) — 비동기 경로는SemaphoreSlim.WaitAsync로 교체.- Unity에서는 락 안에서 UnityEngine API 호출 금지, 락 구간 최소화, 메인 스레드에서는
TryEnter(0)을 고려해 프리징을 막는다. - 경합 없는 경로는 Thin Lock으로 20ns, 경합이 나면 Fat Lock으로 승격되어 커널까지 내려간다.
Interlocked,ConcurrentDictionary같은 대안을 먼저 검토하라. - .NET 9의
System.Threading.Lock은 타입 안전성과 성능을 개선했지만 현재 Unity 런타임에서는 아직 쓸 수 없다. 존재를 알고 로드맵에 대비하라.
'C# 심화' 카테고리의 다른 글
| [PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법 (1) | 2026.04.14 |
|---|---|
| [PART11.비동기와 동시성(10/12)] Mutex vs SemaphoreSlim vs lock — 동기화 도구 선택 기준 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(8/12)] IAsyncEnumerable<T> — 비동기 스트림이란 무엇인가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(7/12)] ValueTask — 언제 Task 대신 쓰는가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(6/12)] Task.WhenAll vs Task.WhenAny — 언제 무엇을 쓰는가 (0) | 2026.04.14 |