[PART13.비동기와 스레딩 기초(14/15)] 경쟁 상태와 데드락 — 감 잡기
멀티스레딩 코드를 처음 짜면 "분명 1000번 더했는데 결과가 987이 나오고", "잘 돌던 프로그램이 어느 날 갑자기 멈춰서 안 풀린다." 두 현상은 각각 경쟁 상태(Race Condition) 와 데드락(Deadlock) 입니다. 이 글은 두 버그가 *왜* 생기는지 그림으로 감을 잡고, *최소한의 도구* (lock, Interlocked) 로 막는 법, 그리고 *피하는 코딩 습관* 까지 입문자 시각으로 정리합니다. ThreadSanitizer 같은 탐지 도구·재진입 락·메모리 배리어는 심화 커리큘럼에서 다룹니다.
목차
0. 한 줄 요약
- 경쟁 상태: 두 스레드가 같은 변수를 동시에 읽고 쓸 때, *실행 순서가 매번 달라서* 결과도 매번 달라지는 버그입니다.
- 데드락: 두 스레드가 서로 *상대방이 들고 있는 락을 기다리느라* 영원히 멈춰버리는 상태입니다.
- 방어 도구: 단순 카운터는
Interlocked, 여러 줄짜리 임계 영역은lock. 둘 다 "한 번에 하나만 들어가게" 만들어 줍니다. - 데드락 회피 규칙: 락 잡는 순서를 *모든 코드에서 동일하게*, 락 안에서는 *외부 코드 호출 금지*, 락 범위는 *최대한 짧게*.
1. 왜 멀티스레딩에서 결과가 어긋날까
1-1. counter++ 는 한 줄이 아닙니다
C# 코드를 작성하는 사람의 눈에는 counter++ 가 한 동작으로 보입니다. 그런데 CPU 입장에서는 세 단계입니다.
- Read — 메모리에서
counter값을 레지스터로 가져옵니다. - Modify — 레지스터에서 1을 더합니다.
- Write — 레지스터의 새 값을 메모리에 다시 씁니다.
이 세 단계 사이 *어디든* 다른 스레드가 끼어들 수 있습니다. 끼어들면 한 번 증가가 통째로 사라집니다.

