WPF/WinForms의 async와 UI 스레드를 한 장으로 정리

· 업데이트: · · C#, async/await, .NET, WPF, WinForms, UI, 스레드

수정 이력(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)

목차

  1. 먼저 결론 (한 마디로)
  2. 먼저 한 장으로 정리
    • 2.1. 전체 그림
    • 2.2. 먼저 볼 판단표
  3. 이 글에서 쓰는 말
    • 3.1. UI 스레드와 메시지 루프
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. 전형 패턴
    • 4.1. UI 이벤트 핸들러에서 plain await
    • 4.2. 무거운 CPU 계산만 Task.Run
    • 4.3. ConfigureAwait(false)는 「돌아가지 않는다는 보증」이 아니라 「복귀를 강제하지 않는다」
    • 4.4. .Result / .Wait() / .GetAwaiter().GetResult()로 막히는 이유
  5. Dispatcher / Invoke를 언제 쓰는가
  6. 흔한 안티패턴
  7. 리뷰 시 체크리스트
  8. 대략적인 구분
  9. 정리
  10. 참고 자료

이 글의 지식 맵

이 글은 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까지 빠져나가 앱이 떨어진다고 설명합니다.

WPF/WinForms의 UI 스레드와 async/awaitUI 스레드가 메시지 루프를 계속 돌리는 것, plain await가 포착한 SynchronizationContext로 이어지는 처리(continuation)를 되돌리는 것, ConfigureAwait(false)가 그 복귀를 강제하지 않는 것, Task.Run이 CPU 계산을 UI 스레드에서 빼내는 것, Dispatcher·Control.BeginInvoke·InvokeAsync가 UI로 명시적으로 되돌리는 수단인 것, .Result·.Wait()·GetAwaiter().GetResult()가 UI 스레드를 막아 데드락이나 프리즈를 부를 수 있는 것, async void의 예외가 UI의 전역 예외 처리까지 이르는 것의 관계를 보여주는 그림전제로 한다이용한다이용한다이용한다이용한다이용한다의 후속전제로 한다전제로 한다이용한다방지한다이용한다권장되는 대응전제로 한다사용은 비권장권장되는 대응권장되는 대응사용은 비권장방지한다원인이 될 수 있다원인이 될 수 있다사용은 비권장원인이 될 수 있다권장되는 대응원인이 될 수 있다전제로 한다UI 스레드 컨텍스트WPFWindows Forms메시지 루프SynchronizationContextDispatcher(WPF)Control.Invoke / Control.BeginInvokeControl.InvokeAsyncTaskCompletionSourceCancellationToken(.NET)재진입 방지 가드(Interlocked.Exchange 등)취소와 실행이 경합하는 레이스plain awaitConfigureAwait(false)범용 라이브러리 코드Task.RunI/O-bound 처리UI 스레드 막힘동기 대기 혼입(sync-over-async)교착 상태(deadlock).Result / .Wait() / .GetAwaiter().GetResult()async void이벤트 핸들러 메서드UI 전역 예외 처리기(DispatcherUnhandledException / ThreadException)

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 26건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

1. 먼저 결론 (한 마디로)

  • WPF / WinForms의 UI 이벤트 핸들러에서 plain await한 경우, await 후의 이어지는 처리는 기본적으로 UI 스레드로 돌아간다고 생각하면 됩니다
  • Task.RunCPU 계산을 UI 스레드에서 빼내기 위한 것이며, I/O 대기를 감싸는 도구가 아닙니다
  • UI 핸들러 안에서 await Task.Run(...)해도, 그 await가 plain await라면 이어지는 처리는 통상 UI 스레드로 돌아갑니다
  • ConfigureAwait(false)는 그 await캡처한 UI 컨텍스트로 돌아가는 것을 강제하지 않는다는 의미입니다. 붙인 뒤의 이어지는 처리에서 UI를 직접 건드리는 것은 위험합니다
  • .Result / .Wait() / .GetAwaiter().GetResult()UI 스레드를 막습니다. await continuation이 UI로 돌아가야 한다면, 꽤 흔하게 막힙니다
  • WPF에서 명시적으로 UI로 되돌린다면 Dispatcher.InvokeAsync
  • WinForms에서 명시적으로 UI로 되돌린다면, 기존에는 BeginInvoke, .NET 9 이후라면 InvokeAsync가 async 플로우와 잘 맞습니다
  • 우선 방침은 UI의 가장 바깥쪽은 plain await, 범용 라이브러리는 ConfigureAwait(false)를 검토, UI로 되돌리는 것은 필요한 곳에서만 명시입니다

