수정 이력(11건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635113)
- 추가한 그림의 Mermaid 소스 들여쓰기를 글 안의 규약에 맞추고, 리뷰에서 지적된 그림 표현을 본문 기술에 맞춰 조정했습니다. 본문 문장은 바꾸지 않았습니다.
- 본문의 흐름·분기를 그림으로도 따라갈 수 있도록 Mermaid 그림을 12점 추가했습니다(본문 500〜750자당 1그림 규약에 맞춘 것입니다). 기존 8그림에는 캡션을 추가했습니다. 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조해 주세요.
- `InvokeOnUiAsync`에서 취소와 실행 판정이 경쟁하던 문제를 고쳤습니다. `BeginInvoke`한 델리게이트 안에서 「취소되었는가」를 확인한 뒤에 `action()`을 호출하는 형태는 원자적이지 않고, 확인 직후에 취소가 돌면 `tcs`는 취소된 상태가 되어 호출 측은 다음 조작을 시작하는데, 큐에 남은 오래된 델리게이트가 나중에 화면을 다시 씁니다. 새 표시가 오래된 표시로 덮어쓰이는, 재현하기 어려운 깨짐입니다. `Interlocked.Exchange`로 「한 번뿐인 권리」를 취소 측과 실행 측이 빼앗게 하고, 가져가지 못한 쪽은 아무것도 하지 않고 돌아가게 했습니다. `BeginInvoke` 자체가 예외를 던진 경로도 같은 권리를 가져온 뒤에 마무리합니다.
- `BeginInvoke`에 큐에 넣은 델리게이트가 취소 후에도 무조건 `action()`을 실행하고 있었습니다. 호출 측은 이미 취소를 받았는데 나중에 화면만 바뀌어, 새 조작 결과를 오래된 결과로 덮어씁니다. 실행 전에 토큰과 완료 상태를 확인하도록 했습니다.
- `InvokeOnUiAsync`가 `CancellationToken`을 필수로 받도록 했습니다. `BeginInvoke`가 받은 뒤에 컨트롤이 파괴되면, 큐에 넣은 델리게이트는 실행되지 않은 채 버려지고, `TaskCompletionSource`에는 결과도 예외도 들어가지 않아 `await`하는 쪽이 영원히 기다립니다. 글 후반에서 이 위험은 언급했지만, 샘플에는 끊을 수단이 없어 그대로 복사하면 hang이 되는 형태였습니다. `BeginInvoke` 자체가 던지는 경우도 마무리하도록 했고, 호출 측에는 폼 종료에 묶은 토큰을 넘기는 예를 추가했습니다.
- 중복되어 있던 판단표를 한곳으로 모으고, 나머지는 기억법과 안내로 압축했습니다. .NET 9 미만을 위해 `Control.BeginInvoke`를 기다릴 수 있게 하는 확장 메서드를 추가하고, continuation 위치가 어떻게 정해지는지 설명을 엄밀히 했습니다. 더불어 출처를 확인할 수 없었던 「WPF 문서에서도 그렇게 되어 있다」는 귀속을 빼고, 메커니즘 설명과 실제로 있는 문서 링크로 바꿨습니다.
- 본문 중 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635112)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「WPF/WinForms의 async와 UI 스레드를 한 장으로 정리」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635112 https://comcomponent.com/ko/blog/wpf-winforms-ui-thread-async-await-one-sheet/
- DOI(최신 버전)
- 10.5281/zenodo.21635112
- DOI(이 버전)
- 10.5281/zenodo.22217422
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를 사용합니다.
또한 이 글에 등장하는 코드는 빌드·실행할 수 있는 샘플 세트(UI 비의존 라이브러리, WPF / WinForms 샘플, await의 복귀 위치와 데드락을 재현하는 유닛 테스트)로 GitHub에 공개되어 있습니다.
wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)
목차
- 먼저 결론 (한 마디로)
- 먼저 한 장으로 정리
- 2.1. 전체 그림
- 2.2. 먼저 볼 판단표
- 이 글에서 쓰는 말
- 3.1. UI 스레드와 메시지 루프
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- 전형 패턴
- 4.1. UI 이벤트 핸들러에서 plain
await - 4.2. 무거운 CPU 계산만
Task.Run - 4.3.
ConfigureAwait(false)는 「돌아가지 않는다는 보증」이 아니라 「복귀를 강제하지 않는다」 - 4.4.
.Result/.Wait()/.GetAwaiter().GetResult()로 막히는 이유
- 4.1. UI 이벤트 핸들러에서 plain
Dispatcher/Invoke를 언제 쓰는가- 흔한 안티패턴
- 리뷰 시 체크리스트
- 대략적인 구분
- 정리
- 참고 자료
이 글의 지식 맵
이 글은 WPF/WinForms의 UI 스레드가 메시지 루프를 계속 돌린다는 것을 토대로, plain await는 그 시점의 SynchronizationContext를 붙잡아 연속(continuation)을 UI 스레드로 되돌리므로 그대로 UI를 업데이트할 수 있는 반면, ConfigureAwait(false)는 그 복귀를 강제하지 않으므로 범용 라이브러리 코드에 어울린다고 정리합니다. Task.Run은 CPU 계산만을 UI 스레드에서 빼내는 도구이며, UI가 아닌 곳에서 UI로 되돌리려면 WPF의 Dispatcher, WinForms의 Control.BeginInvoke나 .NET 9 이후의 Control.InvokeAsync를 써야 한다고 말합니다. 반대로 .Result·.Wait()·.GetAwaiter().GetResult()로 UI 스레드를 동기적으로 막으면 연속이 UI로 돌아가지 못해 데드락이나 프리즈를 부를 수 있고, async void 이벤트 핸들러에서 예외를 잡지 않으면 WPF의 DispatcherUnhandledException이나 WinForms의 ThreadException까지 빠져나가 앱이 떨어진다고 설명합니다.
flowchart LR
accTitle: WPF/WinForms의 UI 스레드와 async/await
accDescr: UI 스레드가 메시지 루프를 계속 돌리는 것, plain await가 포착한 SynchronizationContext로 이어지는 처리(continuation)를 되돌리는 것, ConfigureAwait(false)가 그 복귀를 강제하지 않는 것, Task.Run이 CPU 계산을 UI 스레드에서 빼내는 것, Dispatcher·Control.BeginInvoke·InvokeAsync가 UI로 명시적으로 되돌리는 수단인 것, .Result·.Wait()·GetAwaiter().GetResult()가 UI 스레드를 막아 데드락이나 프리즈를 부를 수 있는 것, async void의 예외가 UI의 전역 예외 처리까지 이르는 것의 관계를 보여주는 그림
ui_thread_context["UI 스레드 컨텍스트"]
wpf["WPF"]
windows_forms["Windows Forms"]
message_loop["메시지 루프"]
synchronizationcontext["SynchronizationContext"]
wpf_dispatcher["Dispatcher(WPF)"]
winforms_begininvoke["Control.Invoke / Control.BeginInvoke"]
winforms_invokeasync["Control.InvokeAsync"]
taskcompletionsource["TaskCompletionSource"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
reentrancy_guard["재진입 방지 가드(Interlocked.Exchange 등)"]
cancel_execute_race["취소와 실행이 경합하는 레이스"]
plain_await["plain await"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["범용 라이브러리 코드"]
taskrun_dotnet["Task.Run"]
io_bound_operation["I/O-bound 처리"]
ui_thread_blocking["UI 스레드 막힘"]
sync_over_async["동기 대기 혼입(sync-over-async)"]
deadlock["교착 상태(deadlock)"]
task_result_wait[".Result / .Wait() / .GetAwaiter().GetResult()"]
async_void["async void"]
event_handler_method["이벤트 핸들러 메서드"]
ui_unhandled_exception_handler["UI 전역 예외 처리기(DispatcherUnhandledException / ThreadException)"]
ui_thread_context -->|"전제로 한다"| message_loop
wpf -->|"이용한다"| synchronizationcontext
windows_forms -->|"이용한다"| synchronizationcontext
wpf -->|"이용한다"| wpf_dispatcher
windows_forms -->|"이용한다"| winforms_begininvoke
windows_forms -.->|"이용한다"| winforms_invokeasync
winforms_invokeasync -->|"의 후속"| winforms_begininvoke
winforms_begininvoke -.->|"전제로 한다"| taskcompletionsource
taskcompletionsource -.->|"전제로 한다"| cancellationtoken_dotnet
taskcompletionsource -.->|"이용한다"| reentrancy_guard
reentrancy_guard -->|"방지한다"| cancel_execute_race
plain_await -->|"이용한다"| synchronizationcontext
plain_await -->|"권장되는 대응"| ui_thread_context
configureawait_false -.->|"전제로 한다"| synchronizationcontext
configureawait_false -->|"사용은 비권장"| ui_thread_context
configureawait_false -->|"권장되는 대응"| generic_library_code
taskrun_dotnet -->|"권장되는 대응"| ui_thread_context
taskrun_dotnet -->|"사용은 비권장"| io_bound_operation
taskrun_dotnet -->|"방지한다"| ui_thread_blocking
sync_over_async -->|"원인이 될 수 있다"| deadlock
task_result_wait -.->|"원인이 될 수 있다"| deadlock
task_result_wait -->|"사용은 비권장"| ui_thread_context
wpf_dispatcher -->|"원인이 될 수 있다"| deadlock
async_void -->|"권장되는 대응"| event_handler_method
async_void -->|"원인이 될 수 있다"| ui_unhandled_exception_handler
event_handler_method -.->|"전제로 한다"| ui_thread_context
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
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 스레드를 막습니다.awaitcontinuation이 UI로 돌아가야 한다면, 꽤 흔하게 막힙니다- WPF에서 명시적으로 UI로 되돌린다면
Dispatcher.InvokeAsync - WinForms에서 명시적으로 UI로 되돌린다면, 기존에는
BeginInvoke, .NET 9 이후라면InvokeAsync가 async 플로우와 잘 맞습니다 - 우선 방침은 UI의 가장 바깥쪽은 plain
await, 범용 라이브러리는ConfigureAwait(false)를 검토, UI로 되돌리는 것은 필요한 곳에서만 명시입니다
요컨대 WPF / WinForms에서는
- 지금 어느 스레드에서 돌고 있는가
await의 이어지는 처리가 어디로 돌아가는가- UI로 되돌리는 책임을 어디가 질 것인가
이 세 가지를 잡으면 흐름이 한결 잘 보입니다.
flowchart TB
accTitle: 흐름을 밝히는 세 가지 질문
accDescr: 지금 어느 스레드에서 돌고 있는가, await의 이어지는 처리가 어디로 돌아가는가, UI로 되돌리는 책임을 어디가 질 것인가의 세 가지를 잡으면 WPF와 WinForms의 비동기 코드 흐름이 잘 보인다.
q1["지금 어느 스레드에서 돌고 있는가"] --> goal["비동기 UI 코드의 흐름"]
q2["await의 이어지는 처리는 어디로 돌아가는가"] --> goal
q3["UI로 되돌리는 책임은 누가 지는가"] --> goal
그림 1: 막히면 돌아가는 세 가지 질문. 스레드·복귀 위치·되돌리는 책임을 나눠 생각합니다.
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["continuation이 UI로 돌아갈 수 없다"]
O --> P["행 / 데드락 / 적어도 프리즈"]
그림 2: UI 핸들러에서 본 4패턴의 전체 그림. plain await와 Task.Run은 UI로 돌아가고, ConfigureAwait(false)는 복귀를 강제하지 않으며, .Result / .Wait()는 UI 스레드를 막습니다.
실무에서 보이는 것은 대개 이 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 스레드가 대기로 막힌다 | 애초에 continuation이 돌기 어렵다 | 안 된다 | 쓰지 않는다 |
백그라운드 스레드나 ConfigureAwait(false) 뒤에서 UI를 업데이트하고 싶다 |
UI와 다른 스레드에서 움직이고 있다 | 그대로는 UI가 아니다 | 안 된다 | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
| UI에 의존하지 않는 범용 라이브러리를 작성한다 | 호출 측 사정에 의존하지 않는다 | UI로 되돌리는 것을 강제하지 않는다 | 건드리지 않는 설계로 한다 | ConfigureAwait(false)를 검토 |
| 생성자나 동기 프로퍼티에서 async를 호출하고 싶다 | UI 스레드가 대기에 들어가기 쉽다 | 시작 경로가 막히기 쉽다 | 안 된다 | Loaded / Shown / InitializeAsync로 빼낸다 |
이 표에서 중요한 것은 plain await는 UI 코드에서는 오히려 아군이라는 점입니다.
적인 것은 await 자체가 아니라, UI 스레드를 동기적으로 막는 것입니다.
선택 자체는 이 표에 모아 두었습니다. 이후 장은 이 표가 왜 이렇게 되는지(3장과 4장), UI로 되돌리는 도구를 고르는 방법(5장), 리뷰에서 찾는 방법(6장과 7장)으로 나눕니다.
flowchart TB
accTitle: 적은 await가 아니라 동기 블록
accDescr: plain await는 UI 코드에서는 오히려 아군이고, 진짜 적은 UI 스레드를 동기적으로 막는 일이라는 정리를 나타낸다.
pa["plain await"] --> friend["UI 코드에서는 오히려 아군"]
blk["UI 스레드를 동기적으로 막는다"] --> enemy["프리즈나 데드락의 원인"]
그림 3: 판단표의 핵심 정리. 적은 await 자체가 아니라, UI 스레드를 동기적으로 막는 일입니다.
3. 이 글에서 쓰는 말
3.1. UI 스레드와 메시지 루프
WPF / WinForms의 UI는 기본적으로 UI 스레드가 하나 있고, 그 스레드가 입력·그리기·이벤트 처리를 돌린다는 형태입니다.
이 UI 스레드의 역할은 대개 이렇습니다.
- 버튼 클릭, 키 입력, 다시 그리기 같은 메시지를 처리한다
- 컨트롤이나 UI 객체를 안전하게 건드릴 수 있는 유일한 스레드가 된다
- 여기에 처리를 너무 많이 넣으면 화면 업데이트나 입력 응답이 멈춘다
여기서의 핵심은 UI 스레드의 일은 「빠르게 도는 것」이라는 점입니다. 여기를 오래 블록하면 마우스도 키보드도 다시 그리기도 막혀, 사용자에게는 「멈췄다」로 보입니다.
이 이미지는 그림으로 머리에 두면 헷갈리기 어려워집니다.
flowchart LR
A["사용자 입력 / 다시 그리기 요청"] --> B["UI 스레드의 메시지 루프"]
B --> C["이벤트 핸들러 실행"]
C --> D["화면 업데이트"]
D --> B
C --> E["긴 동기 처리"]
E --> F["메시지 루프가 돌지 않는다"]
F --> G["화면이 멈춘 것처럼 보인다"]
그림 4: UI 스레드의 일은 메시지 루프를 빠르게 돌리는 것이고, 긴 동기 처리가 끼면 루프가 멈춰 화면이 멈춘 것처럼 보입니다.
3.2. SynchronizationContext / Dispatcher / Invoke
여기서 자주 나오는 말을 실무 기준으로 나누면 이렇습니다.
| 말 | 여기서의 의미 |
|---|---|
| UI 스레드 | UI 객체를 만든 스레드. 기본적으로 여기만 UI를 안전하게 건드릴 수 있다 |
| 메시지 루프 | UI 스레드가 메시지를 차례로 처리하는 메커니즘 |
SynchronizationContext |
「그 실행 장소로 처리를 되돌린다」는 추상화 |
Dispatcher |
WPF의 UI 스레드용 큐 |
Invoke / BeginInvoke / InvokeAsync |
UI 스레드로 처리를 던지기 위한 API |
continuation이 어디로 가는지 조금 더 정확히 쓰면 이렇습니다. await(기본의 ConfigureAwait(true)에 해당)가 잡는 것은 먼저 SynchronizationContext.Current입니다. 그것이 null일 때만 TaskScheduler.Current를 보고, 그것이 TaskScheduler.Default 가 아니면 그 TaskScheduler로 continuation을 되돌립니다. 어느 쪽도 아니면, 즉 SynchronizationContext.Current가 null이고 TaskScheduler.Current가 기본이면, continuation은 ThreadPool 위에서 돕니다.
WPF / WinForms의 UI 스레드에서는 전자, 즉 UI의 SynchronizationContext가 있으므로, 실무에서는 UI의 SynchronizationContext가 적용되고 있다고 봐도 됩니다.
flowchart TB
accTitle: await continuation 위치의 결정 방식
accDescr: 기본 await는 먼저 SynchronizationContext.Current를 잡고, null이면 TaskScheduler.Current를 보고, 기본이 아니면 그 TaskScheduler로, 어느 쪽도 아니면 ThreadPool에서 continuation을 실행한다.
a["기본 await"] --> sc{"SynchronizationContext가 있는가?"}
sc -->|"있다"| toSc["그 컨텍스트로 되돌린다"]
sc -->|"null"| ts{"TaskScheduler는 기본인가?"}
ts -->|"기본이 아니다"| toTs["그 TaskScheduler로 되돌린다"]
ts -->|"기본"| pool["ThreadPool에서 continuation을 실행"]
toSc -.-> ui["UI 스레드에서는 UI의 컨텍스트"]
그림 5: continuation 위치의 결정 방식. UI 스레드에서는 UI의 SynchronizationContext가 있으므로, 이어지는 처리는 UI로 돌아갑니다.
프레임워크별 대응은 표로 보는 편이 알기 쉽습니다.
| 프레임워크 | 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+)"]
그림 6: SynchronizationContext라는 추상의 실체는, WPF에서는 Dispatcher 계열, WinForms에서는 Control의 Invoke 계열 API로 이어집니다.
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를 건드릴 수 있습니다.
이 핸들러가 async void인 것은 UI 이벤트 핸들러 시그니처가 void를 요구하기 때문이며, 여기는 예외적으로 허용되는 async void입니다. 그만큼 try / catch를 안에 두는 이유가 분명합니다. async Task라면 예외는 반환값 Task에 실리고, 호출 측이 await한 시점에 받을 수 있습니다. async void에는 그 Task가 없으므로, 밖으로 빠져나간 예외는 그 핸들러가 시작했을 때의 SynchronizationContext, 즉 UI 스레드로 다시 던져집니다. UI 스레드의 미처리 예외는 WPF라면 Application.DispatcherUnhandledException, WinForms라면 Application.ThreadException에 나온 뒤, 거기서 처리하지 않으면 앱이 죽습니다.
즉 async void 핸들러에서는 핸들러 안에서 잡는 것이 기본이며, 위 예처럼 「실패를 상태 표시로 바꾸고, finally에서 버튼을 되돌린다」는 형태로 두면 UI로서 자연스럽게 마무리됩니다. 앱 전체의 최종 처리기(DispatcherUnhandledException 등)는 어디까지나 마지막 그물로 두는 것입니다.
flowchart TB
accTitle: async void 예외의 행선지
accDescr: async Task의 예외는 반환값 Task에 실려 호출 측이 await로 받을 수 있지만, async void에서는 밖으로 빠져나간 예외가 시작 시의 SynchronizationContext 즉 UI 스레드로 다시 던져지고, 전역 예외 핸들러에서 처리하지 않으면 앱이 죽는다.
ex["핸들러 밖으로 빠져나간 예외"] --> kind{"async Task인가 async void인가"}
kind -->|"async Task"| task["반환값 Task에 실린다"]
task --> caller["await한 호출 측이 받는다"]
kind -->|"async void"| ctx["UI 스레드로 다시 던져진다"]
ctx --> global["전역 예외 핸들러에 나온다"]
global --> crash["처리하지 않으면 앱이 죽는다"]
그림 7: async void에는 예외를 실을 Task가 없으므로, 핸들러 안에서 잡는 것이 기본이 됩니다.
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을 업데이트
그림 8: plain await의 대기 중에는 UI 스레드가 메시지 루프로 돌아가고, I/O 완료 후의 이어지는 처리는 UI 스레드에서 재개됩니다.
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가 아닌 장소」로 가는 것은 아닙니다.
여기를 한 장으로 보면 오해하기 어렵습니다.
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: 화면에 결과를 반영
그림 9: Task.Run 안만 ThreadPool에서 움직이고, await의 이어지는 처리는 UI 스레드로 돌아가므로 화면 반영은 그대로 쓸 수 있습니다.
여기서의 주의는 두 가지입니다.
- I/O 대기를
Task.Run으로 감싸지 않는다 Task.Run은 「비동기화」가 아니라 「CPU를 빼내는 곳」을 만드는 것이라고 생각한다
Task.Run(async () => await File.ReadAllTextAsync(...)) 같은 작성은 I/O 대기를 쓸데없이 ThreadPool로 다시 던지는 것에 불과해, 이득이 거의 없습니다.
flowchart TB
accTitle: Task.Run을 쓸 곳
accDescr: 무거운 CPU 계산을 ThreadPool로 빼내는 것이 Task.Run의 역할이며, I/O 대기를 감싸는 것은 대기를 쓸데없이 ThreadPool로 다시 던지는 것에 불과해 이득이 없다.
q{"빼고 싶은 것은 무엇인가"}
q -->|"무거운 CPU 계산"| ok["Task.Run으로 빼낸다"]
q -->|"I/O 대기"| ng["Task.Run으로 감싸지 않는다"]
ng --> why["대기를 다시 던질 뿐 이득이 없다"]
그림 10: Task.Run은 「비동기화」 도구가 아니라, CPU를 빼내는 곳을 만드는 도구입니다.
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로 돌아간다
는 분리가 됩니다.
flowchart TB
accTitle: 라이브러리와 UI의 분리
accDescr: 범용 라이브러리 내부는 ConfigureAwait(false)로 UI 컨텍스트로의 복귀를 요구하지 않고, 그것을 UI 핸들러가 plain await하면 호출 측의 이어지는 처리는 UI로 돌아간다는 분리를 나타낸다.
lib["라이브러리 내부의 await"] --> nof["ConfigureAwait〔false〕"]
nof --> stay["UI로의 복귀를 요구하지 않는다"]
uih["UI 핸들러의 plain await"] --> back["호출 측의 이어지는 처리는 UI로 돌아간다"]
nof -.-> note["안쪽 지정은 바깥으로 미치지 않는다"]
그림 11: 라이브러리 안의 ConfigureAwait(false)는 호출 측 await까지 강제로 false로 만들지 않습니다.
반대로 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가 기다리지 않고 바로 완료된 경우 이어지는 처리는 그대로 지금 스레드에서 흐를 수 있습니다.
「반드시 다른 스레드로 간다」「여기서부터는 줄곧 UI가 아니다」로 읽으면 사고의 씨앗이 되며, 의미는 어디까지나 그 await continuation을 원래 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가 필요"]
그림 12: ConfigureAwait(false)의 유무로 바뀌는 것은 「이어지는 처리를 UI로 되돌리는 것을 강제하는가」뿐입니다.
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 완료, continuation을 UI로 되돌리고 싶다
Ctx-->>UI: 이어지는 처리를 실행하고 싶다
Note over UI: 그러나 UI는 .Result로 막혀 있다
Note over UI, Ctx: continuation이 돌지 않으므로 완료할 수 없다
그림 13: .Result로 막힌 UI 스레드로 continuation이 돌아가지 못해, Task가 끝없이 완료되지 않는 흐름입니다.
무엇이 일어나는지를 말로 하면 이렇습니다.
- UI 스레드가
LoadTextAsync()를 호출한다 LoadTextAsync()안의await는 UI 컨텍스트를 잡는다- UI 스레드는
.Result로 기다려 버린다 - I/O가 끝난다
LoadTextAsync()의 이어지는 처리는 UI 스레드로 돌아가고 싶다- 그러나 UI 스레드는
.Result로 막혀 있다 - 이어지는 처리가 돌지 못해
LoadTextAsync()가 완료되지 않는다 .Result는 끝나지 않는다
즉 UI는 「네가 끝날 때까지 기다린다」고 하고, 비동기 측은 「UI로 돌아갈 수 있으면 끝낼 수 있다」고 하여, 서로 기다립니다. 정말 기분 나쁜 느낌입니다.
flowchart TB
accTitle: 서로 기다리는 구도
accDescr: UI 스레드는 비동기 처리의 완료를 .Result로 기다리고, 비동기 측 continuation은 UI 스레드의 빈자리를 기다리므로, 서로 상대를 기다려 진행되지 않는다.
ui["UI 스레드가 .Result로 기다린다"] --> need["비동기 측의 완료가 필요하다"]
cont["continuation은 UI로 돌아가야 한다"] --> free["UI 스레드의 빈자리가 필요하다"]
need --> cycle["서로 기다려 진행되지 않는다"]
free --> cycle
그림 14: 「끝날 때까지 기다린다」와 「UI로 돌아갈 수 있으면 끝낼 수 있다」가 부딪혀, 서로 기다립니다.
여기서 흔한 착각은 GetAwaiter().GetResult()로 바꾸면 안전하다고 생각하는 것입니다.
그러나 UI 스레드를 막는다는 본질은 같습니다. 다른 것은 주로 예외가 감싸지는 방식입니다.
그래서 UI에서는 이 세 가지를 같은 냄새로 다루는 편이 안전합니다.
.Result.Wait().GetAwaiter().GetResult()
덧붙여, WPF의 Dispatcher.InvokeAsync(...)가 반환하는 DispatcherOperation의 Task를 UI 스레드에서 Task.Wait()하는 것도 같은 이유로 위험합니다. InvokeAsync는 넘긴 델리게이트를 Dispatcher 큐에 쌓을 뿐이며, 실제로 도는 것은 UI 스레드가 그 큐를 돌렸을 때입니다. UI 스레드가 Wait()로 멈춰 있으면 큐는 돌지 않으므로, 그 Task는 영원히 완료되지 않습니다.
같은 이야기는 DispatcherOperation 측에도 있으며, DispatcherOperation.Wait()는 같은 스레드에서 실행 중인 조작을 기다리면 InvalidOperationException이 된다고 명시되어 있습니다. 블록해서 기다리는 경로 자체가 전제되어 있지 않다는 뜻입니다.
UI 맥락에서는 「던진 것을 동기로 기다린다」는 방향 자체가 막히기 쉽습니다. 막히는 방식의 자세한 설명은 Await, and UI, and deadlocks! Oh my!가 읽기 쉽습니다.
flowchart TB
accTitle: InvokeAsync를 동기로 기다리는 위험
accDescr: Dispatcher.InvokeAsync는 델리게이트를 큐에 쌓을 뿐이며, 실행되는 것은 UI 스레드가 그 큐를 돌렸을 때이므로, UI 스레드가 Wait로 멈춰 있으면 큐가 돌지 않아 Task는 영원히 완료되지 않는다.
post["InvokeAsync로 큐에 쌓는다"] --> run["UI 스레드가 큐를 돌리면 실행"]
wait["UI 스레드가 Wait로 정지"] --> norun["큐가 돌지 않는다"]
norun --> never["Task는 영원히 완료되지 않는다"]
그림 15: 던진 것을 UI 스레드 자신이 동기로 기다리면, 실행의 장인 큐가 돌지 않아 영원히 끝나지 않습니다.
「반드시 데드락이 나는가」라고 하면, 반드시 그렇지는 않습니다. 어쩌다 continuation이 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가 async 플로우와 자연스럽게 맞물립니다.
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 플로우에서는 기본적으로 블록하지 않는 쪽이 맞물리기 좋습니다.
flowchart TB
accTitle: Invoke와 BeginInvoke의 차이
accDescr: Invoke는 동기 송신으로 호출 측을 기다리게 하고, BeginInvoke는 큐에 넣고 바로 돌아오므로, async 플로우에서는 블록하지 않는 쪽이 맞물리기 좋다.
inv["Invoke〔동기 송신〕"] --> waitc["호출 측을 기다리게 한다"]
bi["BeginInvoke〔큐에 넣기〕"] --> ret["바로 돌아온다"]
ret --> fit["async 플로우와 맞물린다"]
그림 16: 같은 「UI로 던지기」라도, 기다리게 하는 Invoke와 바로 돌아오는 BeginInvoke는 성질이 다릅니다.
다만 Control.BeginInvoke가 반환하는 것은 IAsyncResult이므로, 그대로는 await할 수 없습니다. Control.InvokeAsync가 없는 환경(.NET Framework 4.8, .NET 6 / 8 등)에서 async 플로우에 올리고 싶다면, TaskCompletionSource로 감싸 Task로 만드는 것이 자연스럽습니다.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
public static class ControlUiExtensions
{
// .NET Framework 4.8에서도 그대로 쓸 수 있도록, 제네릭 버전의
// TaskCompletionSource를 사용합니다. .NET 5 이후라면 비제네릭 버전으로도 작성할 수 있습니다.
//
// cancellationToken을 생략 가능하게 두지 않았습니다. BeginInvoke가 받은 뒤에
// 컨트롤이 폐기되면, 큐에 넣은 델리게이트는 실행되지 않은 채 버려지고,
// TaskCompletionSource에는 결과도 예외도 들어가지 않습니다. 취소할 수단을 마련하지 않으면,
// await하고 있는 쪽이 그대로 영원히 기다립니다
public static Task InvokeOnUiAsync(
this Control control, Action action, CancellationToken cancellationToken)
{
if (control is null)
{
throw new ArgumentNullException(nameof(control));
}
if (action is null)
{
throw new ArgumentNullException(nameof(action));
}
if (!control.IsHandleCreated)
{
throw new InvalidOperationException("윈도우 핸들이 아직 만들어지지 않았습니다.");
}
if (!control.InvokeRequired)
{
action();
return Task.CompletedTask;
}
// await한 쪽의 이어지는 처리가 UI 스레드에서 그대로 돌지 않도록,
// continuation을 비동기로 흘린다는 것을 명시해 둡니다.
var tcs = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
// 취소와 실행이 같은 「한 번뿐인 권리」를 빼앗는 형태로 만듭니다.
// Interlocked.Exchange로 먼저 1을 쓴 쪽만 앞으로 갑니다.
// 플래그를 본 뒤에 action()을 호출하는 작성은, 본 직후에 취소된
// 경우 「호출 측은 취소를 받고 다음 조작을 시작했는데,
// 오래된 델리게이트가 나중에 화면을 다시 쓴다」는 경로가 남습니다
int claimed = 0; // 0 = 미확정 / 1 = 어느 쪽이 가져갔다
// 취소되면 델리게이트가 실행되지 않아도 Task는 완료 처리됩니다.
// 등록은 Task 완료 시에 반드시 뗍니다(떼지 않으면 토큰이 살아있는 동안
// tcs를 계속 붙잡습니다). CancellationTokenRegistration.Dispose는
// 스레드 세이프이므로, 어느 스레드에서 호출해도 됩니다
CancellationTokenRegistration registration = cancellationToken.Register(() =>
{
if (Interlocked.Exchange(ref claimed, 1) == 0)
{
tcs.TrySetCanceled(cancellationToken);
}
});
tcs.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
control.BeginInvoke(new Action(() =>
{
// 큐에 넣은 뒤 UI 스레드가 움직이기까지의 사이에 취소되는 일이
// 있습니다. 여기서 권리를 가져가지 못하면, 취소 측이 먼저 가져간
// 것이므로, 화면에는 일절 손대지 않고 돌아갑니다
if (Interlocked.Exchange(ref claimed, 1) != 0)
{
return;
}
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}));
}
catch (Exception ex)
{
// BeginInvoke 자체가 던지는 경우도 있습니다(이미 핸들이 없는 경우 등).
// 여기서 완료 처리해 두지 않으면, 역시 기다린 채로 남습니다.
// 델리게이트는 돌지 않으므로, 여기에서도 권리를 가져온 뒤에 완료 처리합니다
if (Interlocked.Exchange(ref claimed, 1) == 0)
{
tcs.TrySetException(ex);
}
}
return tcs.Task;
}
}
호출 측은 InvokeAsync 예와 거의 같은 형태가 됩니다. 토큰은 폼의 수명에 묶어 주세요.
// 폼의 필드. 닫을 때 취소한다
private readonly CancellationTokenSource _formClosing = new();
protected override void OnFormClosed(FormClosedEventArgs e)
{
// 큐에 넣은 델리게이트가 실행되지 않은 채 버려져도,
// 여기서 await 측을 완료 처리할 수 있도록 해 둔다
_formClosing.Cancel();
base.OnFormClosed(e);
}
private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _formClosing.Token);
string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);
await previewTextBox.InvokeOnUiAsync(() =>
{
previewTextBox.Text = text;
statusLabel.Text = "완료";
}, linked.Token);
}
이 형태라면 UI 측에서 난 예외도 await한 장소의 try / catch에서 받을 수 있습니다. 잡아 둘 점은 네 가지입니다.
- 취소와 실행은 「플래그를 본 뒤에 움직인다」로는 부족합니다. 「취소되었는가?」를 확인한 직후,
action()을 호출하기 전에 취소가 돌 수 있습니다. 이 순간에tcs는 취소 완료가 되고,await하던 호출 측은 앞으로 가서 다음 조작을 시작합니다. 그 뒤에 아직 큐에 남아 있던 오래된 델리게이트가 움직여 화면을 다시 씁니다 ── 새 표시가 오래된 표시로 덮어쓰입니다, 재현하기 어려운 깨짐입니다. 위 코드가Interlocked.Exchange로 「한 번뿐인 권리」를 빼앗게 하는 것은 이 때문이며, 가져가지 못한 쪽은 아무것도 하지 않고 돌아갑니다 - 핸들 작성 전(
Load보다 앞)이나, 폼을 닫은 뒤의BeginInvoke는 예외가 됩니다. 호출 측 수명을 의식해 주세요 - 큐에 넣은 뒤에 컨트롤이 폐기되면, 델리게이트는 실행되지 않고 버려질 수 있습니다. 그 경우
TaskCompletionSource에는 결과도 예외도 들어가지 않으므로,await하고 있는 쪽은 영원히 기다립니다. 위 예처럼 폼 종료에 묶은 토큰을 반드시 넘겨 주세요. 완료 처리한 결과는OperationCanceledException으로 올라갑니다 File.ReadAllTextAsync는 .NET Core 2.0 이후의 API입니다. .NET Framework 4.8에서 같은 형태로 하려면StreamReader.ReadToEndAsync등으로 바꿔 주세요
flowchart TB
accTitle: 취소와 실행의 권리 쟁탈
accDescr: 취소 측과 실행 측이 Interlocked.Exchange로 한 번뿐인 권리를 빼앗고, 먼저 가져온 쪽만 진행하며, 가져가지 못한 쪽은 아무것도 하지 않고 돌아가 오래된 델리게이트가 화면을 다시 쓰는 레이스를 막는다.
race["한 번뿐인 권리"] --> c["취소 측이 먼저 가져간다"]
race --> e["실행 측이 먼저 가져간다"]
c --> c2["Task를 취소 완료로 마무리한다"]
c --> c3["오래된 델리게이트는 아무것도 하지 않고 돌아간다"]
e --> e2["action을 실행하고 결과를 넣는다"]
그림 17: 플래그를 본 뒤에 움직이는 것이 아니라, 한 번뿐인 권리를 빼앗게 하여 가져가지 못한 쪽은 돌아갑니다.
구분하는 법으로는 이 정도의 구분이면 충분합니다.
| 하고 싶은 일 | 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"]
그림 18: 이어지는 처리를 쓰는 장소가 UI 스레드인지에 따라, plain await 그대로인지 Dispatcher / Invoke 계열이 필요한지를 판단합니다.
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로 빼낸다 |
이 중에서도 특히 마주치는 비율이 높은 것은 세 가지입니다.
- UI 스레드에서
.Result/.Wait() - UI 코드에
ConfigureAwait(false)를 기계적으로 붙인다 - 라이브러리와 UI의 책임이 섞여
Dispatcher가 안쪽까지 침입한다
이 세 가지만 빼도 코드는 한결 정돈됩니다.
flowchart TB
accTitle: 특히 마주치는 비율이 높은 세 가지
accDescr: UI 스레드에서의 .Result나 .Wait, UI 코드에 기계적으로 붙이는 ConfigureAwait(false), 책임이 섞여 Dispatcher가 라이브러리 안쪽까지 침입하는 형태 세 가지를 빼는 것만으로 코드는 정돈된다.
a1["UI 스레드에서 .Result나 .Wait"] --> fix["이 세 가지를 뺀다"]
a2["기계적인 ConfigureAwait"] --> fix
a3["Dispatcher가 안쪽까지 침입"] --> fix
fix --> calm["코드가 한결 정돈된다"]
그림 19: 안티패턴 중에서도 마주치는 비율이 높은 것은 이 세 가지이며, 빼는 것만으로 효과가 큽니다.
7. 리뷰 시 체크리스트
내용은 2.2의 판단표와 6장의 안티패턴과 같지만, 여기는 코드를 열었을 때 차례로 확인할 질문 형태로 두었습니다.
- 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. 대략적인 구분
상황별 선택은 2.2의 판단표에 모아 두었으므로, 여기서는 가져갈 요점만 둡니다.
- UI의 가장 바깥쪽은 plain
await.await뒤에 그대로 UI를 건드릴 수 있는 것은, 이것을 지키고 있기 때문입니다 Task.Run은 CPU를 빼내는 곳. I/O 대기를 감싸는 도구가 아닙니다ConfigureAwait(false)는 범용 라이브러리의 도구. UI 코드에 기계적으로 붙이지 않습니다Dispatcher/BeginInvoke/InvokeAsync는 UI가 아닌 장소에서 UI를 건드릴 때만- UI 스레드에서 기다리는 세 가지(
.Result/.Wait()/.GetAwaiter().GetResult())는 쓰지 않습니다. 동기화하고 싶어지면 호출 측까지 통째로 async로 늘립니다
이유 부분은 4장, Dispatcher / Invoke를 고르는 방법은 5장, 실제 코드에서 찾기 위한 관점은 6장과 7장에 있습니다.
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의 바깥과 안을 나눈다
- 복귀 위치를 의식한다
- 블록을 들이지 않는다
이 세 가지를 지키는 것만으로 WPF / WinForms의 비동기 코드는 꽤 조용해집니다. 화면이 멈추는 코드는 대개 「비동기가 나쁘다」가 아니라, UI 스레드에 빚지는 방식이 거칠 뿐입니다.
flowchart TB
accTitle: 조용한 비동기 UI 코드의 3원칙
accDescr: UI의 바깥과 안을 나누고, await의 복귀 위치를 의식하고, 블록을 들이지 않는다는 세 가지를 지키는 것만으로 WPF와 WinForms의 비동기 코드는 조용해진다.
r1["UI의 바깥과 안을 나눈다"] --> calm["비동기 코드가 조용해진다"]
r2["복귀 위치를 의식한다"] --> calm
r3["블록을 들이지 않는다"] --> calm
그림 20: 정리의 3원칙. 나누기·복귀 위치를 의식하기·블록하지 않기, 로 화면은 멈추지 않게 됩니다.
10. 참고 자료
- 이 글의 샘플 코드 세트(UI 비의존 라이브러리, WPF / WinForms 샘플, 유닛 테스트) - komurasoft-blog-samples (GitHub)
- 관련 글: 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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WinForms / WPF 앱의 CI/CD 실전 ── GitHub Actions로 빌드부터 서명·배포까지 자동화하기
WinForms / WPF 앱의 CI/CD를 GitHub Actions로 구성하는 실무 가이드입니다. windows-latest에서 빌드+테스트를 돌리는 최소 YAML, 태그 기반 버전 부여, signtool 서명 연동, MSI/MSIX/Clic...
Windows 앱의 작업 트레이 상주와 토스트 알림 ── NotifyIcon의 함정과 AppNotification 고르는 법
업무용 Windows 앱의 작업 트레이 상주와 토스트 알림 구현을 정리합니다. NotifyIcon의 올바른 사용법, Explorer 재시작 시 재등록, 토스트 API 3종의 선정 판단표, 알림이 도착하지 않는 경우까지 설명합니다.
WinForms/WPF 앱의 다국어화 ── resx·satellite assembly·culture 전환의 실무
Windows 데스크톱 앱의 다국어화를 정리합니다. CurrentCulture와 CurrentUICulture의 차이, resx와 satellite assembly의 구조, WPF에서 현실적인 방식 선택, 런타임 언어 전환, 서식·RTL까지 설명...
WinForms/WPF 앱에 Entra ID 인증을 넣는 방법 ── MSAL.NET과 WAM 브로커의 실무 구성
WinForms/WPF 데스크톱 앱에 Entra ID 인증을 넣는 절차를 정리합니다. 퍼블릭 클라이언트의 개념, 앱 등록, MSAL.NET의 AcquireTokenSilent, WAM 브로커, 토큰 캐시 영속화까지 설명합니다.
Windows 데스크톱 앱의 UI 자동 테스트 ── UI Automation 구조와 FlaUI로 만드는 잘 깨지지 않는 테스트
WinForms/WPF 앱의 UI 자동 테스트를 Windows UI Automation 구조부터 정리합니다. FlaUI 최소 구현, AutomationId 설계와 조건 대기로 잘 깨지지 않게 하는 방법, CI 무인 실행의 함정까지 실무 관점에서 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
WPF / WinForms의 UI 스레드와 async/await는 Windows 앱 개발 구현에서 가장 막히기 쉬운 논점 중 하나입니다.
기술 상담 & 설계 리뷰
UI와 백그라운드 처리의 책임 분담이나 Dispatcher 사용 구분을 정리하고 싶은 단계라면, 기술 상담·설계 리뷰로 다시 볼 수 있습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- await 뒤에는 어느 스레드로 돌아가나요?
- WPF / WinForms의 UI 이벤트 핸들러에서 plain await(ConfigureAwait 없음)를 쓴 경우, await 후의 이어지는 처리는 기본적으로 UI 스레드로 돌아갑니다. await는 그 시점의 UI SynchronizationContext를 잡아 continuation을 되돌리기 때문이며, await 후에 TextBox나 Label 업데이트를 그대로 쓸 수 있습니다. await Task.Run(...)인 경우에도 계산 자체는 ThreadPool에서 움직이지만, plain await라면 이어지는 처리는 UI 스레드에서 재개됩니다.
- UI 스레드에서 .Result나 .Wait()를 쓰면 왜 멈추나요?
- UI 스레드가 .Result로 기다리는 동안, 비동기 처리의 continuation은 캡처한 UI 컨텍스트로 돌아가려 하지만 UI 스레드는 .Result로 막혀 있어 continuation이 실행되지 못하고, 서로 기다려 데드락이 되기 때문입니다. GetAwaiter().GetResult()도 예외가 감싸지는 방식만 다를 뿐 UI 스레드를 막는 본질은 같습니다. UI에서는 .Result, .Wait(), GetAwaiter().GetResult() 세 가지를 모두 피하고 await를 사용합니다.
- ConfigureAwait(false)는 UI 코드에 붙여야 하나요?
- 붙이지 않는 편이 좋습니다. ConfigureAwait(false)는 「캡처한 UI 컨텍스트로 돌아가는 것을 강제하지 않는다」는 의미이므로, 이어지는 처리가 임의의 스레드에서 재개될 수 있고 직후의 UI 업데이트는 크로스 스레드 액세스가 될 수 있습니다. 적합한 것은 UI에 의존하지 않는 범용 라이브러리 코드이며, UI의 가장 바깥쪽은 plain await로 유지하는 것이 방침입니다.
- Task.Run은 언제 써야 하나요?
- 무거운 CPU 계산을 UI 스레드에서 빼고 싶을 때뿐입니다. I/O 대기를 Task.Run으로 감싸는 것은 대기를 쓸데없이 ThreadPool로 다시 던지는 것에 불과해 이득이 없습니다. Task.Run 안만 다른 스레드이고, await Task.Run(...)의 이어지는 처리는 plain await라면 보통 UI 스레드로 돌아가므로 결과를 화면에 반영하는 코드는 그대로 쓸 수 있습니다.