[PART11.비동기와 동시성(3/12)] async deadlock — 왜 일어나고 어떻게 피하는가
.Result와 .Wait()의 위험 / ConfigureAwait의 역할 / 재현 코드와 해결법
목차
1. 문제 제기 — 멈춰버린 게임
로그인 화면을 만든다고 해보자. 서버에 HTTP 요청을 던지고 응답으로 토큰을 받아와야 한다. 네트워크 코드는 당연히 비동기 API(HttpClient.GetStringAsync)로 되어 있다. 그런데 호출하는 쪽은 기존 동기 코드다. 바꾸긴 번거로우니 일단 .Result로 결과를 꺼낸다.
// LoginManager.cs - 문제의 패턴
public string GetToken()
{
// "비동기 메서드를 동기로 쓰는" 한 줄
return HttpApi.GetTokenAsync().Result;
}
에디터에서 Play를 누른다. 버튼을 클릭한다. 화면이 그대로 얼어붙는다. 로그도 없고 예외도 없다. 그냥 멈춘다. 프로파일러를 켜보면 메인 스레드가 WaitOne 상태에서 움직이지 않는다.
이것이 async deadlock이다. 예외 메시지도, 스택 트레이스도 없이 앱이 통째로 정지한다. 원인을 모르면 Task.Run으로 감싸거나 GetAwaiter().GetResult()로 바꾸는 식으로 "우회"를 시도하지만, 대부분 문제를 다른 형태로 옮겨놓을 뿐이다.
이 글의 목표는 하나다. 왜 .Result 한 줄이 앱을 얼게 만드는지 상태 머신(state machine) 레벨에서 이해하고, 상황별로 올바른 해결책을 고르는 것.
비동기(asynchronous) — 어떤 작업의 결과를 바로 기다리지 않고 제어권을 먼저 반환한 뒤, 나중에 작업이 끝나면 이어받는 실행 방식.
예시:await Task.Delay(1000)— 1초를 "점유"하지 않고 제어권을 반환. 1초 뒤 이어서 실행.
2. 개념 정의 — async deadlock이란 무엇인가
2.1 식당 웨이터 비유
비동기 런타임을 1명짜리 웨이터로 생각해보자.
- 웨이터는 동시에 한 테이블만 서빙한다 (메인 스레드 1개).
- 웨이터가 "주방에 주문 전달" 같은 오래 걸리는 일을 시키고 그동안 다른 테이블을 도는 게 비동기다.
- 음식이 준비되면 다시 웨이터가 와서 배달해야 한다 (continuation).
이 상태에서 손님이 "음식 나올 때까지 여기 서서 기다리세요"라고 웨이터를 잡는다. 이것이 .Result다. 주방이 "음식 준비 끝! 웨이터 불러줘" 라고 외쳐도 웨이터는 붙잡혀 있어서 올 수 없다. 손님은 음식이 오길 기다리고, 음식은 웨이터가 자유로워지길 기다린다. 서로가 서로를 영원히 기다린다.
2.2 구조 다이어그램
2.3 최소 재현 코드
WPF든 WinForms든 Unity든, "메인 스레드에 SynchronizationContext가 존재하는 환경"이면 모두 똑같이 재현된다.
async/await— 비동기 메서드 키워드async는 "이 메서드 안에await가 있을 수 있다"는 컴파일러 힌트다.await는 awaitable(대표적으로Task)의 완료를 기다리되, 스레드를 점유하지 않고 제어권을 반환했다가 완료 시점에 이어서 실행한다.
예시:string data = await HttpClient.GetStringAsync(url);
// 재현 코드 (WPF 버튼 클릭 핸들러)
private void Button_Click(object sender, RoutedEventArgs e)
{
// ❌ 이 한 줄이 앱을 얼린다
string result = GetMessageAsync().Result;
ResultLabel.Text = result;
}
private async Task<string> GetMessageAsync()
{
await Task.Delay(1000); // 이 await가 UI Context를 캡처
return "Hello";
}
왜 멈추는가 — Button_Click이 UI 스레드에서 실행된다. .Result가 UI 스레드를 동기 차단한다. Task.Delay가 1초 뒤 완료되고, await 이후의 return "Hello"가 캡처해둔 UI SynchronizationContext로 Post된다. 하지만 UI 스레드는 .Result에 잡혀 있어 Post를 처리할 수 없다. 결과적으로 Task<string>은 완료되지 않고, .Result도 영원히 반환되지 않는다.
한 문장으로: 동기 차단이 걸린 스레드 위에서, 그 스레드로 돌아와야 하는 continuation을 기다리면 무조건 deadlock이다.
3. 내부 동작 — SynchronizationContext와 상태 머신
데드락의 진짜 원인은 "async 메서드가 컴파일러에 의해 상태 머신으로 바뀌고, 이 상태 머신이 SynchronizationContext를 캡처해서 나중에 그 위에 다시 올라타려 한다"는 점에 있다. IL을 보면 정확히 확인된다.
3.1 SynchronizationContext란 무엇인가
SynchronizationContext— "continuation(이어서 실행할 코드)을 어느 스레드/큐에 올려야 하는가"를 추상화한 객체.Post(비동기 전달) /Send(동기 전달) 메서드를 갖는다.
런타임 환경별로 서로 다른 구현체가 존재한다.
| 환경 | SynchronizationContext | 동작 |
|---|---|---|
| WPF / WinForms | DispatcherSynchronizationContext / WindowsFormsSynchronizationContext |
UI 메시지 큐에 Post |
| ASP.NET Classic (pre-Core) | AspNetSynchronizationContext |
요청 컨텍스트 잠금 |
| ASP.NET Core | 없음 (null) | Thread Pool에서 바로 실행 |
| Unity (메인 스레드) | UnitySynchronizationContext |
메인 스레드 작업 큐에 enqueue, 다음 프레임 Update에서 flush |
| Console / Task Body | 없음 (null) | Thread Pool에서 바로 실행 |
await 의 기본 동작은 "현재 SynchronizationContext.Current가 null이 아니면 그것을 캡처하고, continuation 실행 시 그 위로 Post"한다.
3.2 컴파일러가 생성하는 상태 머신 (SVG)
3.3 실제 IL — 상태 머신의 컨텍스트 캡처 지점
아래는 BadAsync(ConfigureAwait 없음)의 상태 머신이 생성한 IL에서 컨텍스트 캡처의 핵심 부분이다. 길이 때문에 MoveNext의 await 관련 구간만 발췌했다. 전체는 동일한 패턴이 반복된다.
C# 코드
public class AsyncDeadlockDemo
{
// Before: ConfigureAwait 없음 → SynchronizationContext 캡처
public async Task<string> BadAsync()
{
await Task.Delay(100);
return "done";
}
// After: ConfigureAwait(false) → 컨텍스트 캡처 생략
public async Task<string> GoodAsync()
{
await Task.Delay(100).ConfigureAwait(false);
return "done";
}
}
IL 코드 (핵심 발췌)
// ─────────────── BadAsync 상태 머신 필드 ───────────────
.class nested private sealed '<BadAsync>d__0'
implements IAsyncStateMachine
{
.field public int32 '<>1__state' // 현재 단계 번호
.field public AsyncTaskMethodBuilder`1<string> '<>t__builder' // Task 빌더
.field private TaskAwaiter '<>u__1' // ← 일반 TaskAwaiter
// (Context 캡처 ON)
}
// ─────────────── BadAsync.MoveNext() ───────────────
IL_000f: ldc.i4.s 100
IL_0011: call Task Task::Delay(int32)
IL_0016: callvirt TaskAwaiter Task::GetAwaiter() // 일반 Awaiter 획득
IL_001e: call bool TaskAwaiter::get_IsCompleted() // 즉시 완료됐나?
IL_0023: brtrue.s IL_0065 // 예 → 바로 아래로
// 아니오 → 컨텍스트 캡처 후 리턴
IL_0041: call void AsyncTaskMethodBuilder`1::
AwaitUnsafeOnCompleted<TaskAwaiter, ...>(!!0&, !!1&)
// ↑ 이 시점에서 SynchronizationContext.Current를
// 캡처하여 continuation에 붙임. Task.Delay가 끝나면
// 캡처된 Context 위로 MoveNext가 Post된다.
IL_0047: leave.s IL_00a4 // 메서드 이탈 (재진입 대기)
// ─────────────── GoodAsync 상태 머신 필드 ───────────────
.class nested private sealed '<GoodAsync>d__1'
implements IAsyncStateMachine
{
.field private ConfiguredTaskAwaitable/ConfiguredTaskAwaiter '<>u__1'
// ↑ Awaiter 타입이 다르다! ConfigureAwait(false)가 바꿔치기한 Awaiter.
// 이 Awaiter는 SynchronizationContext를 캡처하지 않는다.
}
// ─────────────── GoodAsync.MoveNext() ───────────────
IL_0011: call Task Task::Delay(int32)
IL_0016: ldc.i4.0 // false
IL_0017: callvirt ConfiguredTaskAwaitable
Task::ConfigureAwait(bool) // ← Awaiter 교체
IL_001f: call ConfiguredTaskAwaiter
ConfiguredTaskAwaitable::GetAwaiter()
IL 분석 포인트
1. IAsyncStateMachine 구현 클래스 — 힙 할당의 발원지
.class nested private sealed '<BadAsync>d__0' ... implements IAsyncStateMachine. async 메서드 한 개마다 이런 컴파일러 생성 클래스가 하나씩 만들어진다. 중요한 점은 클래스(참조 타입)이므로 힙에 할당된다는 것이다. 단, 모든 await가 동기 완료(IsCompleted=true)되면 struct로 최적화돼 할당이 생략된다. Unity 핫패스에서 async를 남발하면 이 상태 머신 객체가 프레임마다 쌓여 GC 스파이크로 이어진다.
2. AwaitUnsafeOnCompleted — 여기서 Context가 캡처된다
IL_0041: call ... AwaitUnsafeOnCompleted<TaskAwaiter, ...>. 이 호출이 "await 중단" 시점에 실행되는 핵심이다. 내부적으로 SynchronizationContext.Current를 읽어서 Awaiter에 continuation 델리게이트를 등록한다. Awaiter가 완료되면 이 델리게이트가 캡처된 Context의 Post를 통해 원래 스레드로 되돌아간다. 이 한 줄이 데드락의 발원점이다 — 호출자가 그 스레드를 block 상태로 잡고 있다면 Post된 continuation은 영원히 실행되지 못한다.
3. TaskAwaiter vs ConfiguredTaskAwaiter — 필드 타입의 차이
BadAsync는 .field private TaskAwaiter '<>u__1', GoodAsync는 .field private ConfiguredTaskAwaitable/ConfiguredTaskAwaiter '<>u__1'. 두 Awaiter는 동일한 인터페이스(INotifyCompletion)를 따르지만 구현이 다르다. ConfiguredTaskAwaiter는 내부에 m_continueOnCapturedContext = false 플래그를 가지고 있어, OnCompleted를 부를 때 SynchronizationContext를 무시하고 Thread Pool에서 바로 continuation을 실행한다. ConfigureAwait(false)는 컴파일러가 Awaiter 타입 자체를 교체하는 기능이라는 점이 IL에서 명확히 드러난다.
4. state=0 / state=-1 — 진입 경로 분기
brfalse.s IL_000c(state 검사)는 "처음 진입인지, Post로 재진입한 것인지"를 구분한다. state=-1은 최초, state=0 이상은 특정 await에서 재진입. MoveNext는 이 상태 번호로 switch-case처럼 동작하여 await 경계 너머의 코드로 점프한다. Unity의 UnitySynchronizationContext가 다음 프레임 Update에서 큐를 flush할 때 재진입하는 바로 그 시점이 state=0이다.
5. SetResult — Task가 비로소 완료되는 곳
IL_009e: call ... AsyncTaskMethodBuilder1::SetResult(!0). .Result로 기다리던 Task는 바로 이 SetResult 호출 순간에 완료 신호를 받는다. 메인 스레드가 block되어 있으면 MoveNext의 최종 실행이 일어나지 못하고, SetResult도 불리지 않는다. 결과적으로 .Result`는 영원히 반환되지 않는다.
4. 실전 적용 — Before/After와 Unity 패턴
4.1 해결 원칙 — 우선순위
| 순위 | 전략 | 적용 대상 | 설명 |
|---|---|---|---|
| 1 | Async all the way | 애플리케이션 전반 | 호출 스택 전체를 async로 만든다. 동기 차단 자체를 없앤다. 가장 올바른 해결 |
| 2 | ConfigureAwait(false) |
라이브러리 코드 | 컨텍스트를 캡처하지 않도록 선언. 라이브러리의 기본 규칙 |
| 3 | Task.Run(() => sync()) |
외부 동기 API 래핑 | CPU 바운드 동기 코드를 Thread Pool로 보낼 때만 |
| ❌ | .Result, .Wait(), GetAwaiter().GetResult() |
(원칙적으로) 금지 | Main()/엔트리포인트 같은 특수한 경우를 제외하면 사용 금지 |
4.2 Before — 동기 브리지에 기대는 코드
// LoginService.cs — 기존 동기 인터페이스를 유지하려고 .Result 사용
public class LoginService
{
public string Login(string id, string pw)
{
// HttpApi.PostAsync는 비동기인데, 여기는 동기 메서드
// ❌ UI 스레드에서 호출되면 deadlock
var response = HttpApi.PostAsync("/login", new { id, pw }).Result;
return response.Token;
}
}
// 호출부 (Unity MonoBehaviour)
public void OnLoginButtonClicked()
{
// 메인 스레드가 Login() 안의 .Result에서 block
// → HttpApi.PostAsync의 continuation이 메인 스레드로 돌아오지 못해 deadlock
string token = _loginService.Login(idField.text, pwField.text);
Debug.Log(token); // 이 로그는 찍히지 않음
}
무엇이 문제인가 — HttpApi.PostAsync 내부의 await 가 Unity의 메인 스레드 컨텍스트를 캡처한다. 상위 Login()이 .Result로 메인 스레드를 block한다. await의 continuation은 Unity의 메인 스레드 큐에 Post됐지만, Unity의 메인 스레드는 block 상태라 큐를 소비하지 못한다. 게임이 멈춘다.
4.3 After — Async all the way
// LoginService.cs — 아예 async로 인터페이스를 뒤집는다
public class LoginService
{
public async Task<string> LoginAsync(string id, string pw)
{
var response = await HttpApi.PostAsync("/login", new { id, pw });
return response.Token;
}
}
// 호출부 (Unity MonoBehaviour)
public async void OnLoginButtonClicked() // 이벤트 핸들러는 async void 허용
{
try
{
string token = await _loginService.LoginAsync(idField.text, pwField.text);
Debug.Log(token); // 메인 스레드에서 찍힘 — UI 갱신 안전
}
catch (Exception ex)
{
Debug.LogError(ex);
}
}
왜 이게 되는가 — 메인 스레드가 block되는 지점이 없다. await가 만날 때마다 메인 스레드는 즉시 반환되어 Unity 루프를 계속 돌린다. HttpApi.PostAsync가 완료되면 continuation이 메인 스레드 큐에 Post되고, Unity의 다음 Update에서 자연스럽게 실행된다. 데드락이 생길 구조 자체가 사라진다.
4.4 라이브러리 코드 — ConfigureAwait(false)
라이브러리는 호출자가 어떤 컨텍스트에서 부를지 알 수 없다. 기본적으로 컨텍스트를 캡처하면, UI 앱이 실수로 .Result를 섞어 쓸 때 deadlock의 원인이 된다. 또한 라이브러리 코드는 UI 객체를 건드리지 않으므로 원래 컨텍스트로 돌아갈 이유도 없다.
// ❌ 라이브러리 내부인데 ConfigureAwait 생략
public class HttpApi
{
public async Task<Response> PostAsync(string path, object body)
{
var json = await _serializer.SerializeAsync(body); // UI Context 캡처
var msg = await _client.PostAsync(path, json); // UI Context 캡처
return await _serializer.DeserializeAsync<Response>(msg); // UI Context 캡처
}
}
// ✅ 라이브러리 규칙 — 모든 await에 ConfigureAwait(false)
public class HttpApi
{
public async Task<Response> PostAsync(string path, object body)
{
var json = await _serializer.SerializeAsync(body).ConfigureAwait(false);
var msg = await _client.PostAsync(path, json).ConfigureAwait(false);
return await _serializer.DeserializeAsync<Response>(msg).ConfigureAwait(false);
}
}
라이브러리가 모든 await에서 ConfigureAwait(false)를 쓰면, 상위 애플리케이션이 실수로 .Result를 호출해도 라이브러리 내부의 continuation은 Thread Pool에서 실행된다 → 메인 스레드를 기다리지 않음 → 데드락 없음. 성능면에서도 UI 큐를 거치지 않으므로 컨텍스트 전환 비용이 줄어든다.
4.5 Unity 실전 — UnitySynchronizationContext 주의사항
Unity는 UnitySynchronizationContext를 메인 스레드에 설치한다. Post된 continuation은 메인 스레드 큐에 쌓이고, 다음 프레임 Update 시점에 flush된다. 따라서 Unity에서의 async는 두 가지를 특히 조심한다.
(1) UnityEngine API는 메인 스레드에서만 호출
// ❌ ConfigureAwait(false) 이후에 transform 접근
public async Task MoveAsync()
{
await Task.Delay(1000).ConfigureAwait(false);
transform.position = Vector3.zero; // InvalidOperationException
// "... can only be called from the main thread"
}
// ✅ MonoBehaviour 레벨은 기본 await 사용
public async Task MoveAsync()
{
await Task.Delay(1000); // Unity Context 캡처 → 메인 스레드로 복귀
transform.position = Vector3.zero; // 정상
}
(2) GC 부담 — async Task의 상태 머신은 힙에 할당된다
Update/FixedUpdate 같은 핫패스에서 async Task를 매 프레임 만들면 상태 머신 클래스가 매번 힙에 할당된다. Unity의 Boehm GC(세대별 수집이 없는 mark-sweep 방식)에서는 이런 작은 할당의 누적이 큰 스파이크로 이어진다.
Boehm GC — Unity(IL2CPP/Mono)가 사용하는 가비지 컬렉터. 세대 구분 없이 전체 힙을 한 번에 훑는 mark-sweep 방식이라, 할당량이 늘수록 수집 시 프레임 드랍이 커진다.
해결책은 두 가지다.
- Unity 6의
Awaitable타입을 사용 — 풀링된 상태 머신으로 할당을 줄임. - 외부 라이브러리 UniTask 사용 —
ValueTask기반 제로 할당 구조.
(3) 절대 하지 말아야 할 패턴
// ❌ Awake에서 .Result — 게임이 로딩 중 먹통
private void Awake()
{
_config = _configLoader.LoadAsync().Result;
}
// ✅ async Awake (Unity 2017+) 또는 Start 코루틴
private async void Awake()
{
_config = await _configLoader.LoadAsync();
}
4.6 Thread Pool 고갈 — ASP.NET Core의 숨은 데드락
ASP.NET Core는 SynchronizationContext가 없어서 "클래식 async deadlock"은 일어나지 않는다. 하지만 .Result는 여전히 위험하다. 요청마다 Thread Pool 스레드를 block하면, 동시 요청이 Thread Pool 스레드 수를 넘어서는 순간 Thread Pool 고갈(starvation)이 발생한다. 새로 온 요청의 continuation이 처리할 스레드를 찾지 못해 latency가 폭증하고, 심한 경우 deadlock처럼 정지한다. 고갈은 점진적이라 로컬 개발 중엔 보이지 않다가 부하 테스트/프로덕션에서 터진다.
5. 함정과 주의사항 — 우회하려다 더 망가지는 길
5.1 GetAwaiter().GetResult() — 데드락은 그대로, 예외만 깔끔
".Result는 예외를 AggregateException으로 감싸니까 GetAwaiter().GetResult()를 쓰면 되지 않나?" 라고 생각하기 쉽다. 둘 다 동기 차단이라는 본질은 똑같다. deadlock도 동일하게 발생한다. 차이는 예외 wrapping 여부뿐이다.
// ❌ 데드락은 .Result와 똑같이 발생한다
string token = HttpApi.GetTokenAsync().GetAwaiter().GetResult();
// ❌ 이것도 동일
string token = HttpApi.GetTokenAsync().Result;
// ✅ 오직 이것만 올바르다
string token = await HttpApi.GetTokenAsync();
GetAwaiter().GetResult()가 허용되는 거의 유일한 상황은 "이미 완료된 Task에서 값만 동기적으로 꺼낼 때" 혹은 "콘솔 앱의 Main 엔트리 포인트"다. UI/Unity에서는 쓰지 않는다.
5.2 Task.Run(() => GetSomethingAsync()).Result — 문제를 옮길 뿐
// ❌ 이 패턴을 쓰는 사람이 많다. 겉으로는 deadlock이 안 나는 것처럼 보인다.
string token = Task.Run(() => HttpApi.GetTokenAsync()).Result;
얼핏 동작하는 이유 — Task.Run은 Thread Pool 스레드에서 실행되고, 그 스레드에는 UnitySynchronizationContext/UI Context가 없다. 그래서 안쪽 await가 컨텍스트를 캡처하지 않아 deadlock이 풀린다.
숨은 비용 — (1) Thread Pool 스레드 하나를 통째로 block한다. Thread Pool 고갈의 원흉. (2) 호출 스레드(메인)도 .Result로 block되어 UI/게임이 멈춘다. (3) 취소 토큰 전파가 깨진다. (4) 예외 스택이 꼬인다. 결국 "deadlock은 피했지만 동결은 그대로이고 확장성까지 잃는다".
올바른 수정은 역시 async all the way다.
5.3 async void — 예외를 잡을 수 없다
async void 메서드 안에서 발생한 예외는 Task에 담기지 않는다. 대신 그 시점의 SynchronizationContext로 던져지고, UI 앱에서는 앱 종료로 이어지기 쉽다. 이벤트 핸들러를 제외하면 async void는 쓰지 않는다.
// ❌ 일반 메서드를 async void로 — 호출자가 완료/예외를 알 수 없음
public async void LoadConfigAsync()
{
_config = await _loader.LoadAsync();
throw new InvalidOperationException("bad"); // 잡히지 않고 앱 크래시
}
// ✅ 이벤트 핸들러가 아니면 async Task
public async Task LoadConfigAsync()
{
_config = await _loader.LoadAsync();
}
예외: 버튼 클릭 같은 UI 이벤트 핸들러는 시그니처상 void여야 하므로 async void 사용이 허용된다. 이 경우 핸들러 내부 전체를 try-catch로 감싸 예외를 잡아라.
5.4 생성자에서 비동기 초기화 — 구조적으로 불가능
// ❌ 생성자는 동기. .Result로 억지로 쓰면 deadlock.
public MyService()
{
_data = _loader.LoadAsync().Result;
}
// ✅ 팩토리 메서드 패턴
public class MyService
{
private MyService(Data d) => _data = d;
public static async Task<MyService> CreateAsync(ILoader loader)
{
var d = await loader.LoadAsync();
return new MyService(d);
}
}
5.5 IL로 보는 함정 — 상태 머신은 async void든 async Task든 똑같이 생성된다
async void로 바꿔도 상태 머신 클래스 생성, AwaitUnsafeOnCompleted 호출, 컨텍스트 캡처는 동일하게 일어난다. 단지 반환 타입이 AsyncVoidMethodBuilder로 바뀌는 차이뿐이다. 즉 async void로 쓴다고 해서 "Task 없이 가벼워지는 것"이 아니다 — 힙 할당은 그대로다.
6. C# 버전별 변화
6.1 C# 5.0 (2012) — async/await 도입
async/await 자체가 추가된 시점. AsyncTaskMethodBuilder, IAsyncStateMachine, TaskAwaiter 등 런타임 인프라가 확립됐다. 이때부터 SynchronizationContext 캡처가 기본 동작이 됐고, 동시에 .Result/.Wait() deadlock도 "공식 함정"으로 자리 잡았다.
6.2 C# 7.0 (2017) — ValueTask 등장
매 await마다 힙에 Task를 할당하는 비용을 줄이기 위해 ValueTask<T> 추가. 데드락 메커니즘 자체는 바뀌지 않는다. 다만 Unity처럼 할당에 민감한 환경에서 ValueTask를 쓰면 상태 머신 외의 Task 객체 할당은 줄어든다.
6.3 .NET Core / ASP.NET Core (2016~)
ASP.NET Core는 SynchronizationContext 자체를 제거했다. 그 결과 클래식 ASP.NET에서 악명 높던 .Result deadlock이 구조적으로 사라졌다. 그러나 "Thread Pool 고갈"이라는 새로운 형태의 숨은 deadlock은 여전하다.
6.4 C# 8.0 (2019) — IAsyncEnumerable / await using
비동기 스트림과 비동기 Dispose가 추가됐다. 내부적으로는 여전히 상태 머신 + Context 캡처 구조이며, 데드락 위험 요인도 동일하다.
6.5 Unity 2017+ / Unity 6
Unity 2017부터 UnitySynchronizationContext가 도입되어 await 이후 코드가 자동으로 메인 스레드로 복귀하게 됐다. Unity 6은 Awaitable 타입을 제공해 상태 머신을 풀링하여 할당을 제거한다. 단, 데드락 회피 원칙은 동일하다 — .Result는 Unity 6에서도 메인 스레드를 얼린다.
버전 간 핵심 차이를 한 표로:
| 시점 | 변화 | 데드락 관점 영향 |
|---|---|---|
| C# 5.0 | async/await, SynchronizationContext 기반 | 데드락 메커니즘 확립 |
| C# 7.0 | ValueTask | 힙 할당 감소, 데드락은 동일 |
| ASP.NET Core | SynchronizationContext 제거 | 클래식 deadlock 없음, Thread Pool 고갈 위험은 남음 |
| Unity 2017+ | UnitySynchronizationContext | Unity 메인 스레드 복귀 자동화, .Result 위험은 동일 |
| Unity 6 / UniTask | Awaitable / ValueTask 풀링 | 할당 감소, 데드락 원칙 불변 |
7. 정리
핵심 체크리스트
- [ ] async deadlock의 본질: "메인 스레드를 block한 상태에서 그 스레드로 돌아와야 하는 continuation을 기다릴 때" 발생한다.
- [ ] IL 레벨에서 보이는 것:
AwaitUnsafeOnCompleted가SynchronizationContext.Current를 캡처한다. 호출자가 그 스레드를 동기 차단하면 continuation의 Post가 영원히 처리되지 않는다. - [ ]
ConfigureAwait(false)의 실체: Awaiter 타입을TaskAwaiter→ConfiguredTaskAwaiter로 교체해 Context 캡처를 생략하게 만드는 컴파일러 기능. - [ ] 1순위 해결: Async all the way — 호출 스택 전체를 async로. 동기 차단을 없애면 deadlock도 없다.
- [ ] 2순위 해결: 라이브러리 코드에서 모든
await에ConfigureAwait(false)— 호출자의 컨텍스트 오염을 방지. - [ ] 금지 리스트:
.Result,.Wait(),GetAwaiter().GetResult(),Task.Run(...).Result우회. 셋 모두 데드락 또는 Thread Pool 고갈을 유발한다. - [ ] Unity 특이사항:
UnityEngine.API는 메인 스레드 전용 →ConfigureAwait(false)이후 Unity API를 건드리면 예외. 핫패스에서 async를 남발하면 Boehm GC 스파이크. - [ ] ASP.NET Core 예외: SynchronizationContext가 없어 클래식 데드락은 없지만,
.Result는 여전히 Thread Pool 고갈의 원인.
한 줄 요약
"비동기 코드 위에서 동기 차단은 절대 섞지 않는다." 이 원칙 하나만 지키면 async deadlock은 생길 수가 없다.
'C# 심화' 카테고리의 다른 글
| [PART11.비동기와 동시성(5/12)] CancellationToken — 비동기 작업을 취소하는 올바른 방법 (0) | 2026.04.14 |
|---|---|
| [PART11.비동기와 동시성(4/12)] ConfigureAwait(false) — 언제 필요한가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(2/12)] Task vs Thread — 무엇이 다른가 (1) | 2026.04.14 |
| [PART11.비동기와 동시성(1/12)] async-await — 상태 머신으로 변환되는 원리 (1) | 2026.04.14 |
| [PART10.예외 처리(5/5)] 사용자 정의 예외 — 올바르게 만드는 방법 (0) | 2026.04.14 |