요컨대 WPF / WinForms에서는

  1. 지금 어느 스레드에서 돌고 있는가
  2. await의 이어지는 처리가 어디로 돌아가는가
  3. UI로 되돌리는 책임을 어디가 질 것인가

이 세 가지를 잡으면 흐름이 한결 잘 보입니다.

흐름을 밝히는 세 가지 질문지금 어느 스레드에서 돌고 있는가, await의 이어지는 처리가 어디로 돌아가는가, UI로 되돌리는 책임을 어디가 질 것인가의 세 가지를 잡으면 WPF와 WinForms의 비동기 코드 흐름이 잘 보인다.지금 어느 스레드에서 돌고 있는가비동기 UI 코드의 흐름await의 이어지는 처리는 어디로 돌아가는가UI로 되돌리는 책임은 누가 지는가

그림 1: 막히면 돌아가는 세 가지 질문. 스레드·복귀 위치·되돌리는 책임을 나눠 생각합니다.

2. 먼저 한 장으로 정리

2.1. 전체 그림

먼저 이 그림으로 전체 그림을 잡는 것이 빠릅니다.

UI 이벤트 핸들러(WPF / WinForms)plain awaitI/O APIUI SynchronizationContext를 잡는다await 후는 UI 스레드에서 재개UI 업데이트를 그대로 쓸 수 있다await Task.Run(...)무거운 CPU 처리계산 자체는 ThreadPoolawait 후는 UI 스레드에서 재개await SomeAsync().ConfigureAwait(false)UI로 돌아가는 것을 강제하지 않는다이어지는 처리는 임의의 스레드직접 UI 업데이트는 위험Dispatcher / Invoke가 필요SomeAsync().Result / Wait()GetAwaiter().GetResult()UI 스레드를 블록continuation이 UI로 돌아갈 수 없다행 / 데드락 / 적어도 프리즈

그림 2: UI 핸들러에서 본 4패턴의 전체 그림. plain await와 Task.Run은 UI로 돌아가고, ConfigureAwait(false)는 복귀를 강제하지 않으며, .Result / .Wait()는 UI 스레드를 막습니다.

실무에서 보이는 것은 대개 이 4패턴입니다.

  1. UI 이벤트 핸들러에서 plain await
  2. UI 이벤트 핸들러에서 Task.Run으로 CPU를 빼낸다
  3. ConfigureAwait(false)로 복귀 위치를 뗀다
  4. .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장)으로 나눕니다.

적은 await가 아니라 동기 블록plain await는 UI 코드에서는 오히려 아군이고, 진짜 적은 UI 스레드를 동기적으로 막는 일이라는 정리를 나타낸다.plain awaitUI 코드에서는 오히려 아군UI 스레드를 동기적으로 막는다프리즈나 데드락의 원인

그림 3: 판단표의 핵심 정리. 적은 await 자체가 아니라, UI 스레드를 동기적으로 막는 일입니다.

3. 이 글에서 쓰는 말

3.1. UI 스레드와 메시지 루프

WPF / WinForms의 UI는 기본적으로 UI 스레드가 하나 있고, 그 스레드가 입력·그리기·이벤트 처리를 돌린다는 형태입니다.

이 UI 스레드의 역할은 대개 이렇습니다.

  • 버튼 클릭, 키 입력, 다시 그리기 같은 메시지를 처리한다
  • 컨트롤이나 UI 객체를 안전하게 건드릴 수 있는 유일한 스레드가 된다
  • 여기에 처리를 너무 많이 넣으면 화면 업데이트나 입력 응답이 멈춘다