A 가 10을 읽은 직후 B 가 끼어들어 똑같이 10을 읽고, 둘 다 11을 씁니다. 두 번 더했는데 한 번분만 적용됐습니다. 이게 잃어버린 갱신(Lost Update) 으로 부르는 가장 흔한 경쟁 상태입니다.
1-2. 컴파일러·CPU 의 명령 재배치
JIT 컴파일러와 CPU 는 *단일 스레드 결과만 같다면* 명령 순서를 자유롭게 바꿔도 된다는 규칙으로 최적화합니다. 단일 스레드에선 문제가 없지만, 다른 스레드가 그 변수를 *중간 상태* 로 관찰할 때 의도가 깨집니다. 이 글에서는 "이런 것도 있다" 정도로만 짚고, 본격적인 해결(메모리 배리어, volatile)은 *심화 커리큘럼* 에서 다룹니다.
용어 짧게 — *원자적(atomic) 연산*: 중간에 끊기지 않고 한 번에 끝나는 연산입니다. CPU 가 직접 보장합니다. *임계 영역(critical section)*: 한 번에 한 스레드만 들어가야 안전한 코드 구간입니다.
2. 비유로 잡기
은행 ATM 두 대가 같은 통장 잔고에 동시에 접근한다고 상상합니다. 두 사람이 동시에 1만 원씩 입금하는데, 두 ATM 모두 *입금 직전 잔고* 를 따로 읽어서 1만 원을 더하고 *각자* 결과를 통장에 씁니다. 한쪽이 덮어써서 1만 원이 사라집니다 — 경쟁 상태.
이번엔 두 사람이 *각자* 다른 ATM 에서 작업 중인데, 첫 번째 사람은 통장 A 의 인증을 잡은 채 통장 B 의 인증을 기다리고, 두 번째 사람은 통장 B 의 인증을 잡은 채 통장 A 의 인증을 기다립니다. 둘 다 손에 든 자원을 놓지 못한 채 영원히 기다립니다 — 데드락.
차이를 한 줄로:
- 경쟁 상태 → "결과가 *틀려진다*."
- 데드락 → "결과를 못 내고 *멈춘다*."
3. 코드로 감 잡기 — 경쟁 상태 재현과 방어
3-1. 가장 작은 재현 코드
using System;
using System.Threading;
using System.Threading.Tasks;
class RaceDemo
{
static int counter = 0;
static void Main()
{
// 두 스레드가 각각 100,000번씩 ++ → 기대값 200,000
Parallel.Invoke(
() => { for (int i = 0; i < 100_000; i++) counter++; },
() => { for (int i = 0; i < 100_000; i++) counter++; }
);
Console.WriteLine($"실제: {counter} / 기대: 200,000");
}
}
이 코드를 여러 번 돌리면 결과가 매번 다릅니다. 어떤 때는 198,742, 어떤 때는 173,005. 200,000 이 *우연히* 나올 수도 있습니다. 멀티스레드 버그는 이렇게 *재현이 들쑥날쑥하다는 것* 자체가 가장 골치 아픈 특징입니다.
3-2. Interlocked — 단순 연산의 가벼운 답
counter++ 정도의 *한 변수에 대한 단순 연산* 은 Interlocked 가 제일 빠릅니다. CPU 가 직접 지원하는 원자적 명령(예: LOCK XADD) 으로 컴파일되어, 락 획득·해제의 OS 비용이 들지 않습니다.
static int counter = 0;
Parallel.Invoke(
() => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); },
() => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); }
);
Console.WriteLine($"실제: {counter}"); // 항상 200,000
세 가지만 기억하면 충분합니다.
| 메서드 | 의미 |
|---|---|
Interlocked.Increment(ref x) |
x++ 를 원자적으로 |
Interlocked.Add(ref x, n) |
x += n 를 원자적으로 |
Interlocked.CompareExchange(ref x, newValue, expected) |
x 가 expected 와 같을 때만 newValue 로 바꿉니다. 락 없이 동시성 자료구조를 만드는 핵심 도구입니다. |
3-3. lock — 여러 줄을 묶어야 할 때
상태가 여러 변수에 걸쳐 있어 *동시에 일관* 돼야 한다면 lock 으로 묶습니다.
class Wallet
{
private readonly object _gate = new(); // 락 전용 객체
private int _krw;
private int _coin;
public void Exchange(int krwIn, int coinOut)
{
// 두 변수의 변경이 "동시에" 일어난 것처럼 보여야 합니다.
lock (_gate)
{
_krw -= krwIn;
_coin += coinOut;
}
}
}
lock(obj) { ... } 은 컴파일러가 다음 패턴으로 풀어줍니다(개념적 형태).
bool taken = false;
try
{
Monitor.Enter(_gate, ref taken);
_krw -= krwIn;
_coin += coinOut;
}
finally
{
if (taken) Monitor.Exit(_gate);
}
핵심은 두 가지입니다.
Monitor.Enter가 상호 배제 를 만듭니다. 다른 스레드가 이미 들어가 있으면 풀릴 때까지 기다립니다.try/finally로 예외가 터져도 락은 반드시 풀립니다. 락이 풀리지 않으면 그 자체가 또 다른 정지 원인이 됩니다.
락 객체 고르는 규칙 —private readonly object _gate = new();같은 *전용 객체* 를 만들어 잠급니다.this,typeof(MyClass),string리터럴 같은 *외부에 노출된 참조* 는 다른 코드가 같은 객체로 락을 잡아 의도치 않은 데드락을 만들 수 있어 피합니다.
3-4. IL 로 한 번 들여다보기 — lock 은 정말 try/finally 일까
C# 13 이전 컴파일러로 lock(_gate) { ... } 을 컴파일한 IL 은 다음과 같습니다(요지만 발췌).
ldarg.0
ldfld object Wallet::_gate
stloc.0
ldc.i4.0
stloc.1
.try
{
ldloc.0
ldloca.s 1
call void [System.Threading]Monitor::Enter(object, bool&)
// ...임계 영역 본문...
leave.s END
}
finally
{
ldloc.1
brfalse.s SKIP
ldloc.0
call void [System.Threading]Monitor::Exit(object)
SKIP: endfinally
}
END: ret
읽기 포인트:
Monitor.Enter(obj, ref taken)와Monitor.Exit(obj)가 그대로 보입니다.lock키워드는 문법적 설탕 입니다.taken변수가 핵심입니다.Enter가 락을 *실제로 잡았는지* 가 기록되고,finally에서 잡힌 경우에만Exit합니다. 락을 못 잡은 채로 예외가 나면 잘못된Exit를 호출하지 않습니다.Interlocked.Increment는 IL 에서 일반 메서드 호출처럼 보이지만, JIT 가 플랫폼별 *원자적 명령* (x86:LOCK XADD, ARM:LDADD) 으로 인라인화합니다. 그래서lock보다 압도적으로 빠른 겁니다.
C# 13 이상에서는 System.Threading.Lock 타입을 도입하면서 IL 패턴이 약간 달라지지만, "Enter/Exit 를 try/finally 로 감싼다"는 본질은 동일합니다.
4. 데드락 — 멈춰서 안 풀리는 상태
4-1. 데드락의 4가지 필요 조건 (Coffman 조건)
네 조건이 *동시에* 충족돼야 데드락이 일어납니다. 거꾸로, *하나만 깨도* 데드락은 사라집니다. 예방 규칙은 모두 이 네 가지를 깨는 방법입니다.
| 조건 | 의미 | 깨는 법 |
|---|---|---|
| 상호 배제 | 자원은 한 번에 한 스레드만 점유 | (락의 본질이라 깨기 어려움) |
| 점유와 대기 | 자원을 들고 다른 자원을 기다림 | 필요한 락을 *한꺼번에* 잡거나 기다리는 동안 들고 있던 락을 놓는다 |
| 비선점 | 들고 있는 락을 빼앗을 수 없음 | Monitor.TryEnter 처럼 *타임아웃·실패* 가능한 API 사용 |
| 순환 대기 | A→B, B→A 로 사이클이 만들어짐 | 락 획득 순서를 전역적으로 통일 |
4-2. 가장 작은 데드락 재현 코드
using System.Threading;
class DeadlockDemo
{
static readonly object lockA = new();
static readonly object lockB = new();
static void Main()
{
var t1 = new Thread(() =>
{
lock (lockA)
{
Thread.Sleep(50); // 상대가 lockB 를 잡을 시간 확보
lock (lockB) { /* unreached */ }
}
});
var t2 = new Thread(() =>
{
lock (lockB)
{
Thread.Sleep(50);
lock (lockA) { /* unreached */ }
}
});
t1.Start(); t2.Start();
t1.Join(); t2.Join(); // 영원히 안 끝남
}
}
4-3. 그림으로 보는 순환 대기

