[PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13)
이 글에서 다루는 것 - C# 13 / .NET 9 에서 새로 추가된 System.Threading.Lock 타입이 왜 도입되었는가 - 컴파일러가 lock 키워드를 만났을 때 락 대상의 타입에 따라 어떻게 다른 코드로 변환하는가 - EnterScope() 가 반환하는 ref struct Scope 의 의미와 using 패턴 - Lock 객체를 object 로 캐스팅하면 왜 새 API 가 동작하지 않는가 (CS9216 경고) - 기존 코드를 안전하게 마이그레이션하는 한 줄 변경법
목차
- 왜 새 타입이 필요했는가 — `lock(object)` 의 구조적 부담
- 30초 요약 — 한 줄만 바꾸면 됩니다
- ADEPT — 비유로 이해하는 `Lock` vs `lock(object)`
- IL 로 직접 확인하기 — 두 코드는 정말 다른 IL 로 컴파일됩니다
- `Lock.EnterScope()` 와 `ref struct Scope` — 왜 굳이 ref struct 인가
- 함정 — `object` 캐스팅·할당 시 새 API 가 사라진다
- `using` 패턴으로 명시적으로 쓰기
- `TryEnter` — 시간 제한이 있는 락 시도
- 마이그레이션 가이드 — 점진적으로 옮기기
- Unity 환경에서의 주의
- 자주 묻는 질문
- 정리
- 참고 자료
앞 주제와의 관계 이전 글([12. lock 문](../12.%20lock%20문))에서는 고전lock(_syncRoot)패턴이 내부적으로Monitor.Enter/Monitor.Exit로 변환되는 과정을 IL 수준까지 들여다봤습니다. 이 글은 그 다음 단계입니다 — 같은lock키워드를 그대로 쓰면서도, 락 객체의 타입만object→Lock으로 바꿨을 때 컴파일러가 완전히 다른 IL 을 생성한다는 사실을 보여주는 것이 핵심입니다.
1. 왜 새 타입이 필요했는가 — lock(object) 의 구조적 부담
lock(_syncRoot) 한 줄을 쓰는 순간 런타임은 다음 세 가지를 떠안습니다.
- 모든 참조 객체가 락이 될 수 있다는 일반성 —
Monitor.Enter(object)는 인자 타입이object입니다. 이 일반성을 지원하기 위해 CLR 은 힙에 있는 모든 객체에 "동기화 블록 인덱스(sync block index)" 를 걸 수 있도록 객체 헤더를 설계해 두었습니다. 한 번도 락 대상으로 쓰이지 않는 객체조차 이 메커니즘을 위해 비용을 지불합니다. - 잘못된 락 대상으로 인한 데드락 위험 —
lock(this),lock(typeof(MyClass)),lock("some-string")처럼 외부에서 동일 참조에 접근 가능한 객체를 잠그면, 의도치 않은 코드와 락 경합·데드락이 발생합니다. 컴파일러는 이를 막을 수단이 없습니다. - 확장의 한계 —
Monitor는 객체 헤더 비트를 사용하기 때문에 잠금 자체에 메타데이터를 더 붙이거나 진단 정보를 풍부하게 만들기 어렵습니다.
C# 13 / .NET 9 의 System.Threading.Lock 은 이 세 가지를 한꺼번에 정리합니다 — 잠금만을 위한 전용 타입을 만들고, 컴파일러가 그 타입을 인식해 전용 API 로 변환하는 방식입니다.
2. 30초 요약 — 한 줄만 바꾸면 됩니다
// Before (.NET 8 이하 / 고전 패턴)
private readonly object _sync = new();
public void Increment()
{
lock (_sync) // → Monitor.Enter / Monitor.Exit 로 변환
{
_count++;
}
}
// After (.NET 9 / C# 13)
private readonly System.Threading.Lock _sync = new();
public void Increment()
{
lock (_sync) // → _sync.EnterScope() + Scope.Dispose() 로 변환
{
_count++;
}
}
바뀐 것은 필드 타입 한 줄뿐입니다. lock 키워드는 그대로 유지하지만, 컴파일러가 락 대상의 정적 타입을 보고 완전히 다른 IL 을 생성합니다. 호출부 코드를 한 글자도 건드리지 않고도 새 API 의 이점을 받습니다.
3. ADEPT — 비유로 이해하는 Lock vs lock(object)
A (Analogy) — 회의실 사용
lock(object)(고전): 사무실에 회의실이 따로 없습니다. 회의가 필요하면 임의의 빈 책상 한 자리를 골라 "지금부터 여기 회의 중" 이라고 표시판을 둡니다. 표시판은 이 책상에 앉던 사람의 책상 헤더(객체 헤더의 sync block index) 에 붙입니다. 모든 책상이 표시판을 받을 수 있도록 처음부터 그 슬롯이 마련되어 있습니다.Lock(.NET 9): 회의실 전용 공간을 만듭니다. 그 공간을 잡는 도구도 회의실에 내장되어 있습니다(EnterScope()). 일반 책상은 더 이상 회의용 표시판 슬롯을 비워둘 필요가 없습니다.
D (Diagram) — 컴파일러가 두 갈래로 갈라지는 지점