여기서의 핵심은 UI 스레드의 일은 「빠르게 도는 것」이라는 점입니다. 여기를 오래 블록하면 마우스도 키보드도 다시 그리기도 막혀, 사용자에게는 「멈췄다」로 보입니다.

이 이미지는 그림으로 머리에 두면 헷갈리기 어려워집니다.

사용자 입력 / 다시 그리기 요청UI 스레드의 메시지 루프이벤트 핸들러 실행화면 업데이트긴 동기 처리메시지 루프가 돌지 않는다화면이 멈춘 것처럼 보인다

그림 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.Currentnull이고 TaskScheduler.Current가 기본이면, continuation은 ThreadPool 위에서 돕니다. WPF / WinForms의 UI 스레드에서는 전자, 즉 UI의 SynchronizationContext가 있으므로, 실무에서는 UI의 SynchronizationContext가 적용되고 있다고 봐도 됩니다.

await continuation 위치의 결정 방식기본 await는 먼저 SynchronizationContext.Current를 잡고, null이면 TaskScheduler.Current를 보고, 기본이 아니면 그 TaskScheduler로, 어느 쪽도 아니면 ThreadPool에서 continuation을 실행한다.있다null기본이 아니다기본기본 awaitSynchronizationContext가 있는가?그 컨텍스트로 되돌린다TaskScheduler는 기본인가?그 TaskScheduler로 되돌린다ThreadPool에서 continuation을 실행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가 앞에 나옵니다.

실무에서는 추상화와 실체의 관계를 이 정도로 기억하면 섞이기 어렵습니다.

현재 코드SynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.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_ClickUI 스레드에서 시작합니다. 그리고 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 등)는 어디까지나 마지막 그물로 두는 것입니다.

async void 예외의 행선지async Task의 예외는 반환값 Task에 실려 호출 측이 await로 받을 수 있지만, async void에서는 밖으로 빠져나간 예외가 시작 시의 SynchronizationContext 즉 UI 스레드로 다시 던져지고, 전역 예외 핸들러에서 처리하지 않으면 앱이 죽는다.async Taskasync void핸들러 밖으로 빠져나간 예외async Task인가 async void인가반환값 Task에 실린다await한 호출 측이 받는다UI 스레드로 다시 던져진다전역 예외 핸들러에 나온다처리하지 않으면 앱이 죽는다

그림 7: async void에는 예외를 실을 Task가 없으므로, 핸들러 안에서 잡는 것이 기본이 됩니다.

WinForms에서도 보는 법은 같습니다. Click 핸들러 안에서 plain await하는 한, 이어지는 처리는 기본적으로 UI 측으로 돌아갑니다.

그림으로 보면 이런 흐름입니다.

UI SynchronizationContext비동기 I/OUI 스레드UI SynchronizationContext비동기 I/OUI 스레드대기 중에는 메시지 루프로 돌아간다Click 핸들러 시작ReadAllTextAsync를 await이어지는 처리를 UI로 되돌릴 예약I/O 완료이어지는 처리를 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;
    }
}

이 코드에서 일어나는 일은 대개 이렇습니다.

  1. UI 스레드에서 이벤트 핸들러가 시작된다
  2. File.ReadAllBytesAsync의 I/O 대기는 비동기로 흘린다
  3. 무거운 해시 계산만 Task.Run으로 ThreadPool에 낸다
  4. await Task.Run(...)의 이어지는 처리는 plain await이므로 UI 스레드로 돌아간다
  5. ResultText.Text = hash;를 그대로 쓸 수 있다

Task.Run 안만 다른 스레드입니다. await 뒤까지 영속적으로 「이제 UI가 아닌 장소」로 가는 것은 아닙니다.

여기를 한 장으로 보면 오해하기 어렵습니다.

ThreadPool비동기 I/OUI 스레드ThreadPool비동기 I/OUI 스레드await Task.Run(...)의 이어지는 처리는 UI에서 재개ReadAllBytesAsync를 awaitplain await이므로 UI에서 재개Task.Run으로 무거운 CPU 처리를 던진다계산 결과를 반환화면에 결과를 반영

그림 9: Task.Run 안만 ThreadPool에서 움직이고, await의 이어지는 처리는 UI 스레드로 돌아가므로 화면 반영은 그대로 쓸 수 있습니다.

