[PART13.비동기와 스레딩 기초(15/15)] 비동기·병렬 프로그래밍을 처음 만났을 때 지켜야 할 4가지
PART 13의 마무리 정리편입니다. 앞 14개 주제에서 다룬 동기/비동기, 스레드, Task, async/await, lock 같은 개념들을 신입 개발자가 실제 코드를 짤 때 가장 자주 발등을 찍는 4가지 규칙으로 압축했습니다. 각 규칙은 "왜 필요한가 → 위반 사례 → 올바른 코드 → 한 줄 요약" 순서로 정리했습니다.
목차
TL;DR — 한눈에 보는 4가지 규칙
| 번호 | 규칙 | 한 줄 요약 |
|---|---|---|
| ① | I/O는 async, CPU 작업은 Task.Run |
기다리는 일과 일하는 일은 다른 도구를 쓴다 |
| ② | async void는 이벤트 핸들러에서만 |
추적 불가능한 비동기는 앱을 죽인다 |
| ③ | await 앞뒤 예외 전파를 항상 확인 |
await 없는 Task는 예외도 없는 셈이다 |
| ④ | 공유 상태는 피하고, 불가피하면 lock 또는 불변 데이터 |
await는 스레드 경계를 넘는다는 사실을 잊지 않는다 |
신입 시절 비동기 코드를 짜다가 "왜 이 예외가 안 잡히지?", "왜 앱이 갑자기 죽지?", "왜 카운터 값이 이상하지?" 같은 의문이 들었다면 거의 100% 이 4가지 중 하나를 어겼기 때문입니다.
비유로 먼저 이해하기 — 식당 주방의 4가지 규칙
비동기·병렬 프로그래밍을 식당 주방에 비유해 보겠습니다.
- 주방장(메인 스레드): 손님 응대, 플레이팅 등 핵심 작업을 직접 담당합니다.
- 주방 보조(스레드 풀 스레드): 무거운 작업을 위임받습니다.
- 오븐(I/O 장치): 음식을 넣어두면 주방 인력 없이 알아서 익습니다.
- 공유 도마(공유 상태): 여러 사람이 동시에 쓰면 사고가 납니다.
이 비유 위에서 4가지 규칙은 다음과 같이 매칭됩니다.
- I/O는
async, CPU는Task.Run— 오븐에 음식을 넣었으면 그냥 다음 일을 하면 됩니다(=async). 칼질이 필요하면 보조에게 시킵니다(=Task.Run). 오븐 앞에 보조를 세워두는 건(=I/O에Task.Run) 인력 낭비입니다. async void는 이벤트 핸들러에서만— 보조에게 일을 시켰으면 끝났는지 확인할 수 있어야 합니다. "끝났는지조차 모르는 일"은 손님 콜벨(이벤트 핸들러)에 응답할 때만 허용합니다.await앞뒤 예외 전파— 보조가 칼에 베였으면 주방장이 알아야 합니다.await하지 않으면 보조가 쓰러져도 주방장은 모르고 계속 다음 손님을 받습니다.- 공유 상태는 피하고, 어쩔 수 없으면
lock— 도마 하나를 둘이 동시에 쓰면 손이 잘립니다. 줄을 서거나(=SemaphoreSlim) 각자 도마를 들고 다니거나(=불변 데이터) 해야 합니다.
비유를 바탕으로 이제 각 규칙을 깊이 들여다보겠습니다.
규칙 ① I/O는 async, CPU 바인드 작업은 Task.Run
왜 이 규칙이 필요한가
비동기 코드의 첫 번째 갈림길은 "이 작업이 기다리는 작업인가, 일하는 작업인가" 입니다.
- I/O 바인드(I/O-bound): 네트워크 호출, 파일 읽기/쓰기, DB 쿼리. CPU는 거의 안 쓰고, 외부 장치의 응답을 기다립니다.
- CPU 바인드(CPU-bound): 이미지 압축, 경로 탐색, JSON 파싱(대용량). CPU가 계속 일해야 합니다.
async/await는 스레드를 기다리지 않고 풀어주는 메커니즘이고, Task.Run은 다른 스레드에 일을 위임하는 메커니즘입니다. 둘은 목적이 완전히 다릅니다.

