WPF / WinForms의 async/await와 UI 스레드를 한 장으로 정리 - await 후의 돌아갈 곳, Dispatcher, ConfigureAwait, .Result / .Wait()의 막힘 포인트
· 小村 豪 · C#, async/await, .NET, WPF, WinForms, UI, 스레드
WPF / WinForms에서 async / await를 사용할 때 가장 망설이기 쉬운 것은 await 후에 어느 스레드로 돌아갈 것인가, 그리고 언제 UI를 건드려도 되는가입니다.
특히 Dispatcher, BeginInvoke, ConfigureAwait(false), .Result / .Wait()가 섞이면 화면 프리즈나 크로스 스레드 예외의 원인이 보이기 어려워집니다.
이 글에서는 WPF / WinForms의 UI 스레드와 async / await의 관계에 한정하여 정리합니다.
async / await의 전체적인 판단 축은 C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표와 이어지는 형태입니다.
실무에서 정말로 피 냄새가 나는 것은 대개 이 부근입니다.
await후, 어디서 이어지는 것이 동작하는지 모른다Task.Run을 끼운 후에 UI를 건드려도 되는지 모른다ConfigureAwait(false)를 어디에 붙여야 할지 망설여진다.Result/.Wait()/.GetAwaiter().GetResult()로 화면이 굳어진다- WPF의
Dispatcher와 WinForms의Invoke/BeginInvoke/InvokeAsync가 머릿속에서 섞인다
WPF / WinForms는 둘 다 UI 스레드 중심의 모델입니다.
그래서 async / await의 정리에서 가장 효과적인 것은 「비동기란 무엇인가」라는 철학적인 이야기보다, UI 스레드와 메시지 루프에 대해 무엇을 하고 있는가를 분명히 하는 것입니다.
이 글에서는 주로 .NET 6 이후의 WPF / WinForms 앱을 전제로,
await 후의 돌아갈 곳, Dispatcher, ConfigureAwait(false), .Result / .Wait()로 막히는 이유를 실무에서 사용하기 쉬운 순서로 정리합니다.
덧붙여, WinForms의 Control.InvokeAsync는 .NET 9 이후입니다.
그보다 전의 WinForms에서는 기본은 BeginInvoke / Invoke를 사용합니다.
1. 먼저 결론(한마디로)
- WPF / WinForms의 UI 이벤트 핸들러에서 plain
await한 경우,await후의 이어지는 것은 기본적으로 UI 스레드로 돌아간다고 생각하면 좋다 Task.Run은 CPU 계산을 UI 스레드에서 빼내기 위한 것이며, I/O 대기를 감싸는 도구가 아니다- UI 핸들러 안에서
await Task.Run(...)해도, 그await가 plainawait라면 이어지는 것은 통상 UI 스레드로 돌아간다 ConfigureAwait(false)는 그await로 캡처한 UI 컨텍스트로 돌아가는 것을 강제하지 않는다는 의미. 붙인 후의 이어지는 것에서 UI를 직접 건드리는 것은 위험.Result/.Wait()/.GetAwaiter().GetResult()는 UI 스레드를 막는다.await의 연속이 UI로 돌아갈 필요가 있다면, 꽤 일반적으로 막힌다- WPF에서 명시적으로 UI로 되돌린다면
Dispatcher.InvokeAsync - WinForms에서 명시적으로 UI로 되돌린다면, 기존에는
BeginInvoke, .NET 9 이후라면InvokeAsync가 async 플로우와 상성이 좋다 - 먼저의 방침은 UI의 가장 바깥쪽은 plain
await, 범용 라이브러리는ConfigureAwait(false)를 검토, UI로의 되돌림은 필요한 장소에서만 명시입니다
요컨대 WPF / WinForms에서는
- 지금 어느 스레드에서 달리고 있는가
await의 연속이 어디로 돌아가는가- UI로 되돌리는 책임을 어디가 질 것인가
이 3가지를 보면 꽤 정리하기 쉬워집니다.
2. 먼저 한 장으로 정리
2.1. 전체상
먼저 이 그림으로 대략 전체상을 파악하는 것이 빠릅니다.
flowchart LR
A["UI 이벤트 핸들러<br/>(WPF / WinForms)"] --> B["plain await<br/>I/O API"]
B --> C["UI SynchronizationContext를 잡는다"]
C --> D["await 후는 UI 스레드에서 재개"]
D --> E["UI 업데이트를 그대로 쓸 수 있다"]
A --> F["await Task.Run(...)<br/>무거운 CPU 처리"]
F --> G["계산 본체는 ThreadPool"]
G --> H["await 후는 UI 스레드에서 재개"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["UI로 돌아가는 것을 강제하지 않음"]
J --> K["이어지는 것은 임의의 스레드"]
K --> L["직접 UI 업데이트는 위험<br/>Dispatcher / Invoke가 필요"]
A --> M["SomeAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
M --> N["UI 스레드를 블로킹"]
N --> O["연속이 UI로 돌아갈 수 없음"]
O --> P["행 / 데드락 / 적어도 프리즈"]
실무에서 보면 대개 다음 4가지 패턴입니다.
- UI 이벤트 핸들러에서 plain
await - UI 이벤트 핸들러에서
Task.Run을 사용해 CPU를 도망시킨다 ConfigureAwait(false)로 돌아갈 곳을 뺀다.Result/.Wait()로 UI 스레드를 막는다
2.2. 먼저의 판단표
| 상황 | 대기 중에 어디가 동작하는가 | await 후의 이어지는 것 |
UI를 직접 건드려도 좋은가 | 먼저의 선택 |
|---|---|---|---|---|
UI 핸들러에서 await SomeIoAsync() |
I/O의 완료 대기. UI 스레드 자체는 메시지 루프로 돌아갈 수 있다 | 기본은 UI 스레드 | 좋다 | plain await |
UI 핸들러에서 await Task.Run(...) |
무거운 CPU는 ThreadPool | 기본은 UI 스레드 | 좋다 | CPU만 Task.Run |
UI 핸들러에서 await x.ConfigureAwait(false) |
돌아갈 곳을 UI로 고정하지 않는다 | 임의의 스레드 | 좋지 않다 | UI 코드에서는 기본 피한다 |
UI 스레드에서 x.Result / x.Wait() |
UI 스레드가 대기로 막힌다 | 애초에 연속이 돌아가기 어렵다 | 좋지 않다 | 사용하지 않는다 |
배경 스레드나 ConfigureAwait(false) 후에 UI 업데이트하고 싶다 |
UI와는 별도 스레드에서 동작하고 있다 | 그대로는 UI가 아니다 | 좋지 않다 | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
이 표에서 중요한 것은 plain await는 UI 코드에서는 오히려 아군이라는 점입니다.
적은 await 그 자체가 아니라 UI 스레드를 동기적으로 막는 것입니다.
3. 이 글에서 사용하는 용어
3.1. UI 스레드와 메시지 루프
WPF / WinForms의 UI는 기본적으로 UI 스레드가 1개 있고, 거기가 입력・묘화・이벤트 처리를 돌린다는 형태입니다.
이 UI 스레드는 대략 다음 역할을 가지고 있습니다.
- 버튼 누름, 키 입력, 재묘화 등의 메시지를 처리한다
- 컨트롤이나 UI 오브젝트를 안전하게 건드릴 수 있는 유일한 스레드가 된다
- 거기에 처리를 너무 많이 밀어넣으면 화면 업데이트나 입력 응답이 멈춘다
여기서의 핵심은 UI 스레드는 「빠르게 도는 것」이 일이라는 것입니다. 여기를 길게 블로킹하면 마우스도 키보드도 재묘화도 막히고, 사용자로부터는 「굳었다」로 보입니다.
이 이미지는 다음 그림으로 가지고 있으면 꽤 정리하기 쉽습니다.
flowchart LR
A["사용자 입력 / 재묘화 요청"] --> B["UI 스레드의 메시지 루프"]
B --> C["이벤트 핸들러 실행"]
C --> D["화면 업데이트"]
D --> B
C --> E["긴 동기 처리"]
E --> F["메시지 루프가 돌지 않음"]
F --> G["화면이 굳어 보임"]
3.2. SynchronizationContext / Dispatcher / Invoke
여기서 자주 나오는 용어를 실무용으로 대략 나누면 이렇습니다.
| 용어 | 여기서의 의미 |
|---|---|
| UI 스레드 | UI 오브젝트를 만든 스레드. 기본은 여기만이 UI를 안전하게 건드릴 수 있다 |
| 메시지 루프 | UI 스레드가 메시지를 순서대로 처리하는 구조 |
SynchronizationContext |
「그 실행 장소로 처리를 되돌린다」를 위한 추상화 |
Dispatcher |
WPF의 UI 스레드용 큐 |
Invoke / BeginInvoke / InvokeAsync |
UI 스레드로 처리를 던지기 위한 API |
세세하게 말하면, await는 연속처를 결정할 때 현재의 SynchronizationContext를 우선하고, 없으면 비기본 TaskScheduler도 봅니다.
다만, WPF / WinForms의 실무에서는 먼저 UI의 SynchronizationContext가 효과적이다라고 생각하면 충분합니다.
프레임워크별 대응은 대략 다음과 같이 보면 알기 쉽습니다.
| 프레임워크 | UI 측의 컨텍스트 | 명시적으로 UI로 되돌리는 대표 API |
|---|---|---|
| WPF | DispatcherSynchronizationContext |
Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke |
| WinForms | WindowsFormsSynchronizationContext |
Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync |
WPF는 Dispatcher가 중심입니다.
WinForms는 컨트롤의 핸들과 메시지 루프가 중심이며, BeginInvoke / Invoke가 표에 나옵니다.
실무에서는 추상화와 실체의 관계를 이 정도로 기억하면 섞이기 어렵습니다.
flowchart TD
A["현재의 코드"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.BeginInvoke / Invoke / InvokeAsync(.NET 9+)"]
4. 전형 패턴
4.1. UI 이벤트 핸들러에서 plain await
가장 자연스러운 형태입니다.
private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
LoadButton.IsEnabled = false;
StatusText.Text = "읽는 중...";
try
{
string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
PreviewTextBox.Text = text;
StatusText.Text = "완료";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
LoadButton.IsEnabled = true;
}
}
이 코드에서는 LoadButton_Click은 UI 스레드 상에서 시작됩니다.
그리고 await File.ReadAllTextAsync(...)는 plain await이므로, 통상은 그 시점의 UI 컨텍스트를 잡습니다.
그래서,
- 파일 I/O의 대기 중은 UI 스레드를 점유하지 않음
- 읽기 완료 후의 연속은 기본적으로 UI 스레드로 돌아간다
PreviewTextBox.Text = text;를 그대로 쓸 수 있다
는 형태가 됩니다.
여기서 쓸데없는 Dispatcher는 필요 없습니다.
UI 핸들러 안에서 plain await했을 뿐이라면 보통 그대로 UI를 건드릴 수 있습니다.
WinForms에서도 보는 법은 같습니다.
Click 핸들러 안에서 plain await하고 있는 한, 연속은 기본적으로 UI 측으로 돌아갑니다.
그림으로 하면 이런 흐름입니다.
sequenceDiagram
participant UI as UI 스레드
participant IO as 비동기 I/O
participant Ctx as UI SynchronizationContext
UI->>UI: Click 핸들러 시작
UI->>IO: ReadAllTextAsync를 await
UI-->>Ctx: 연속을 UI로 되돌리는 예약
Note over UI: 대기 중은 메시지 루프로 돌아간다
IO-->>Ctx: I/O 완료
Ctx-->>UI: 연속을 UI 스레드에서 재개
UI->>UI: TextBox / Label을 업데이트
4.2. 무거운 CPU 계산만 Task.Run
Task.Run이 효과적인 것은 무거운 CPU 계산을 UI 스레드에서 빼내고 싶을 때입니다.
private async void HashButton_Click(object sender, RoutedEventArgs e)
{
HashButton.IsEnabled = false;
ResultText.Text = "계산 중...";
try
{
byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);
string hash = await Task.Run(() =>
{
using SHA256 sha256 = SHA256.Create();
byte[] digest = sha256.ComputeHash(data);
return Convert.ToHexString(digest);
});
ResultText.Text = hash;
}
catch (Exception ex)
{
ResultText.Text = ex.Message;
}
finally
{
HashButton.IsEnabled = true;
}
}
이 코드에서 일어나고 있는 것은 대략 이렇습니다.
- UI 스레드에서 이벤트 핸들러가 시작된다
File.ReadAllBytesAsync의 I/O 대기는 비동기로 흘린다- 무거운 해시 계산만
Task.Run으로 ThreadPool에 낸다 await Task.Run(...)의 연속은 plainawait이므로 UI 스레드로 돌아간다ResultText.Text = hash;를 그대로 쓸 수 있다
즉, Task.Run의 안만이 별도 스레드입니다.
await 후까지 영구적으로 「이제 UI가 아닌 장소」로 가는 것이 아닙니다.
여기를 1장으로 보면 오해하기 어렵습니다.
sequenceDiagram
participant UI as UI 스레드
participant IO as 비동기 I/O
participant Pool as ThreadPool
UI->>IO: ReadAllBytesAsync를 await
IO-->>UI: plain await이므로 UI에서 재개
UI->>Pool: Task.Run으로 무거운 CPU 처리를 던진다
Pool-->>UI: 계산 결과를 반환
Note over UI: await Task.Run(...)의 연속은 UI에서 재개
UI->>UI: 화면에 결과 반영
여기서의 주의는 2가지입니다.
- I/O 대기를
Task.Run으로 감싸지 않는다 Task.Run은 「비동기화」가 아니라 「CPU의 도망처」를 만드는 것이라고 생각한다
Task.Run(async () => await File.ReadAllTextAsync(...))와 같은 쓰는 법은 I/O 대기를 쓸데없이 ThreadPool로 다시 던지고 있을 뿐으로, 별로 이득이 없습니다.
4.3. ConfigureAwait(false)는 「돌아가지 않는 보증」이 아니라 「돌아가기를 강제하지 않는다」
여기가 가장 오해되기 쉬운 곳입니다.
먼저, ConfigureAwait(false)가 어울리는 것은 UI나 특정 앱 모델에 의존하지 않는 범용 라이브러리 코드입니다.
public sealed class DocumentRepository
{
public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return text.Replace("\r\n", "\n", StringComparison.Ordinal);
}
}
이 메서드는 UI를 건드리지 않습니다.
WPF에서도 WinForms에서도 ASP.NET Core에서도 worker에서도 사용할 수 있는 형태입니다.
이런 코드에서는 ConfigureAwait(false)가 꽤 자연스럽습니다.
그리고, UI 측의 호출은 plain await로 좋습니다.
private readonly DocumentRepository _repository = new();
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
OpenButton.IsEnabled = false;
StatusText.Text = "읽는 중...";
try
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None);
PreviewTextBox.Text = text;
StatusText.Text = "완료";
}
catch (Exception ex)
{
StatusText.Text = ex.Message;
}
finally
{
OpenButton.IsEnabled = true;
}
}
여기서 중요한 것은 라이브러리 내의 ConfigureAwait(false)는 호출원의 await까지 강제적으로 false로 하지 않는다는 점입니다.
즉,
- 라이브러리 내부에서는 UI로 돌아가지 않는다
- 그것을 UI 핸들러가 plain
await하면, 호출원의 연속은 UI로 돌아간다
는 분리가 가능합니다.
반대로, UI 핸들러 자신이 이렇게 쓰면 위험합니다.
private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
string text = await _repository.LoadNormalizedTextAsync(
PathTextBox.Text,
CancellationToken.None).ConfigureAwait(false);
PreviewTextBox.Text = text;
}
이 경우 OpenButton_Click의 그 await의 연속은 UI로 돌아가는 것을 강제하지 않습니다.
그래서 PreviewTextBox.Text = text;는 크로스 스레드 액세스가 될 수 있습니다.
또 하나 수수하게 중요한 점이 있습니다.
ConfigureAwait(false)는 「반드시 ThreadPool로 옮긴다」가 아닙니다.
그 await가 기다리지 않고 즉시 완료된 경우, 연속은 그대로 지금의 스레드에서 흐르는 경우가 있습니다.
그래서 ConfigureAwait(false)는
- 「반드시 별도 스레드로 간다」
- 「여기부터 앞은 계속 UI가 아니다」
라는 의미가 아닙니다.
의미로서는 어디까지나
- 그
await의 연속을 원래의 UI 컨텍스트로 되돌리는 것을 강제하지 않는다
입니다. 이쪽이 꽤 사고나기 어려운 이해입니다.
정리 그림으로 하면 이렇게 봅니다.
flowchart LR
A["UI 핸들러에서 await"] --> B{"ConfigureAwait(false)를 붙이는가?"}
B -- 아니오 --> C["연속은 기본 UI 스레드"]
C --> D["그대로 UI 업데이트하기 쉽다"]
B -- 예 --> E["연속은 UI로 고정하지 않음"]
E --> F["임의의 스레드에서 재개할 수 있음"]
F --> G["UI 업데이트에는 Dispatcher / Invoke가 필요"]
4.4. .Result / .Wait() / .GetAwaiter().GetResult()로 막히는 이유
여기가 가장 자주 보는 사고입니다.
private void LoadButton_Click(object sender, RoutedEventArgs e)
{
string text = LoadTextAsync().Result;
PreviewTextBox.Text = text;
}
private async Task<string> LoadTextAsync()
{
string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
return text.ToUpperInvariant();
}
얼핏 보면 그냥 동기로 결과를 취하고 있는 것으로만 보입니다. 하지만 UI 스레드에서는 꽤 위험합니다.
흐름을 그림으로 하면 이렇습니다.
sequenceDiagram
participant UI as UI 스레드
participant IO as 비동기 I/O
participant Ctx as UI SynchronizationContext
UI->>UI: LoadButton_Click 시작
UI->>IO: LoadTextAsync() 호출
IO-->>UI: 미완료의 Task를 반환
UI->>UI: .Result로 대기해 블로킹
IO-->>Ctx: I/O 완료, 연속을 UI로 되돌리고 싶음
Ctx-->>UI: 연속을 실행하고 싶음
Note over UI: 하지만 UI는 .Result로 막혀 있음
Note over UI, Ctx: 연속이 돌 수 없으므로 완료될 수 없음
무엇이 일어나고 있는지를 말로 하면 이렇습니다.
- UI 스레드가
LoadTextAsync()를 부른다 LoadTextAsync()의 안의await는 UI 컨텍스트를 잡는다- UI 스레드는
.Result로 기다려버린다 - I/O가 끝난다
LoadTextAsync()의 연속은 UI 스레드로 돌아가고 싶어한다- 하지만 UI 스레드는
.Result로 막혀 있다 - 연속이 달릴 수 없으므로
LoadTextAsync()가 완료되지 않는다 .Result는 끝나지 않는다
즉, UI가 「너가 끝날 때까지 기다린다」라고 말하고, 비동기 측이 「UI로 돌아갈 수 있다면 끝낼 수 있다」라고 말해서 서로 기다린다는 것입니다. 실로 싫은 느낌입니다.
여기서 자주 있는 잘못된 생각은 GetAwaiter().GetResult()로 하면 안전하다고 생각하는 것입니다.
하지만 UI 스레드를 막는 본질은 같습니다. 다른 것은 주로 예외의 감싸지는 법입니다.
그래서 UI에서는 다음 3가지를 같은 냄새로서 다루는 편이 안전합니다.
.Result.Wait().GetAwaiter().GetResult()
덧붙여, WPF의 Dispatcher.InvokeAsync(...)가 반환하는 Task를 Task.Wait()하는 것도 위험합니다.
WPF의 문서에서도 DispatcherOperation이 반환하는 Task를 Task.Wait하면 데드락이 된다고 되어 있습니다.
요컨대 UI의 문맥에서 「던진 것을 동기로 기다린다」 방향 자체가 꽤 막히기 쉽습니다.
「반드시 데드락하는가」라고 하면 반드시 그렇지는 않습니다. 우연히 연속이 UI로 돌아가지 않는 코드라면 데드락하지 않고 단지 UI를 프리즈시킬 뿐인 경우도 있습니다. 하지만 그것도 충분히 힘들기 때문에 UI에서는 기본적으로 하지 않는 편이 좋습니다.
5. Dispatcher / Invoke를 언제 사용할까
정리하면, plain await의 UI 핸들러에서는 평소는 명시적인 Dispatcher / Invoke는 필요 없습니다.
필요해지는 것은 예를 들어 다음과 같은 때입니다.
ConfigureAwait(false)의 연속에서 UI를 건드리고 싶다Task.Run의 안이나, 그 밖에서도 UI로 돌아가지 않는 구성으로 하고 있다- 소켓 수신, 타이머, 이벤트 콜백 등 처음부터 UI 스레드가 아닌 장소에서 알림이 온다
- UI와 비 UI를 의도적으로 분리한 레이어에서 마지막의 UI 업데이트만 명시하고 싶다
WPF라면 대표는 Dispatcher.InvokeAsync입니다.
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
await Dispatcher.InvokeAsync(() =>
{
PreviewTextBox.Text = text;
StatusText.Text = "완료";
});
}
WinForms라면 .NET 9 이후는 InvokeAsync가 꽤 상성이 좋습니다.
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
await previewTextBox.InvokeAsync(() =>
{
previewTextBox.Text = text;
statusLabel.Text = "완료";
});
}
WinForms의 기존 패턴에서는 BeginInvoke를 사용합니다.
Invoke는 동기 송신이고 호출 측을 기다리게 합니다. BeginInvoke는 투고하고 즉시 돌아옵니다.
async 플로우에서는 기본적으로 블로킹하지 않는 쪽이 맞물림이 좋습니다.
대략 말하면 다음 구별법으로 충분합니다.
| 하고 싶은 것 | WPF | WinForms |
|---|---|---|
| UI로 동기적으로 넣다 | Dispatcher.Invoke |
Control.Invoke |
| UI로 비동기로 던지다 | Dispatcher.InvokeAsync / Dispatcher.BeginInvoke |
Control.BeginInvoke / .NET 9+ Control.InvokeAsync |
| async / await와 자연스럽게 맞추고 싶다 | Dispatcher.InvokeAsync |
.NET 9+ Control.InvokeAsync, 그 이전은 BeginInvoke |
실무에서의 감각으로는,
- UI 핸들러에서 plain
await만 하고 있다면 불필요 - UI 이외의 장소에서 UI를 건드리고 싶어지면 사용
- async 플로우 속에서 동기
Invoke를 너무 늘리지 않는다
이것으로 꽤 사고가 줄어듭니다.
망설였을 때는 다음 판단 그림으로 충분합니다.
flowchart TD
A["이 연속을 쓰는 장소는 UI 스레드?"] --> B{"예?"}
B -- 예 --> C["plain await 그대로 UI 업데이트해도 좋다"]
B -- 아니오 --> D{"UI를 건드리고 싶은가?"}
D -- 아니오 --> E["그대로 처리 계속"]
D -- 예 --> F["WPF: Dispatcher.InvokeAsync"]
D -- 예 --> G["WinForms: BeginInvoke / InvokeAsync"]
6. 자주 있는 안티패턴
| 안티패턴 | 무엇이 힘든가 | 먼저의 치환 |
|---|---|---|
UI 핸들러에서 LoadAsync().Result |
UI 스레드를 막는다. 데드락하기 쉽다 | await LoadAsync() |
UI 핸들러에서 LoadAsync().Wait() |
동상. 메시지 루프가 멈춘다 | await LoadAsync() |
UI 핸들러에서 LoadAsync().GetAwaiter().GetResult() |
예외의 보이는 법이 다를 뿐, 블록은 같다 | await LoadAsync() |
UI 코드에 기계적으로 ConfigureAwait(false) |
await 후의 UI 업데이트가 망가지기 쉽다 |
UI의 가장 바깥쪽은 plain await |
Task.Run(async () => await IoAsync()) |
I/O를 쓸데없이 다시 던지고 있다 | await IoAsync() |
라이브러리 코드가 Dispatcher나 Control을 직접 잡는다 |
UI 의존이 깊어진다. 재이용하기 어렵다 | 라이브러리는 데이터만 반환하고 UI 측에서 marshal한다 |
Dispatcher.Invoke / Control.Invoke를 async 플로우에 다용한다 |
블록의 고리가 생기기 쉽다 | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync를 검토 |
| 컨스트럭터나 프로퍼티 getter에서 async를 동기화한다 | 기동시 행의 온상이 된다 | Loaded / Shown / InitializeAsync로 도망시킨다 |
이 중에서 특히 조우율이 높은 것은 다음 3가지입니다.
- UI 스레드에서
.Result/.Wait() - UI 코드에
ConfigureAwait(false)를 기계적으로 붙인다 - 라이브러리와 UI의 책무가 섞여
Dispatcher가 깊은 곳까지 침입한다
이 3가지를 빼는 것만으로도 꽤 진정됩니다.
7. 리뷰 시의 체크리스트
WPF / WinForms의 async / await를 리뷰할 때는 다음을 순서대로 보면 알기 쉽습니다.
- UI 이벤트 핸들러나 UI 초기화 경로에
.Result/.Wait()/.GetAwaiter().GetResult()가 남아 있지 않은가 Task.Run은 CPU 계산에만 사용되고 있는가. I/O를 감싸고 있지 않은가ConfigureAwait(false)가 UI 코드에 기계적으로 들어 있지 않은가- 반대로, 범용 라이브러리에서 UI 컨텍스트로의 의존을 끌어들이고 있지 않은가
await후에 UI를 직접 건드리고 있는 곳은 거기가 정말로 UI 컨텍스트 상이라고 말할 수 있는가- UI로 명시적으로 되돌릴 필요가 있는 곳에서
Dispatcher.InvokeAsync/BeginInvoke/InvokeAsync가 사용되고 있는가 Dispatcher.Invoke/Control.Invoke와 같은 동기 marshal이 불필요하게 늘어나고 있지 않은가- 컨스트럭터, 동기 프로퍼티, 동기 이벤트에서 async를 무리하게 동기화하고 있지 않은가
- 라이브러리 층이
Window/Control/Dispatcher를 직접 참조하고 있지 않은가
이 체크리스트는 팀에서 「어디가 UI의 책무인가」를 맞추는 데도 사용하기 쉽습니다.
8. 대강의 사용 구분
| 하고 싶은 것 | 먼저 고르는 것 |
|---|---|
| UI 핸들러에서 HTTP / DB / 파일 I/O를 기다린다 | plain await |
| UI를 멈추고 싶지 않은 무거운 CPU 계산 | Task.Run을 await |
ConfigureAwait(false)의 후나 배경 스레드에서 UI를 업데이트한다 |
WPF: Dispatcher.InvokeAsync / WinForms: BeginInvoke or .NET 9+ InvokeAsync |
| 범용 라이브러리를 쓴다 | ConfigureAwait(false)를 검토 |
| UI에서 async를 동기화하고 싶다 | 기본 하지 않는다. 호출원째로 async로 늘린다 |
| 기동시 초기화를 하고 싶다 | Loaded / Shown / 명시적인 InitializeAsync |
await 후에 그대로 UI를 건드리고 싶다 |
UI의 가장 바깥쪽은 plain await를 유지한다 |
9. 정리
WPF / WinForms의 async / await에서 정말 중요한 것은
「비동기는 어렵다」라는 분위기가 아니라,
- 지금 어디서 시작되었는가
await의 연속이 어디로 돌아가는가- UI로 되돌리는 책임을 누가 지는가
를 나누어 생각하는 것입니다.
먼저의 룰로서는 다음으로 꽤 싸울 수 있습니다.
- UI의 가장 바깥쪽에서는 plain
await - 무거운 CPU만
Task.Run - 범용 라이브러리에서는
ConfigureAwait(false)를 검토 - UI로 되돌릴 필요가 있을 때만
Dispatcher/BeginInvoke/InvokeAsync - UI 스레드에서는
.Result/.Wait()/.GetAwaiter().GetResult()를 사용하지 않는다
async / await 자체는 그렇게 까다로운 구조가 아닙니다.
다만, UI 스레드를 중심으로 보지 않은 채 사용하면 갑자기 진흙탕이 됩니다.
반대로 말하면,
- UI의 바깥쪽과 안쪽을 나눈다
- 돌아갈 곳을 의식한다
- 블록을 가지고 들어오지 않는다
이 3가지를 지키는 것만으로도 WPF / WinForms의 비동기 코드는 꽤 조용해집니다. 화면이 굳는 코드는 대개 「비동기가 나쁘다」가 아니라 UI 스레드로의 빚지는 법이 거친 것뿐입니다.
10. 참고 자료
- 관련 기사: C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표
- Threading Model - WPF
- DispatcherSynchronizationContext Class
- How to handle cross-thread operations with controls - Windows Forms
- WindowsFormsSynchronizationContext Class
- Events Overview - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Method
- ConfigureAwait FAQ
- How Async/Await Really Works in C#
- Await, and UI, and deadlocks! Oh my!
- Threading model for WebView2 apps
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Generic Host / BackgroundService를 데스크톱 앱에 가지고 들어오는 이유 - 기동・수명・graceful shutdown의 정리가 꽤 편해진다
WPF나 WinForms 같은 데스크톱 앱에서 Generic Host와 BackgroundService를 도입해 기동, 상주 처리, graceful shutdown, DI, 로그, 설정의 입구를 한 곳으로 모으는 설계 정리법과 안티패턴을 실무 시...
Windows Forms, WPF, WinUI 중 어느 것으로 할까 - 신규 개발, 기존 자산, 배포, UI 표현의 판단표
Windows 데스크톱 앱을 C#/.NET으로 새로 만들 때 WinForms·WPF·WinUI 중 무엇을 고를지, 신규 개발과 기존 자산, 배포 방식, UI 표현력, 팀 문화의 다섯 축으로 비교한 한 장짜리 판단표를 제시하여 독자가 자기 프로젝트...
PeriodicTimer / System.Threading.Timer / DispatcherTimer의 사용 구분 - .NET의 정기 실행을 먼저 정리
PeriodicTimer는 async 루프, System.Threading.Timer는 ThreadPool callback, DispatcherTimer는 WPF UI 스레드라는 책무 차이를 정리하고, .NET 6 이후의 worker・WPF에서 ...
C# async/await의 베스트 프랙티스 - Task.Run과 ConfigureAwait의 판단표
C# async/await의 베스트 프랙티스를 I/O 대기와 CPU 계산의 구분, Task.Run·ConfigureAwait(false)·fire-and-forget·WhenAll·SemaphoreSlim·Channel의 형 선택 판단표로 정리합니다.
Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스
Windows 앱이 자식 프로세스에 의존할 때, 기동 API보다 프로세스 트리의 소유권과 종료 절차의 설계가 안정성을 좌우합니다. Job Object로 수명을 묶고, 종료 전파를 분리하며, stdout/stderr를 비동기로 흘리고 watchdo...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크