여기서의 주의는 두 가지입니다.

  • I/O 대기를 Task.Run으로 감싸지 않는다
  • Task.Run은 「비동기화」가 아니라 「CPU를 빼내는 곳」을 만드는 것이라고 생각한다

Task.Run(async () => await File.ReadAllTextAsync(...)) 같은 작성은 I/O 대기를 쓸데없이 ThreadPool로 다시 던지는 것에 불과해, 이득이 거의 없습니다.

Task.Run을 쓸 곳무거운 CPU 계산을 ThreadPool로 빼내는 것이 Task.Run의 역할이며, I/O 대기를 감싸는 것은 대기를 쓸데없이 ThreadPool로 다시 던지는 것에 불과해 이득이 없다.무거운 CPU 계산I/O 대기빼고 싶은 것은 무엇인가Task.Run으로 빼낸다Task.Run으로 감싸지 않는다대기를 다시 던질 뿐 이득이 없다

그림 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로 돌아간다

는 분리가 됩니다.

라이브러리와 UI의 분리범용 라이브러리 내부는 ConfigureAwait(false)로 UI 컨텍스트로의 복귀를 요구하지 않고, 그것을 UI 핸들러가 plain await하면 호출 측의 이어지는 처리는 UI로 돌아간다는 분리를 나타낸다.라이브러리 내부의 awaitConfigureAwait〔false〕UI로의 복귀를 요구하지 않는다UI 핸들러의 plain await호출 측의 이어지는 처리는 UI로 돌아간다안쪽 지정은 바깥으로 미치지 않는다

그림 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_Clickawait의 이어지는 처리는 UI로 돌아가는 것을 강제하지 않습니다. 그래서 PreviewTextBox.Text = text;크로스 스레드 액세스가 될 수 있습니다.

하나 더, 눈에 잘 안 띄지만 중요한 점이 있습니다. ConfigureAwait(false)를 붙여도 반드시 ThreadPool로 옮겨 간다고 할 수 없고, 그 await가 기다리지 않고 바로 완료된 경우 이어지는 처리는 그대로 지금 스레드에서 흐를 수 있습니다. 「반드시 다른 스레드로 간다」「여기서부터는 줄곧 UI가 아니다」로 읽으면 사고의 씨앗이 되며, 의미는 어디까지나 await continuation을 원래 UI 컨텍스트로 되돌리는 것을 강제하지 않는다, 이것뿐입니다.

그림으로 보면 이렇습니다.

아니오UI 핸들러에서 awaitConfigureAwait(false)를 붙이는가?이어지는 처리는 기본 UI 스레드그대로 UI 업데이트하기 쉽다이어지는 처리는 UI에 고정하지 않는다임의의 스레드에서 재개될 수 있다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 스레드에서 하면 위험합니다.

흐름을 그림으로 보면 이렇습니다.

UI SynchronizationContext비동기 I/OUI 스레드UI SynchronizationContext비동기 I/OUI 스레드그러나 UI는 .Result로 막혀 있다continuation이 돌지 않으므로 완료할 수 없다LoadButton_Click 시작LoadTextAsync() 호출미완료 Task를 반환.Result로 대기하여 블록I/O 완료, continuation을 UI로 되돌리고 싶다이어지는 처리를 실행하고 싶다

그림 13: .Result로 막힌 UI 스레드로 continuation이 돌아가지 못해, Task가 끝없이 완료되지 않는 흐름입니다.

무엇이 일어나는지를 말로 하면 이렇습니다.

  1. UI 스레드가 LoadTextAsync()를 호출한다
  2. LoadTextAsync() 안의 await는 UI 컨텍스트를 잡는다
  3. UI 스레드는 .Result로 기다려 버린다
  4. I/O가 끝난다
  5. LoadTextAsync()의 이어지는 처리는 UI 스레드로 돌아가고 싶다
  6. 그러나 UI 스레드는 .Result로 막혀 있다
  7. 이어지는 처리가 돌지 못해 LoadTextAsync()가 완료되지 않는다
  8. .Result는 끝나지 않는다