위반 사례
// 안티패턴 1: I/O 작업을 Task.Run으로 이중 포장 — 스레드 풀 낭비
public Task<string> FetchUserAsync(string id)
{
var http = new HttpClient();
return Task.Run(() => http.GetStringAsync($"/users/{id}"));
// ^^^^^^^^^ GetStringAsync는 이미 비동기 I/O다.
// Task.Run은 스레드 풀 스레드 1개를 잡아서 await만 시키고 있음.
}
// 안티패턴 2: CPU 작업을 await로만 감쌌지만 사실은 동기
public async Task<int> ComputeChecksumAsync(byte[] data)
{
int sum = 0;
for (int i = 0; i < data.Length; i++) sum += data[i] * 31;
// await 한 번도 없음 → 호출 스레드가 그대로 점유됨
// 메인 스레드에서 호출하면 UI 프리징
return sum;
}
올바른 코드
// I/O 바인드: GetStringAsync 자체가 비동기. Task.Run 불필요
public async Task<string> FetchUserAsync(string id)
{
using var http = new HttpClient();
return await http.GetStringAsync($"/users/{id}");
}
// CPU 바인드: 호출 측에서 Task.Run으로 위임
public Task<int> ComputeChecksumAsync(byte[] data)
{
return Task.Run(() =>
{
int sum = 0;
for (int i = 0; i < data.Length; i++) sum += data[i] * 31;
return sum;
});
}
Unity 모바일 함정
모바일은 데스크톱보다 스레드 풀과 배터리 예산이 빠듯합니다.
- I/O에
Task.Run남발 → 스레드 풀 고갈(starvation), 배터리 소모 증가. - 메인 스레드에서 무거운 연산 → 프레임 드랍, 스터터링.
- 결론: 네트워크는 그대로
await, 무거운 계산만Task.Run으로 넘긴 뒤, 결과를 받아 Unity API를 만질 때만 메인 스레드로 돌아옵니다.
한 줄 요약
기다리는 일은async, 일하는 일은Task.Run. 둘을 바꿔 쓰면 스레드와 배터리가 모두 손해.
규칙 ② async void는 이벤트 핸들러에서만
왜 이 규칙이 필요한가
async 메서드의 반환형은 세 가지가 있습니다.
| 반환형 | 추적 가능? | 예외 잡기 | 용도 |
|---|---|---|---|
Task |
✅ | ✅ | 일반 비동기 메서드 |
Task<T> |
✅ | ✅ | 결과 반환 비동기 메서드 |
void |
❌ | ❌ (앱 크래시) | 이벤트 핸들러 전용 |
async void는 호출자가 끝났는지 알 수도, 예외를 잡을 수도 없는 "쏘고 잊는(fire-and-forget)" 형태입니다. 예외가 발생하면 Task에 담기지 않고 SynchronizationContext로 직접 던져져서, 대부분의 경우 프로세스가 즉시 종료됩니다.
유일한 예외는 Button_Click(object, EventArgs) 같은 UI 이벤트 핸들러입니다. 이벤트 핸들러는 시그니처가 void로 강제되어 있어서 async void로 만들 수밖에 없습니다.
위반 사례
public class OrderService
{
// 안티패턴: 일반 메서드를 async void로 선언
public async void SaveOrderAsync(Order order)
{
await _db.InsertAsync(order);
if (order.Total < 0)
throw new InvalidOperationException("Total < 0");
// 이 예외는 호출자가 잡을 수 없다.
// 앱이 그냥 죽는다.
}
}
// 호출 측 — 잡으려고 해도 잡히지 않음
public void Process(Order order)
{
try
{
_service.SaveOrderAsync(order); // await 못 함, Task가 없음
}
catch (Exception)
{
// 절대 실행되지 않음
}
}
올바른 코드
public class OrderService
{
// 일반 메서드는 Task 반환
public async Task SaveOrderAsync(Order order)
{
await _db.InsertAsync(order);
if (order.Total < 0)
throw new InvalidOperationException("Total < 0");
}
}
// 이벤트 핸들러만 async void 허용
public partial class OrderForm : Form
{
private async void SaveButton_Click(object sender, EventArgs e)
{
try
{
await _service.SaveOrderAsync(_currentOrder);
MessageBox.Show("저장 완료");
}
catch (InvalidOperationException ex)
{
MessageBox.Show($"저장 실패: {ex.Message}");
}
}
}
IL/상태머신으로 보는 차이
async 메서드는 컴파일러가 빌더를 통해 상태 머신을 만듭니다.
async Task→AsyncTaskMethodBuilder사용. 예외를Task.Exception에 저장하고 상태를Faulted로 전환.await가 이 예외를 다시 throw 해 줌.async void→AsyncVoidMethodBuilder사용. 예외를 담을Task가 없으므로SynchronizationContext.Post로 예외를 직접 전송. UI 컨텍스트는 이를 처리되지 않은 예외로 보고 앱을 종료.
// async Task — 빌더가 예외를 Task에 캡처
[AsyncStateMachine(typeof(StateMachine_Task))]
public Task DoWorkAsync() { /* ... */ }
// async void — 빌더가 예외를 SynchronizationContext로 던짐
[AsyncStateMachine(typeof(StateMachine_Void))]
public void DoWorkAsync() { /* ... */ }
Unity 모바일 함정
- 빌드된 모바일 앱에서
async void예외는 로그도 남기지 않고 앱이 종료되는 경우가 많아 디버깅이 매우 어렵습니다. async void Start()는 문법적으로 가능하지만,Start내부에서async Task메서드를 호출하고try-catch를 명시적으로 두는 패턴이 훨씬 안전합니다.
// Unity에서 안전한 진입 패턴
private void Start()
{
_ = StartCoreAsync(); // 또는 별도 헬퍼로 예외 로깅
}
private async Task StartCoreAsync()
{
try
{
await LoadAssetsAsync();
await ConnectToServerAsync();
}
catch (Exception ex)
{
Debug.LogException(ex); // 최소한 로그는 남는다
}
}
한 줄 요약
async void는 이벤트 핸들러 전용. 그 외에는 무조건async Task로 반환해야 추적·예외 처리가 가능하다.
규칙 ③ await 앞뒤에서 예외가 어떻게 전파되는지 항상 확인
왜 이 규칙이 필요한가
비동기 메서드 내부에서 발생한 예외는 즉시 던져지지 않습니다. 대신 반환된 Task에 캡처됩니다. 이 캡처된 예외를 호출 스택으로 다시 끌어올리는 역할이 바로 await입니다.