E (Example) — 실제 동작
using System.Threading;
public sealed class Counter
{
private readonly Lock _sync = new();
private int _count;
public void Increment()
{
lock (_sync)
{
_count++;
}
}
public int Read()
{
lock (_sync)
{
return _count;
}
}
}
호출부에서 lock 키워드를 그대로 사용했지만 — 컴파일러가 _sync 의 타입이 System.Threading.Lock 인 것을 보고 자동으로 EnterScope() 호출 + Scope.Dispose() 패턴으로 IL 을 생성합니다. 다음 4번 절에서 IL 로 직접 확인합니다.
P (Plain language) — 한 문장
락 대상의 타입만 Lock 으로 바꾸면, lock 키워드는 자동으로 새 API 로 변환됩니다.
T (Test your understanding)
private object _sync = new Lock();— 이렇게 선언하면 새 API 가 호출될까요? *(힌트: 정적 타입은object입니다.)*lock((object)_sync) { ... }처럼 명시적으로 캐스팅하면 어떻게 될까요?- 둘 다 컴파일은 되지만, 새 API 가 호출되지 않고 컴파일러 경고 CS9216 이 뜹니다. 6번 절에서 자세히 다룹니다.
4. IL 로 직접 확인하기 — 두 코드는 정말 다른 IL 로 컴파일됩니다
이 글의 핵심 주장은 "컴파일러가 락 대상의 타입을 보고 다른 IL 을 만든다" 입니다. 추측이 아니라 IL 디컴파일 결과로 직접 확인합니다.
4.1 object 락 — 고전 경로
public sealed class CounterObject
{
private readonly object _sync = new();
private int _count;
public void Increment()
{
lock (_sync) { _count++; }
}
}
Increment() 의 IL 핵심부 (Release, .NET 9):
.method public hidebysig instance void Increment() cil managed
{
.locals init (object V_0, bool V_1)
ldarg.0
ldfld object CounterObject::_sync
stloc.0
ldc.i4.0
stloc.1
.try
{
ldloc.0
ldloca.s V_1
call void [System.Threading]System.Threading.Monitor::Enter(object, bool&)
// _count++
ldarg.0
dup
ldfld int32 CounterObject::_count
ldc.i4.1
add
stfld int32 CounterObject::_count
leave.s EndFinally
}
finally
{
ldloc.1
brfalse.s EndOfFinally
ldloc.0
call void [System.Threading]System.Threading.Monitor::Exit(object)
EndOfFinally: endfinally
}
EndFinally: ret
}
핵심: Monitor.Enter(object, bool&) 와 Monitor.Exit(object) 호출이 명시적으로 박혀 있습니다. 락 대상은 object 로 다뤄지고, 객체 헤더의 sync block index 가 사용됩니다.
4.2 Lock 락 — 새 경로
public sealed class CounterLock
{
private readonly System.Threading.Lock _sync = new();
private int _count;
public void Increment()
{
lock (_sync) { _count++; }
}
}
Increment() 의 IL 핵심부:
.method public hidebysig instance void Increment() cil managed
{
.locals init (valuetype [System.Threading]System.Threading.Lock/Scope V_0)
ldarg.0
ldfld class [System.Threading]System.Threading.Lock CounterLock::_sync
callvirt instance valuetype [System.Threading]System.Threading.Lock/Scope
[System.Threading]System.Threading.Lock::EnterScope()
stloc.0
.try
{
ldarg.0
dup
ldfld int32 CounterLock::_count
ldc.i4.1
add
stfld int32 CounterLock::_count
leave.s EndFinally
}
finally
{
ldloca.s V_0
call instance void
[System.Threading]System.Threading.Lock/Scope::Dispose()
endfinally
}
EndFinally: ret
}
핵심: Monitor.Enter / Monitor.Exit 가 사라졌습니다. 대신 Lock::EnterScope() 가 valuetype Lock/Scope 를 반환하고, finally 절에서 Scope::Dispose() 가 호출됩니다. 로컬 변수 V_0 의 타입이 valuetype Lock/Scope 인 것에 주목하세요 — class 가 아니라 valuetype 입니다. 이것이 ref struct 의 흔적입니다.
4.3 핵심 차이를 표로
| 항목 | lock(object) |
lock(Lock) |
|---|---|---|
| 호출하는 API | Monitor.Enter / Monitor.Exit |
Lock.EnterScope() / Scope.Dispose() |
| 락 식별자 | 객체 헤더의 sync block index | Lock 인스턴스의 전용 필드 |
| 진입 경로 비용 | 헤더 비트 검사 + (경합 시) 동기화 블록 인덱스 할당 | 전용 필드 CAS |
try/finally |
C# 컴파일러가 직접 생성 | 컴파일러가 직접 생성 (Scope.Dispose 호출) |
| 로컬 변수 | bool lockTaken |
Lock.Scope (ref struct) |
| 객체 헤더 사용 | 사용함 | 사용 안 함 |
5. Lock.EnterScope() 와 ref struct Scope — 왜 굳이 ref struct 인가
EnterScope() 의 시그니처는 다음과 같습니다 (개념적 표현).
namespace System.Threading;
public sealed class Lock
{
public Scope EnterScope(); // ⬅ ref struct 반환
public bool TryEnter();
public bool TryEnter(int millisecondsTimeout);
public bool TryEnter(TimeSpan timeout);
public void Enter();
public void Exit();
public bool IsHeldByCurrentThread { get; }
public ref struct Scope // ⬅ ref struct
{
public void Dispose();
}
}
Scope 가 ref struct 인 것은 단순한 스타일 선택이 아니라 의미적 보장입니다.
5.1 ref struct 가 강제하는 제약
ref struct 는 다음을 컴파일 시점에 보장합니다.
- 스택에만 존재할 수 있다 — 힙 할당 없음, 박싱 불가능
- 필드로 저장 불가 — 클래스의 필드, 다른 (일반) 구조체의 필드가 될 수 없음
async메서드에서 await 경계를 넘을 수 없음 — 상태 머신이 힙으로 캡처할 수 없으므로- iterator (
yield return) 안에서 yield 경계를 넘을 수 없음 - 람다에 캡처될 수 없음
- 다른 스레드로 전달될 수 없음 — 값이지만 ref-like 라서 의도적으로 한 스레드의 스택에 묶임
5.2 왜 락 해제에 이 제약이 필요한가
락 해제(Monitor.Exit 든 Lock.Exit 든)는 락을 획득한 그 스레드에서만 호출되어야 합니다. 다른 스레드에서 호출하면 SynchronizationLockException 이 터지거나 정의되지 않은 동작입니다.
Scope 가 ref struct 라는 것은 컴파일러가 다음을 막는다는 뜻입니다.
// ✗ 컴파일 에러: ref struct 는 필드로 저장 불가
class Holder
{
Lock.Scope _s;
}
// ✗ 컴파일 에러: ref struct 는 await 경계를 넘을 수 없음
async Task Bad()
{
using (var s = _lock.EnterScope())
{
await Task.Delay(100); // CS4007 / CS8350 류 에러
// 만약 허용되면 await 후 다른 스레드에서 Dispose 가 호출될 수 있음
}
}
// ✗ 람다 캡처 불가
Action a = () =>
{
using (var s = _lock.EnterScope()) // s 는 캡처 가능한 일반 변수지만
{
// 람다 외부로 새어 나갈 수 없음 (ref struct 제약)
}
};
즉, ref struct 는 "락을 잡은 스레드의 스택 프레임을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제합니다. 고전 lock(object) 에서는 컴파일러가 try/finally 를 만들어주지만, lockTaken 변수를 그냥 bool 로 보관하므로 약속의 강도가 다릅니다.
5.3 성능 측면 — 핫 패스 최적화
ref struct 라는 사실 자체와 별개로, 새 API 는 다음 최적화 여지를 얻습니다.
- Dispose 가 같은 스레드에서 불린다는 보장 —
EnterScope가 락을 획득한 스레드와Scope.Dispose()가 호출되는 스레드가 동일하다는 것을 컴파일러가 보장하므로, 해제 경로에서 "현재 스레드 ID" 를 다시 조회하지 않아도 됩니다 (스레드-static 룩업 회피). - 객체 헤더의 sync block 활용 안 함 — 락 식별자가
Lock인스턴스의 전용 필드이므로, 일반 객체에 강제로 부여되던 비용을 우회합니다. - JIT 인라이닝 여지 —
EnterScope/Scope.Dispose는 작은 메서드라 인라이닝되기 쉽습니다.
대략적인 지표로 Monitor 대비 무경합 락(uncontended lock) 진입/해제 비용이 약 25% 줄어든다는 벤치마크 보고가 여러 출처에서 인용됩니다 ([InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html), [Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)). 다만 경합이 심한 시나리오에서는 락 자체의 대기 시간이 지배적이므로, 무경합 비용 절감의 체감 효과는 워크로드에 따라 다릅니다. 실제 측정해서 확인 하는 자세를 권장합니다.
6. 함정 — object 캐스팅·할당 시 새 API 가 사라진다
여기서 가장 자주 미끄러지는 지점입니다.
6.1 컴파일러는 정적 타입만 본다
lock 문이 새 API 로 변환되는 조건은 락 대상 표현식의 정적 타입이 System.Threading.Lock 인지 입니다. 런타임에 어떤 객체인지가 아니라 컴파일러가 보는 타입입니다.
private readonly Lock _sync = new();
void Case1()
{
lock (_sync) { /* OK — 새 API */ }
}
void Case2()
{
object boxed = _sync;
lock (boxed) { /* ✗ 새 API 아님 — boxed 의 정적 타입이 object */ }
}
void Case3()
{
lock ((object)_sync) { /* ✗ 새 API 아님 — 캐스트로 정적 타입이 object */ }
}
void Case4(object o)
{
lock (o) { /* ✗ 새 API 아님 — 매개변수 타입이 object */ }
}
6.2 CS9216 — 컴파일러가 미리 알려준다
C# 13 컴파일러는 위와 같은 코드에 CS9216 경고를 발생시킵니다.
warning CS9216: A value of type 'System.Threading.Lock' converted to a different type will use likely unintended monitor-based locking in 'lock' statement.
이 경고가 떴다는 것은 "당신은 새 Lock 타입을 선언했지만, 이 lock 문에서는 옛 Monitor 경로로 폴백되었다" 는 신호입니다. 경고 무시 금지 — 의도된 폴백이라면 Lock 이 아니라 object 를 락 객체로 선언해야 의도가 명확해집니다.
6.3 대표적인 함정 시나리오
- 컬렉션에 락 객체 보관 —
Dictionary<string, object>같은 자료구조에Lock을 담아 꺼내면 정적 타입이object가 됩니다. - 제네릭 컨테이너의
T = object—List<object>에 담는 경우. - 인터페이스로 받는 헬퍼 메서드 —
void RunUnderLock(object lockObj, Action body)처럼 디자인된 헬퍼에Lock을 넘기면 헬퍼 내부에서는object입니다. - 리플렉션·동적 타입 —
dynamic변수에 담거나 리플렉션으로 꺼내는 경로.
Lock 을 도입할 때는 락 객체의 선언과 사용 모두에서 정적 타입을 Lock 으로 유지하는 것이 원칙입니다. 헬퍼 메서드를 둔다면 인자 타입을 Lock 으로 받도록 다시 설계합니다.
// ✗ 폴백 함정
static void RunUnderLock(object lockObj, Action body)
{
lock (lockObj) { body(); } // lockObj 정적 타입은 object
}
// ✓ Lock 인지 그대로 유지
static void RunUnderLock(Lock lockObj, Action body)
{
lock (lockObj) { body(); } // 새 API 로 변환됨
}
7. using 패턴으로 명시적으로 쓰기
lock 키워드 대신 EnterScope() 를 직접 부를 수도 있습니다. 동작은 동일하지만, lock 블록보다 더 풍부한 범위 제어가 필요할 때 쓸 수 있습니다.
public int ReadCount()
{
using Lock.Scope scope = _sync.EnterScope();
return _count;
}
또는 명시적 using 블록.
public void Update(Func<int, int> update)
{
using (_sync.EnterScope())
{
_count = update(_count);
}
}
언제 굳이 명시적 형태를 쓰는가?
TryEnter()와 함께 조건부 진입을 다룰 때 —lock키워드는 항상Enter하므로.- 읽는 사람에게 "이건 단순 락이 아니라 Scope 가 만들어진다" 는 점을 명시하고 싶을 때.
- 락 객체와 Scope 의 라이프타임을 코드 리뷰에서 더 명확히 드러내고 싶을 때.
대부분의 일반적인 동기화에서는 lock 키워드를 그대로 쓰는 편이 가독성이 좋습니다. 컴파일러가 이미 같은 IL 을 만들어주니, 의미 차이가 있을 때만 명시적 형태를 선택합니다.
8. TryEnter — 시간 제한이 있는 락 시도
Monitor.TryEnter(object, TimeSpan) 의 새 API 대응은 Lock.TryEnter(TimeSpan) 입니다. 다만 lock 키워드와 결합되는 형태는 없으므로, 직접 호출해야 합니다.
private readonly Lock _sync = new();
public bool TryUpdate(Action body, TimeSpan timeout)
{
if (!_sync.TryEnter(timeout))
return false;
try
{
body();
return true;
}
finally
{
_sync.Exit();
}
}
주의: TryEnter / Enter / Exit 직접 호출 경로는 Scope 의 ref struct 보호를 받지 않습니다. try/finally 와 호출 짝맞추기는 직접 책임집니다. 가능하면 EnterScope() + using 을 우선 고려하고, TryEnter 가 정말로 필요한 경우에만 직접 사용합니다.
9. 마이그레이션 가이드 — 점진적으로 옮기기
9.1 한 번에 한 클래스씩
큰 코드베이스에서 모든 object 락을 한 번에 Lock 으로 바꾸는 것은 위험합니다. 다음 순서를 권장합니다.
- 공유 자원이 있는 클래스를 식별 — 락이 같은 인스턴스 안에서만 쓰이는 경우가 안전합니다.
- 필드 타입 변경 —
private readonly object _sync = new();→private readonly Lock _sync = new(); - 빌드 + 경고 확인 — CS9216 이 뜨는 위치를 모두 점검합니다. 새 API 가 적용되지 않는 호출 경로를 발견하는 신호입니다.
- 헬퍼·인자 시그니처 정리 —
RunUnderLock(object, Action)류 헬퍼는Lock으로 다시 선언합니다. - 단위 테스트 / 통합 테스트 — 동기화 로직 자체는 동일하지만, 회귀 가능성을 점검합니다.
9.2 절대 같이 쓰지 말 것
Lock인스턴스를object락으로도 쓰는 혼용 — 예:Lock _sync이지만 일부 코드에서lock((object)_sync)로 사용. 이 경우 두 메커니즘이 동시에 한 객체에 걸려 동작이 미정의입니다.Lock을Hashtable키나Dictionary키로 사용 — 정적 타입이 흐려지면 새 API 가 사라집니다.
9.3 무엇이 바뀌지 않는가
- 재진입(reentrancy) 의미 —
Lock도Monitor와 마찬가지로 같은 스레드의 재진입을 허용합니다 (recursive). - 공정성(fairness) — 두 메커니즘 모두 강한 공정성 보장은 없습니다.
- 데드락 가능성 — 락 객체가 바뀌었을 뿐 락의 의미는 같습니다. 락 순서 규칙은 그대로 지켜야 합니다.
10. Unity 환경에서의 주의
10.1 .NET / C# 버전 호환성
System.Threading.Lock 은 .NET 9 BCL 에 포함된 타입이고, 컴파일러 변환 동작은 C# 13 부터입니다. Unity 에서 사용 가능한지는 다음 두 축으로 확인합니다.
- Unity 의 C# 컴파일러 버전 — Unity 6 이상이 C# 13 / .NET 9 까지 지원하는 방향으로 가고 있지만, 사용 중인 Unity 정확한 버전의 릴리스 노트와
Edit > Project Settings > Player > Other Settings > Api Compatibility Level설정을 직접 확인하세요. 검증되지 않은 버전에서Lock을 쓰면 컴파일은 되더라도 IL2CPP 경로에서 런타임 동작이 다를 수 있습니다. System.Threading.Lock타입 가용성 — Unity 의 BCL 어셈블리(mscorlib또는System.Threading)에Lock클래스가 포함되어야 합니다. 미지원 Unity 버전에서는 타입을 못 찾아 컴파일 에러가 납니다. 사용 전에Lock심볼이 IDE 에서 즉시 인식되는지 먼저 확인합니다.
주의: Unity 의 Mono / IL2CPP 백엔드에서 Lock 의 ref struct 동작 (스택 강제, async 경계 막기 등)이 동일하게 강제되는지는 사용 중인 정확한 Unity 버전에 따라 다릅니다. 추정으로 판단하지 말고, 타겟 Unity 버전에서 테스트로 확인하세요.
10.2 게임 핫 패스에서의 가이드
- 메인 스레드 단독 코드에는 락을 걸지 않는다 — Unity 의
MonoBehaviourAPI 대부분은 메인 스레드 한정입니다. 락 자체가 불필요합니다. - 백그라운드 스레드 ↔ 메인 스레드 데이터 교환에만 사용 —
Job System,Task.Run, custom thread 에서 메인 스레드와 공유하는 자료구조에만 동기화를 적용합니다. - 무경합 락이 잦다면 새
Lock타입의 이점이 큽니다 — 게임 루프에서 매 프레임 한 번 잡고 푸는 패턴이라면, sync block index 를 우회하는 새 API 가 GC 압박과 진입 비용 둘 다 줄여줍니다 (이론상). 측정으로 확인하세요. async/await안에서 락 잡지 말 것 — 새Lock의Scope는 ref struct 라 await 경계를 넘을 수 없습니다. 컴파일 에러로 실수가 차단됩니다 (이 자체가 안전 보장의 일부입니다).
11. 자주 묻는 질문
Q1. Monitor.Wait / Pulse 도 Lock 에서 쓸 수 있나요?
A. 쓸 수 없습니다. Monitor.Wait/Pulse/PulseAll 은 객체 헤더의 sync block 을 사용하는데, Lock 은 그 메커니즘을 우회합니다. 조건 변수 패턴이 필요하면 ManualResetEventSlim, SemaphoreSlim, Channel<T> 등 다른 동기화 프리미티브를 사용하거나 기존 lock(object) 를 유지합니다.
Q2. Lock 인스턴스 자체는 static 으로 선언해도 되나요?
A. 됩니다. 다만 static 락은 모든 인스턴스가 공유하므로 경합 가능성이 커집니다. 락의 보호 범위(인스턴스 단위 vs 타입 단위)를 의식해서 선택합니다.
Q3. Lock 도 직렬화 대상이 될 수 있나요?
A. 하지 마세요. 락 객체는 직렬화·역직렬화 의미가 없습니다. [NonSerialized] 또는 [JsonIgnore] 등으로 제외합니다.
Q4. lock(this) 대신 Lock _this = new() 를 두면 되나요?
A. 의도는 맞지만, 더 좋은 답은 "락 객체를 외부에 노출하지 말 것" 입니다. private readonly Lock _sync = new(); 로 클래스 내부에 가둡니다.
Q5. Lock 과 SemaphoreSlim(1, 1) 중 무엇을 쓰나요?
A. 동기 메서드 안에서 짧은 임계 영역이면 Lock. async/await 와 함께 임계 영역을 보호해야 하면 SemaphoreSlim 입니다 (WaitAsync/Release). 두 도구의 용도는 겹치지 않습니다.
12. 정리
- C# 13 / .NET 9 의
System.Threading.Lock은 락 전용 타입입니다. 객체 헤더의 sync block 메커니즘을 우회하고, 진입·해제 경로를 짧게 만듭니다. lock키워드 사용법은 동일하지만, 컴파일러는 락 대상의 정적 타입이Lock인지 보고 다른 IL 을 생성합니다.Monitor.Enter/Exit가 사라지고EnterScope()+Scope.Dispose()로 바뀝니다.Scope가ref struct인 것은 "락을 획득한 스레드의 스택을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제하기 위해서입니다. async 경계, 람다 캡처, 필드 저장, 다른 스레드 전달이 모두 차단됩니다.object로 캐스팅·할당하는 순간 새 API 는 사라집니다 — CS9216 경고를 보면 의도치 않은 폴백입니다. 컬렉션·헬퍼 인자·dynamic 등의 경로를 점검합니다.- 마이그레이션은 한 줄 변경 —
private readonly object _sync = new();→private readonly Lock _sync = new();호출부는 그대로 유지됩니다. - Unity 에서는 타겟 버전의 C# / .NET 호환성을 먼저 확인한 뒤 사용합니다. 메인 스레드 단독 코드에는 락 자체가 불필요합니다.
다음 글([14. 경쟁 상태와 데드락](../14.%20경쟁%20상태와%20데드락))에서는 락이 잘못 쓰일 때 발생하는 경쟁 상태(race condition)와 데드락(deadlock)을 다룹니다.
참고 자료
- [Lock Class (System.Threading) — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.threading.lock?view=net-9.0)
- [Obey lock object semantics for lock statements — C# 13.0 feature spec](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-13.0/lock-object)
- [The lock statement — C# reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock)
- [Add first class System.Threading.Lock type — dotnet/runtime#34812](https://github.com/dotnet/runtime/issues/34812)
- [How to use the new Lock object in C# 13 — InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html)
- [C# 13: Introducing System.Threading.Lock — Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(15/15)] 비동기·병렬 프로그래밍을 처음 만났을 때 지켜야 할 4가지 (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(14/15)] 경쟁 상태와 데드락 — 감 잡기 (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(12/15)] lock 문 — 임계 구역을 한 스레드만 통과시키기 (1) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(11/15)] 비동기 스트림 — IAsyncEnumerable<T> · await foreach (C# 8) (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리 (0) | 2026.05.09 |