UI는 「네가 끝날 때까지 기다린다」고 하고, 비동기 측은 「UI로 돌아갈 수 있으면 끝낼 수 있다」고 하여, 서로 기다립니다. 정말 기분 나쁜 느낌입니다.

서로 기다리는 구도UI 스레드는 비동기 처리의 완료를 .Result로 기다리고, 비동기 측 continuation은 UI 스레드의 빈자리를 기다리므로, 서로 상대를 기다려 진행되지 않는다.UI 스레드가 .Result로 기다린다비동기 측의 완료가 필요하다continuation은 UI로 돌아가야 한다UI 스레드의 빈자리가 필요하다서로 기다려 진행되지 않는다

그림 14: 「끝날 때까지 기다린다」와 「UI로 돌아갈 수 있으면 끝낼 수 있다」가 부딪혀, 서로 기다립니다.

여기서 흔한 착각은 GetAwaiter().GetResult()로 바꾸면 안전하다고 생각하는 것입니다. 그러나 UI 스레드를 막는다는 본질은 같습니다. 다른 것은 주로 예외가 감싸지는 방식입니다.

그래서 UI에서는 이 세 가지를 같은 냄새로 다루는 편이 안전합니다.

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

덧붙여, WPF의 Dispatcher.InvokeAsync(...)가 반환하는 DispatcherOperationTask를 UI 스레드에서 Task.Wait()하는 것도 같은 이유로 위험합니다. InvokeAsync는 넘긴 델리게이트를 Dispatcher 큐에 쌓을 뿐이며, 실제로 도는 것은 UI 스레드가 그 큐를 돌렸을 때입니다. UI 스레드가 Wait()로 멈춰 있으면 큐는 돌지 않으므로, 그 Task는 영원히 완료되지 않습니다. 같은 이야기는 DispatcherOperation 측에도 있으며, DispatcherOperation.Wait()는 같은 스레드에서 실행 중인 조작을 기다리면 InvalidOperationException이 된다고 명시되어 있습니다. 블록해서 기다리는 경로 자체가 전제되어 있지 않다는 뜻입니다. UI 맥락에서는 「던진 것을 동기로 기다린다」는 방향 자체가 막히기 쉽습니다. 막히는 방식의 자세한 설명은 Await, and UI, and deadlocks! Oh my!가 읽기 쉽습니다.

InvokeAsync를 동기로 기다리는 위험Dispatcher.InvokeAsync는 델리게이트를 큐에 쌓을 뿐이며, 실행되는 것은 UI 스레드가 그 큐를 돌렸을 때이므로, UI 스레드가 Wait로 멈춰 있으면 큐가 돌지 않아 Task는 영원히 완료되지 않는다.InvokeAsync로 큐에 쌓는다UI 스레드가 큐를 돌리면 실행UI 스레드가 Wait로 정지큐가 돌지 않는다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 플로우에서는 기본적으로 블록하지 않는 쪽이 맞물리기 좋습니다.

Invoke와 BeginInvoke의 차이Invoke는 동기 송신으로 호출 측을 기다리게 하고, BeginInvoke는 큐에 넣고 바로 돌아오므로, async 플로우에서는 블록하지 않는 쪽이 맞물리기 좋다.Invoke〔동기 송신〕호출 측을 기다리게 한다BeginInvoke〔큐에 넣기〕바로 돌아온다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 등으로 바꿔 주세요
취소와 실행의 권리 쟁탈취소 측과 실행 측이 Interlocked.Exchange로 한 번뿐인 권리를 빼앗고, 먼저 가져온 쪽만 진행하며, 가져가지 못한 쪽은 아무것도 하지 않고 돌아가 오래된 델리게이트가 화면을 다시 쓰는 레이스를 막는다.한 번뿐인 권리취소 측이 먼저 가져간다실행 측이 먼저 가져간다Task를 취소 완료로 마무리한다오래된 델리게이트는 아무것도 하지 않고 돌아간다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를 너무 늘리지 않는다

이것으로 사고가 꽤 줄어듭니다.

막히면 이 정도의 판단도로 충분합니다.