위반 사례
// 안티패턴 1: await 누락 — 예외가 잡히지 않음
public async Task RunAsync()
{
try
{
FetchAsync(); // await 빠짐
}
catch (HttpRequestException)
{
// 절대 실행되지 않음
}
}
// 안티패턴 2: .Result / .Wait()로 동기 차단 — UI 데드락
public string GetUserSync()
{
return FetchUserAsync().Result;
// UI 스레드에서 호출 시 데드락
// 예외도 AggregateException으로 한 겹 더 감싸짐
}
// 안티패턴 3: WhenAll 결과를 try/catch로만 감쌌지만, 첫 예외만 잡음
public async Task RunAllAsync(List<Task> tasks)
{
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// 여러 Task가 동시에 예외를 던졌을 때
// 그중 첫 번째 예외만 잡힘 (나머지는 Task.Exception에 그대로 남음)
Console.WriteLine(ex.Message);
}
}
올바른 코드
// 1) await로 예외를 정상 풀어내기
public async Task RunAsync()
{
try
{
await FetchAsync();
}
catch (HttpRequestException ex)
{
Console.WriteLine($"네트워크 오류: {ex.Message}");
}
}
// 2) Result/Wait 절대 금지 — 끝까지 await로 가져가기
public async Task<string> GetUserAsync()
{
return await FetchUserAsync();
}
// 3) WhenAll의 모든 예외를 보려면 Task의 Exception 직접 확인
public async Task RunAllAsync(List<Task> tasks)
{
var whenAll = Task.WhenAll(tasks);
try
{
await whenAll;
}
catch
{
// 모든 예외 수집
if (whenAll.Exception is { } agg)
{
foreach (var inner in agg.Flatten().InnerExceptions)
Console.WriteLine($"[실패] {inner.Message}");
}
throw;
}
}
IL/상태머신 관점
await가 끝나면 컴파일러는 내부적으로 awaiter.GetResult()를 호출합니다. Task가 Faulted 상태라면 GetResult()가 AggregateException 안의 첫 번째 예외를 꺼내 다시 throw 합니다. 그래서 await 한 코드는 마치 동기 코드처럼 깔끔하게 try/catch로 잡을 수 있습니다.
반대로 .Result 나 .Wait()은 동기 차단이고, 예외를 AggregateException으로 한 겹 더 감싸서 던지므로 catch (HttpRequestException) 같은 구체 타입 잡기가 더 어려워집니다.
Unity 모바일 함정
Unity 메인 스레드에서 .Result나 .Wait()을 쓰면 데드락이 거의 확정입니다. SynchronizationContext가 메인 스레드 1개로 구성되어 있는데, 메인 스레드가 Result로 자기 자신을 기다리기 때문입니다. 모바일에서 흔한 "앱이 멈췄어요(ANR)" 신고의 큰 비중이 이 패턴입니다.
한 줄 요약
비동기 메서드를 호출했으면 결과에 관심이 있는 한 반드시await..Result,.Wait()은 데드락 폭탄이다.
규칙 ④ 공유 상태는 피하고, 불가피하면 lock 또는 불변 데이터로 감싸기
왜 이 규칙이 필요한가
await는 단순히 "기다리기"가 아니라 "이 지점에서 메서드를 잘랐다가 나중에 다른 스레드에서 이어붙일 수 있는 분기점" 입니다. 이 사실은 두 가지 함의를 가집니다.
await앞뒤에서 스레드가 바뀔 수 있다 → 스레드 친화성(thread-affinity)을 가정하면 안 된다.- 여러 비동기 작업이 같은 변수를 만지면 경쟁 조건(race condition) 이 생긴다.
여기서 가장 자주 빠지는 함정은 lock 블록 안에서 await를 쓰려고 하는 것 입니다. C# 컴파일러는 이를 컴파일 에러로 막습니다.
CS1996: Cannot await in the body of a lock statement
이유는 명확합니다. lock은 IL에서 Monitor.Enter / Monitor.Exit로 컴파일되며 락을 획득한 스레드가 직접 해제해야 합니다. 그런데 await 이후 코드는 다른 스레드에서 실행될 수 있어서 "들어간 스레드 ≠ 나오는 스레드"가 되어 락이 망가집니다.
위반 사례
private int _counter = 0;
private readonly object _gate = new();
// 안티패턴 1: lock 없이 공유 상태 수정
public async Task UnsafeIncrementAsync()
{
int t = _counter;
await Task.Delay(10); // 여기서 다른 Task가 끼어들 수 있음
_counter = t + 1; // → 카운트 누락
}
// 안티패턴 2: lock 안에서 await — 컴파일 에러
public async Task InvalidLockAsync()
{
lock (_gate)
{
await Task.Delay(10); // CS1996
_counter++;
}
}
// 안티패턴 3: ConcurrentDictionary로 안심하다가 "Read-Modify-Write" 실수
private ConcurrentDictionary<string, int> _scores = new();
public void AddScore(string user, int delta)
{
var current = _scores.GetValueOrDefault(user, 0); // 1) 읽기
_scores[user] = current + delta; // 2) 쓰기
// 1과 2 사이에 다른 스레드가 끼어들면 값 손실
}
올바른 코드
// 1) 비동기 안에서 락이 필요하면 SemaphoreSlim
private readonly SemaphoreSlim _gate = new(1, 1);
private int _counter = 0;
public async Task SafeIncrementAsync()
{
await _gate.WaitAsync();
try
{
int t = _counter;
await Task.Delay(10);
_counter = t + 1;
}
finally
{
_gate.Release();
}
}
// 2) 단순 카운터는 Interlocked로 락-프리
private long _hits;
public void IncrementHits() => Interlocked.Increment(ref _hits);
// 3) ConcurrentDictionary는 AddOrUpdate로 원자적 수정
private ConcurrentDictionary<string, int> _scores = new();
public void AddScore(string user, int delta)
{
_scores.AddOrUpdate(user, delta, (_, prev) => prev + delta);
}
// 4) 가장 안전한 길 — 불변 데이터로 만들기
public sealed record GameState(int Score, int Lives, ImmutableList<Item> Items);
// 상태를 바꿀 때마다 새 객체를 만들고 참조만 교체
private GameState _state = new(0, 3, ImmutableList<Item>.Empty);
public void AddItem(Item item)
{
var snapshot = _state;
var next = snapshot with { Items = snapshot.Items.Add(item) };
Interlocked.CompareExchange(ref _state, next, snapshot);
}
IL 관점에서 보는 lock의 한계
// 원본
lock (_gate) { Critical(); }
// 컴파일러가 생성하는 IL 의사코드
bool taken = false;
try
{
Monitor.Enter(_gate, ref taken);
Critical();
}
finally
{
if (taken) Monitor.Exit(_gate);
}
Monitor.Enter는 호출 스레드의 ID를 기록합니다. Monitor.Exit는 같은 스레드에서 호출되어야 합니다. await 이후 다른 스레드가 Exit을 호출하려고 하면 SynchronizationLockException이 발생합니다. 컴파일러는 이 사고를 미연에 방지하기 위해 lock { ... await ... }를 아예 컴파일 에러로 막습니다.
Unity 모바일 함정
- Unity의
GameObject/Transform등은 메인 스레드에서만 접근 가능합니다.await이후에 백그라운드 스레드에서 깨어났다면 Unity API를 만지기 전에 메인 스레드 디스패처로 복귀해야 합니다(예:UniTask.SwitchToMainThread()). - 모바일은 GC 스파이크에 매우 민감합니다.
Task객체 자체도 힙 할당이므로, 빈번한 비동기 호출이 GC를 흔든다면ValueTask사용을 검토하세요. 단,ValueTask는 한 번만await가능하다는 제약이 있어 일반 메서드 반환형을 무조건ValueTask로 바꾸는 것은 위험합니다. - 공유 상태는 가능한 한
ScriptableObject나 단방향 이벤트(예: 메시지 버스)로 분리하고, 멀티스레드 접근이 정말 필요한 부분만lock/SemaphoreSlim으로 보호합니다.
한 줄 요약
await는 스레드 경계를 넘는다. 공유 상태가 있다면lock을 떠올리고,lock { await ... }은 절대 안 된다는 것만 기억하면 충분하다.
4가지 규칙 위반 자가 진단표
작성한 코드를 커밋하기 전에 이 표를 한 번씩 훑어 보세요.
| 체크 | 위반 신호 | 대응 |
|---|---|---|
| ☐ | Task.Run(() => httpClient.XxxAsync(...)) 패턴이 있다 |
I/O는 직접 await로 |
| ☐ | UI 응답 중 멈춤이 있다 | 무거운 연산은 Task.Run으로 위임 |
| ☐ | 메서드 반환형이 async void이고 시그니처가 이벤트 핸들러가 아니다 |
async Task로 변경 |
| ☐ | try/catch가 있는데 안에서 비동기 메서드를 await 없이 호출한다 |
await 추가 |
| ☐ | .Result / .Wait() / GetAwaiter().GetResult() 사용 |
호출 체인을 async로 통일 |
| ☐ | lock (_gate) { ... await ... } 같은 코드가 컴파일 에러로 보인다 |
SemaphoreSlim.WaitAsync 사용 |
| ☐ | _counter++, dict[k] = dict[k] + 1 같은 read-modify-write 패턴 |
Interlocked 또는 AddOrUpdate |
| ☐ | UI 이벤트 핸들러 안에서 비동기 메서드를 await 없이 호출 |
이벤트 핸들러는 async void 자체로 두고 안에서 await |
PART 13 학습 회고
PART 13에서는 비동기와 스레딩 기초를 14개 주제에 걸쳐 다뤘습니다. 다시 한번 짚어 보면 다음 흐름이었습니다.
- 개념의 전환 (01~04) — "동기는 한 줄씩 처리, 비동기는 흐름이 갈라진다"는 사고 방식.
Thread,ThreadPool이라는 OS 추상화 위에서 .NET의Task가 어떻게 만들어졌는지. - Task와 async/await (05~07) —
Task는 결과를 담는 그릇이고async/await는 그 그릇을 펴서 동기처럼 쓰게 해주는 문법 설탕. 반환형의 의미. - 조합과 취소 (08~09) —
WhenAll/WhenAny로 여러 작업을 묶고,CancellationToken으로 협조적 취소를 한다. - 예외와 스트림 (10~11) — 비동기에서 예외는
Task에 담긴 보따리.IAsyncEnumerable은 끝없이 흘러오는 데이터에foreach처럼 접근하게 해 준다. - 동시성 제어 (12~14) —
lock, 새System.Threading.Lock, 그리고 결국 만나게 되는 경쟁 조건과 데드락의 본질.
이 모든 내용을 신입이 코드를 짤 때 매번 떠올리기는 어렵습니다. 그래서 마지막 정리편에서 4가지 규칙으로 압축했습니다. 처음 비동기를 만났을 때 이 4가지만 머릿속에 박아 두면 90%의 사고는 막을 수 있습니다.
비동기·병렬은 한 번에 마스터되는 영역이 아닙니다. 코드를 짜고, 디버깅하고, 운영 환경에서 데드락을 한두 번 겪고 나면 점점 몸에 붙습니다. 이 시리즈가 그 첫걸음의 디딤돌이 되었기를 바랍니다.
마무리 한 줄
"기다리는 일과 일하는 일을 구분하고, 끝났는지 알 수 있게 만들고, 예외는 await로 받아 내고, 공유 상태는 가능한 한 만들지 않는다." — 이 한 문장이 PART 13 전체의 결론이자, 비동기 코드를 짤 때 매일 자신에게 거는 4중 안전벨트입니다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(14/15)] 경쟁 상태와 데드락 — 감 잡기 (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13) (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(12/15)] lock 문 — 임계 구역을 한 스레드만 통과시키기 (1) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(11/15)] 비동기 스트림 — IAsyncEnumerable<T> · await foreach (C# 8) (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리 (0) | 2026.05.09 |
























