반응형

[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가지 규칙은 다음과 같이 매칭됩니다.

  1. I/O는 async, CPU는 Task.Run — 오븐에 음식을 넣었으면 그냥 다음 일을 하면 됩니다(=async). 칼질이 필요하면 보조에게 시킵니다(=Task.Run). 오븐 앞에 보조를 세워두는 건(=I/O에 Task.Run) 인력 낭비입니다.
  2. async void는 이벤트 핸들러에서만— 보조에게 일을 시켰으면 끝났는지 확인할 수 있어야 합니다. "끝났는지조차 모르는 일"은 손님 콜벨(이벤트 핸들러)에 응답할 때만 허용합니다.
  3. await 앞뒤 예외 전파— 보조가 칼에 베였으면 주방장이 알아야 합니다. await 하지 않으면 보조가 쓰러져도 주방장은 모르고 계속 다음 손님을 받습니다.
  4. 공유 상태는 피하고, 어쩔 수 없으면 lock — 도마 하나를 둘이 동시에 쓰면 손이 잘립니다. 줄을 서거나(=SemaphoreSlim) 각자 도마를 들고 다니거나(=불변 데이터) 해야 합니다.

비유를 바탕으로 이제 각 규칙을 깊이 들여다보겠습니다.


규칙 ① I/O는 async, CPU 바인드 작업은 Task.Run

왜 이 규칙이 필요한가

비동기 코드의 첫 번째 갈림길은 "이 작업이 기다리는 작업인가, 일하는 작업인가" 입니다.

  • I/O 바인드(I/O-bound): 네트워크 호출, 파일 읽기/쓰기, DB 쿼리. CPU는 거의 안 쓰고, 외부 장치의 응답을 기다립니다.
  • CPU 바인드(CPU-bound): 이미지 압축, 경로 탐색, JSON 파싱(대용량). CPU가 계속 일해야 합니다.

async/await스레드를 기다리지 않고 풀어주는 메커니즘이고, Task.Run다른 스레드에 일을 위임하는 메커니즘입니다. 둘은 목적이 완전히 다릅니다.

작업의 성격에 따라 도구를 고른다

위반 사례

C#
// 안티패턴 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;
}

올바른 코드

C#
// 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로 만들 수밖에 없습니다.

위반 사례

C#
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)
    {
        // 절대 실행되지 않음
    }
}

올바른 코드

C#
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 TaskAsyncTaskMethodBuilder 사용. 예외를 Task.Exception에 저장하고 상태를 Faulted로 전환. await가 이 예외를 다시 throw 해 줌.
  • async voidAsyncVoidMethodBuilder 사용. 예외를 담을 Task가 없으므로 SynchronizationContext.Post로 예외를 직접 전송. UI 컨텍스트는 이를 처리되지 않은 예외로 보고 앱을 종료.
C#
// 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를 명시적으로 두는 패턴이 훨씬 안전합니다.
C#
// 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입니다.

예외는 Task에 담겼다가 await에서 풀린다

위반 사례

C#
// 안티패턴 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);
    }
}

올바른 코드

C#
// 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()를 호출합니다. TaskFaulted 상태라면 GetResult()AggregateException 안의 첫 번째 예외를 꺼내 다시 throw 합니다. 그래서 await 한 코드는 마치 동기 코드처럼 깔끔하게 try/catch로 잡을 수 있습니다.

반대로 .Result.Wait()은 동기 차단이고, 예외를 AggregateException으로 한 겹 더 감싸서 던지므로 catch (HttpRequestException) 같은 구체 타입 잡기가 더 어려워집니다.

Unity 모바일 함정

Unity 메인 스레드에서 .Result.Wait()을 쓰면 데드락이 거의 확정입니다. SynchronizationContext가 메인 스레드 1개로 구성되어 있는데, 메인 스레드가 Result로 자기 자신을 기다리기 때문입니다. 모바일에서 흔한 "앱이 멈췄어요(ANR)" 신고의 큰 비중이 이 패턴입니다.

한 줄 요약

비동기 메서드를 호출했으면 결과에 관심이 있는 한 반드시 await. .Result, .Wait()은 데드락 폭탄이다.

규칙 ④ 공유 상태는 피하고, 불가피하면 lock 또는 불변 데이터로 감싸기

왜 이 규칙이 필요한가

await는 단순히 "기다리기"가 아니라 "이 지점에서 메서드를 잘랐다가 나중에 다른 스레드에서 이어붙일 수 있는 분기점" 입니다. 이 사실은 두 가지 함의를 가집니다.

  1. await 앞뒤에서 스레드가 바뀔 수 있다 → 스레드 친화성(thread-affinity)을 가정하면 안 된다.
  2. 여러 비동기 작업이 같은 변수를 만지면 경쟁 조건(race condition) 이 생긴다.

여기서 가장 자주 빠지는 함정은 lock 블록 안에서 await를 쓰려고 하는 것 입니다. C# 컴파일러는 이를 컴파일 에러로 막습니다.

CS1996: Cannot await in the body of a lock statement

이유는 명확합니다. lock은 IL에서 Monitor.Enter / Monitor.Exit로 컴파일되며 락을 획득한 스레드가 직접 해제해야 합니다. 그런데 await 이후 코드는 다른 스레드에서 실행될 수 있어서 "들어간 스레드 ≠ 나오는 스레드"가 되어 락이 망가집니다.

위반 사례

C#
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 사이에 다른 스레드가 끼어들면 값 손실
}

올바른 코드

C#
// 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의 한계

C#
// 원본
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개 주제에 걸쳐 다뤘습니다. 다시 한번 짚어 보면 다음 흐름이었습니다.

  1. 개념의 전환 (01~04) — "동기는 한 줄씩 처리, 비동기는 흐름이 갈라진다"는 사고 방식. Thread, ThreadPool이라는 OS 추상화 위에서 .NET의 Task가 어떻게 만들어졌는지.
  2. Task와 async/await (05~07) — Task는 결과를 담는 그릇이고 async/await는 그 그릇을 펴서 동기처럼 쓰게 해주는 문법 설탕. 반환형의 의미.
  3. 조합과 취소 (08~09) — WhenAll/WhenAny로 여러 작업을 묶고, CancellationToken으로 협조적 취소를 한다.
  4. 예외와 스트림 (10~11) — 비동기에서 예외는 Task에 담긴 보따리. IAsyncEnumerable은 끝없이 흘러오는 데이터에 foreach처럼 접근하게 해 준다.
  5. 동시성 제어 (12~14) — lock, 새 System.Threading.Lock, 그리고 결국 만나게 되는 경쟁 조건과 데드락의 본질.

이 모든 내용을 신입이 코드를 짤 때 매번 떠올리기는 어렵습니다. 그래서 마지막 정리편에서 4가지 규칙으로 압축했습니다. 처음 비동기를 만났을 때 이 4가지만 머릿속에 박아 두면 90%의 사고는 막을 수 있습니다.

비동기·병렬은 한 번에 마스터되는 영역이 아닙니다. 코드를 짜고, 디버깅하고, 운영 환경에서 데드락을 한두 번 겪고 나면 점점 몸에 붙습니다. 이 시리즈가 그 첫걸음의 디딤돌이 되었기를 바랍니다.


마무리 한 줄

"기다리는 일과 일하는 일을 구분하고, 끝났는지 알 수 있게 만들고, 예외는 await로 받아 내고, 공유 상태는 가능한 한 만들지 않는다." — 이 한 문장이 PART 13 전체의 결론이자, 비동기 코드를 짤 때 매일 자신에게 거는 4중 안전벨트입니다.
반응형

+ Recent posts