아니오아니오이 이어지는 처리를 쓰는 장소는 UI 스레드인가?예?plain await 그대로 UI 업데이트해도 된다UI를 건드리고 싶은가?그대로 처리 계속WPF: Dispatcher.InvokeAsyncWinForms: 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()
라이브러리 코드가 DispatcherControl을 직접 붙잡는다 UI 의존이 깊어진다. 재사용하기 어렵다 라이브러리는 데이터만 반환하고, UI 측에서 marshal한다
Dispatcher.Invoke / Control.Invoke를 async 플로우에 많이 쓴다 블록의 고리가 생기기 쉽다 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync를 검토
생성자나 프로퍼티 getter에서 async를 동기화한다 시작 시 행의 온상이 된다 Loaded / Shown / InitializeAsync로 빼낸다

이 중에서도 특히 마주치는 비율이 높은 것은 세 가지입니다.

  1. UI 스레드에서 .Result / .Wait()
  2. UI 코드에 ConfigureAwait(false)를 기계적으로 붙인다
  3. 라이브러리와 UI의 책임이 섞여 Dispatcher가 안쪽까지 침입한다

이 세 가지만 빼도 코드는 한결 정돈됩니다.

특히 마주치는 비율이 높은 세 가지UI 스레드에서의 .Result나 .Wait, UI 코드에 기계적으로 붙이는 ConfigureAwait(false), 책임이 섞여 Dispatcher가 라이브러리 안쪽까지 침입하는 형태 세 가지를 빼는 것만으로 코드는 정돈된다.UI 스레드에서 .Result나 .Wait이 세 가지를 뺀다기계적인 ConfigureAwaitDispatcher가 안쪽까지 침입코드가 한결 정돈된다

그림 19: 안티패턴 중에서도 마주치는 비율이 높은 것은 이 세 가지이며, 빼는 것만으로 효과가 큽니다.

7. 리뷰 시 체크리스트

내용은 2.2의 판단표와 6장의 안티패턴과 같지만, 여기는 코드를 열었을 때 차례로 확인할 질문 형태로 두었습니다.

  • UI 이벤트 핸들러나 UI 초기화 경로에 .Result / .Wait() / .GetAwaiter().GetResult()가 남아 있지 않은가
  • Task.RunCPU 계산에만 쓰이고 있는가. 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로 되돌리는 책임을 누가 지는가

를 나눠 생각하는 일입니다.

우선 규칙으로는, 이것만 지키면 충분히 싸울 수 있습니다.

  1. UI의 가장 바깥쪽에서는 plain await
  2. 무거운 CPU만 Task.Run
  3. 범용 라이브러리에서는 ConfigureAwait(false)를 검토
  4. UI로 되돌려야 할 때만 Dispatcher / BeginInvoke / InvokeAsync
  5. UI 스레드에서는 .Result / .Wait() / .GetAwaiter().GetResult()를 쓰지 않는다

async / await 자체는 그렇게 까다로운 메커니즘이 아닙니다. 다만 UI 스레드를 중심으로 보지 않은 채 쓰면, 갑자기 진창이 됩니다.

반대로 말하면,

  • UI의 바깥과 안을 나눈다
  • 복귀 위치를 의식한다
  • 블록을 들이지 않는다

이 세 가지를 지키는 것만으로 WPF / WinForms의 비동기 코드는 꽤 조용해집니다. 화면이 멈추는 코드는 대개 「비동기가 나쁘다」가 아니라, UI 스레드에 빚지는 방식이 거칠 뿐입니다.

조용한 비동기 UI 코드의 3원칙UI의 바깥과 안을 나누고, await의 복귀 위치를 의식하고, 블록을 들이지 않는다는 세 가지를 지키는 것만으로 WPF와 WinForms의 비동기 코드는 조용해진다.UI의 바깥과 안을 나눈다비동기 코드가 조용해진다복귀 위치를 의식한다블록을 들이지 않는다

그림 20: 정리의 3원칙. 나누기·복귀 위치를 의식하기·블록하지 않기, 로 화면은 멈추지 않게 됩니다.

10. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

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 스레드로 돌아가므로 결과를 화면에 반영하는 코드는 그대로 쓸 수 있습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기