4-4. 한 줄만 고치면 풀리는 이유
위 코드를 두 스레드 모두 lockA → lockB 순서로 통일 하면 데드락이 사라집니다.
// t2 의 락 순서를 t1 과 같게 맞춤
var t2 = new Thread(() =>
{
lock (lockA) // 먼저 lockA
{
Thread.Sleep(50);
lock (lockB) { /* ok */ }
}
});
A 를 먼저 잡은 한 스레드만 진행하고, 나머지는 *A 부터* 차분히 기다립니다. 순환이 안 만들어집니다 — Coffman 조건 4(순환 대기)가 깨졌습니다.
5. 예방 규칙 세 가지 — "감 잡기" 단계에서 이것만 지켜도 80%
규칙 1. 락 획득 순서를 *전역적으로* 같게 유지한다
여러 락을 함께 잡아야 한다면, *어떤 코드 경로에서든* 같은 순서로 잡습니다. 객체에 정렬 가능한 식별자(예: RuntimeHelpers.GetHashCode, ID) 가 있으면 작은 쪽 먼저 잡는 식으로 *기계적으로* 정합니다.
// 두 계좌를 동시에 잠가야 할 때 — ID 작은 쪽부터
public static void Transfer(Account from, Account to, int amount)
{
var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
lock (first.Gate)
lock (second.Gate)
{
from.Balance -= amount;
to.Balance += amount;
}
}
규칙 2. 락 안에서 *외부* 코드를 호출하지 않는다
이벤트 발행, 콜백, 가상 메서드, 외부 라이브러리 호출은 *어떤 락을 추가로 잡을지 알 수 없습니다.* 락을 쥔 채로 그런 코드를 부르면 의도치 않은 순환이 만들어집니다.
// ❌ 위험 — 락 안에서 콜백 호출
lock (_gate)
{
_items.Add(item);
OnItemAdded?.Invoke(item); // 구독자가 또 다른 락을 잡으면? 데드락 가능성
}
// ✅ 안전 — 락 밖으로 빼고 발행
ItemAddedEvent? toRaise = null;
lock (_gate)
{
_items.Add(item);
toRaise = OnItemAdded;
}
toRaise?.Invoke(item);
규칙 3. 락 범위는 *최대한 짧게*
락을 들고 있는 시간이 길수록 다른 스레드가 굶고, 데드락이 만들어질 수 있는 *틈* 이 늘어납니다. 락 안에서는 *공유 상태 변경* 만 하고, 계산·I/O·로깅은 락 밖에서 합니다.
// ❌ 락 안에서 무거운 작업
lock (_gate)
{
var json = JsonSerializer.Serialize(_state); // CPU 무거움
File.WriteAllText("dump.json", json); // I/O — 절대 락 안에서 X
}
// ✅ 스냅샷만 락 안에서 만들고 락 밖에서 처리
State snapshot;
lock (_gate)
{
snapshot = _state.Clone();
}
var json = JsonSerializer.Serialize(snapshot);
File.WriteAllText("dump.json", json);
6. Unity 입문자가 만나기 쉬운 함정
Unity 게임 로직은 대부분 *메인 스레드* 에서 돌아갑니다. 그래서 입문자는 "나는 멀티스레딩 안 쓰는데"라고 생각하기 쉽습니다. 그러나 다음 상황은 *모르는 사이* 다른 스레드를 끌어옵니다.
Task.Run— 무거운 계산을 백그라운드로 보낸 뒤, 그 결과를 메인 스레드의 게임 상태에 반영할 때 둘 사이 공유 변수에 경쟁 상태가 생깁니다.async/await+ConfigureAwait(false)—await이후 코드가 *백그라운드* 스레드에서 이어지는데, 그 코드에서 Unity API 를 부르면 충돌합니다. (Unity API 는 거의 다 *메인 스레드 전용* 입니다.)- C# Job System / Burst — 프레임워크 차원에서 안전 검사를 강제하지만, *static* 필드나 외부 컬렉션을 만지면 여전히 경쟁 상태가 가능합니다.
- 이벤트 콜백 — 네트워크 라이브러리(예: 일부 소켓·SDK) 콜백이 *백그라운드 스레드* 에서 호출됩니다. 거기서
GameObject를 직접 만지면 즉시 예외 또는 더 미묘한 깨짐이 생깁니다.
현실적인 입문자 가이드라인 — 백그라운드 스레드에서는 *데이터만 계산해서 큐로 메인 스레드에 넘기고*, Unity API 는 항상 메인 스레드에서 호출합니다. 공유하는 단순 카운터·플래그는Interlocked로 막고, 그 이상 복잡한 상태는 메시지 큐(예:ConcurrentQueue<T>) 한 곳을 두고 *한 스레드만 쓰기* 패턴으로 잡으면 대부분의 사고를 피할 수 있습니다.
7. 한눈 비교 — Interlocked vs lock
| 항목 | Interlocked |
lock (Monitor) |
|---|---|---|
| 보호 범위 | 단일 변수, 한 연산 | 여러 줄·여러 변수 |
| 비용 | 매우 저렴 (CPU 명령 1~2개 수준) | 상대적으로 비쌈 (경쟁 시 OS 대기) |
| 데드락 가능성 | 거의 없음 (단일 연산이라 사이클 없음) | *순서 통일 안 하면* 가능 |
| 적합한 예 | 카운터, 플래그, 시퀀스 ID 발급 | 컬렉션 변경, 두 필드 동시 갱신 |
| 함정 | int/long/ref 타입 위주, 복합 로직 표현 한계 |
락 객체 잘못 고르거나 외부 호출 들이는 실수 |
원칙 — 단일 변수는 Interlocked, 그 외는 lock. 락이 꼭 필요한 상황에서만 락을 씁니다.
심화 커리큘럼 (이 글의 범위 밖)
본격적으로 멀티스레딩 디버깅·튜닝에 들어가면 다음 주제가 따라옵니다. 이 시리즈에서는 별도 글로 다룹니다.
- 메모리 모델·메모리 배리어:
volatile,Volatile.Read/Write,Interlocked.MemoryBarrier— JIT/CPU 재배치를 *언제* 막아야 하는가.- 재진입 락(Reentrant Lock): 같은 스레드가 같은 락을 다시 잡을 때의 동작과 함정.
SemaphoreSlim,ReaderWriterLockSlim,System.Threading.Lock같은 추가 동기화 프리미티브.- Lock-free 자료구조:
CompareExchange기반의 큐·스택·해시맵 설계.- 탐지 도구: ThreadSanitizer (Linux/Clang), Visual Studio Concurrency Visualizer, BenchmarkDotNet 의 동시성 모드, Unity Burst 의 aliasing 검사.
- Async 동기화 컨텍스트와 데드락: WinForms/WPF/
SynchronizationContext위에서.Result/.Wait()가 만드는 클래식 데드락.
8. 정리
- 멀티스레드 코드의 두 대표 버그: 결과가 *틀려지는* 경쟁 상태, 결과 없이 *멈추는* 데드락.
- 경쟁 상태의 뿌리는 *비원자적 연산* 과 *재배치*. 단일 변수는
Interlocked로, 여러 줄은lock으로 막습니다. - 데드락은 *순환 대기* 가 핵심 — 락 순서 통일, 락 안에서 외부 호출 금지, 락 범위 최소화 세 규칙으로 거의 막습니다.
- Unity 메인 스레드 환경이라도
Task.Run·async/await·콜백·Job System 사용 시 동일한 규칙이 그대로 적용됩니다. - 더 깊은 메모리 모델·탐지 도구·Lock-free 설계는 심화 커리큘럼에서 이어 갑니다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(15/15)] 비동기·병렬 프로그래밍을 처음 만났을 때 지켜야 할 4가지 (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13) (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 |