---
title: "await 는 기다림을 넘길 뿐 일을 넘기지 않는다"
description: "무거운 계산에 async 를 붙였는데 화면이 그대로 멈춥니다. 안에 await 를 하나 넣어도 마찬가지입니다. 재보면 이유가 보입니다."
author: "유니얼"
pubDate: 2026-09-14
category: "programming/csharp/concurrency"
tags: ["C#", "Unity", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-async-main-thread/
---
무거운 계산이 프레임을 잡아먹어서 `async` 를 붙였습니다. 그런데 화면은 그대로 멈춥니다.
안에 `await` 를 하나 넣으면 괜찮다는 말을 듣고 넣어봤는데도 멈춥니다.

재봤습니다. 멈추는 게 맞습니다. 그리고 **`await` 를 넣었다고 안 멈추는 것도 아닙니다.**

## 재는 방법

Unity 메인 스레드를 흉내내는 하네스를 만들었습니다.
Unity 처럼 `await` 뒤를 한 스레드로 되돌리는 컨텍스트 하나에 16ms 짜리 프레임 루프를 돌립니다.
작업은 1500ms 짜리 CPU 연산이고, 2000ms 동안 프레임이 몇 개나 그려지는지 셉니다.
아무것도 안 하면 약 125프레임이 나와야 합니다.

`Sleep` 이 아니라 실제로 `Math.Sqrt` 를 돌려서 CPU 를 태웁니다.
`Sleep` 으로 재면 스레드를 놓는 것이라 결과가 정반대로 나옵니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "async 만 붙였다", "code": "async Task 굴리기()\n{\n    무거운계산();          // 1500ms\n    await Task.CompletedTask;\n}", "stats": [["2000ms 동안 프레임", "31개"], ["최대 끊김", "1516ms"], ["작업이 돈 스레드", "메인"]], "tone": "bad" },
    { "label": "await Task.Run", "code": "async Task 굴리기()\n{\n    await Task.Run(() => 무거운계산());\n}", "stats": [["2000ms 동안 프레임", "124개"], ["최대 끊김", "19ms"], ["작업이 돈 스레드", "워커"]], "tone": "good" }
  ]
}
</script>
</x-compare>

다섯 가지를 같은 조건으로 돌린 결과입니다.

| | 프레임 | 최대 끊김 | 총 소요 | 작업 스레드 |
| --- | --- | --- | --- | --- |
| 기준(작업 없음) | 125개 | 17ms | | |
| A. `await` 없음 | 31개 | 1516ms | 1500ms | 메인 |
| B. `await` 가 맨 뒤 | 31개 | 1519ms | 1500ms | 메인 |
| C. `Task.Yield()` 10조각 | 31개 | 318ms | 1500ms | 메인 |
| D. `Task.Yield()` 100조각 | 64개 | 47ms | **3117ms** | 메인 |
| E. `await Task.Run` | 124개 | 19ms | 1500ms | **워커** |

읽을 것이 세 가지 있습니다.

**A 와 B 가 같습니다.** B 는 `await Task.Delay(1)` 이 들어 있는데도 1519ms 를 통째로 멈췄습니다.
`await` 가 있느냐 없느냐는 아무 상관이 없었습니다.

**A 부터 D 까지 전부 메인 스레드에서 돌았습니다.** 스레드가 바뀐 것은 E 하나뿐입니다.

**D 는 안 멈추는 대신 총 시간이 두 배가 됐습니다.** 1500ms 짜리 일이 3117ms 걸렸습니다.

## 왜 멈추나

`await` 가 스레드를 놓는다는 말은 맞습니다. 다만 **놓는 것이 "기다림" 이지 "일" 이 아닙니다.**

메서드 하나를 세 구간으로 쪼개면 보입니다.

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "1구간", "title": "첫 await 를 만나기 전", "text": "부른 그 스레드가 그대로 메서드 본문을 실행합니다. 여기는 평범한 동기 메서드와 완전히 같습니다. 메인 스레드는 이 구간 내내 잡혀 있습니다." },
    { "tag": "2구간", "title": "await 로 기다리는 동안", "text": "메서드가 끊기고 제어가 부른 쪽으로 돌아갑니다. 이 시간에는 어떤 스레드도 이 일을 하고 있지 않습니다. Task.Delay 의 기다림은 타이머가, 소켓의 기다림은 OS 가 대신하기 때문에 스레드가 필요 없습니다." },
    { "tag": "3구간", "title": "await 뒤를 이어 갈 때", "text": "멈춘 자리를 이어받을 스레드가 필요합니다. 그 스레드가 다시 메인입니다. 그래서 await 뒤에 있는 계산도 똑같이 프레임을 잡아먹습니다." }
  ]
}
</script>
</x-steps>

1구간이 A 와 B 를 설명합니다. **`async` 를 붙였다고 메서드가 다른 데로 가지 않습니다.**
`await` 를 만나기 전까지는 부른 스레드가 한 줄씩 그냥 실행합니다.
무거운 계산을 맨 앞에 두면 그 계산이 끝날 때까지 부른 쪽은 아무것도 못 합니다.
`await` 를 뒤에 붙여봐야 이미 다 끝난 뒤라 양보할 시간이 남아 있지 않습니다.

<x-flow>
<script type="application/json">
{
  "label": "실행이 지나가는 자리",
  "cases": [
    {
      "label": "await 가 일 뒤에 하나",
      "state": "계산 1500ms 통째로",
      "lines": ["async Task 무거운일()", "{", "    무거운계산();          // 1500ms", "    await Task.Delay(1);", "}"],
      "path": [
        { "line": 2, "why": "부른 스레드가 여기를 그냥 실행합니다. 1500ms 동안 돌아가지 않습니다." },
        { "line": 3, "why": "일이 다 끝난 뒤에야 양보합니다. 양보해서 남는 것이 없습니다.", "out": "1516ms 만에 다음 프레임" }
      ],
      "note": "await 는 있지만 위치가 일 뒤입니다."
    },
    {
      "label": "조각마다 양보",
      "state": "계산 15ms 짜리 100조각",
      "lines": ["async Task 무거운일()", "{", "    for (int i = 0; i < 100; i++)", "    {", "        한조각_계산();      // 15ms", "        await Task.Yield();", "    }", "}"],
      "path": [
        { "line": 2, "why": "첫 바퀴입니다." },
        { "line": 4, "why": "15ms 만 태웁니다. 프레임 예산 안입니다." },
        { "line": 5, "why": "여기서 제어가 돌아가고 프레임이 한 번 그려집니다.", "out": "프레임 그려짐" },
        { "line": 2, "why": "다음 프레임에 여기부터 이어집니다." },
        { "line": 4 },
        { "line": 5, "out": "프레임 그려짐" },
        { "line": 2 },
        { "line": 4 },
        { "line": 5, "out": "프레임 그려짐" }
      ],
      "note": "같은 1500ms 인데 100번 나눠서 합니다."
    }
  ]
}
</script>
</x-flow>

## 돌아오는 곳이 다시 메인이다

3구간이 제일 안 보이는 자리입니다. `await` 에서 제어를 놓은 것까지는 맞는데,
**멈춘 자리를 이어받는 스레드가 다시 메인** 이라 그 뒤의 계산도 똑같이 프레임을 먹습니다.

**Unity 가 그렇게 되돌려 줍니다.** `await` 뒤는 항상 메인 스레드에서 이어집니다.
`Transform` 이나 `GameObject` 를 워커에서 건드리면 터지니까 그러라고 있는 장치입니다.

같은 코드를 Unity 에서와 그렇지 않은 곳에서 돌려봤습니다.
계산 300ms, `await Task.Delay(200)`, 계산 600ms 순서입니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "Unity 에서", "code": "[   0ms] [프레임  0] [메인] 메서드 시작\n[ 300ms] [프레임  0] [메인] 계산 끝, await Task.Delay(200)\n[ 511ms] [프레임 13] [메인] await 에서 돌아옴\n[1111ms] [프레임 13] [메인] 그다음 계산 끝", "stats": [["await 뒤가 돈 스레드", "메인"], ["600ms 계산 중 프레임", "13에서 13"], ["1500ms 동안 프레임", "38개"]], "tone": "bad" },
    { "label": "Unity 가 아닌 곳에서", "code": "[   0ms] [프레임  0] [메인] 메서드 시작\n[ 300ms] [프레임  0] [메인] 계산 끝, await Task.Delay(200)\n[ 500ms] [프레임 12] [워커] await 에서 돌아옴\n[1100ms] [프레임 49] [워커] 그다음 계산 끝", "stats": [["await 뒤가 돈 스레드", "워커"], ["600ms 계산 중 프레임", "12에서 49"], ["1500ms 동안 프레임", "75개"]], "tone": "good" }
  ]
}
</script>
</x-compare>

두 로그의 앞 두 줄은 같습니다. 갈리는 것은 세 번째 줄입니다.

Unity 에서는 `await` 뒤가 메인에서 이어지고, 프레임은 13에서 멈춘 채 600ms 를 버팁니다.
그렇지 않은 곳에서는 워커에서 이어지고, 그동안 프레임은 12에서 49까지 계속 굴러갑니다.

> **메인 스레드가 멈추는 것은 `async` 때문이 아닙니다.
> `await` 뒤를 메인 스레드로 친절하게 되돌려주기 때문입니다.**

## Task.Yield 가 하는 일

그래서 `await Task.Yield()` 가 무엇인지도 분명해집니다.
**스레드를 바꾸는 것이 아니라 나머지를 다음 프레임으로 미루는 것입니다.**
일은 여전히 메인 스레드가 합니다. 한 번에 다 하지 않을 뿐입니다.

그러면 남는 문제는 하나뿐입니다. **한 조각이 얼마나 큰가.**

C 는 150ms 짜리 10조각이었습니다. 프레임은 돌았지만 끊김이 318ms 입니다.
6fps 면 사용자 눈에는 그냥 멈춘 것입니다. `await` 를 넣었는데도 결과가 A 와 똑같은 31프레임인 이유입니다.

D 는 15ms 짜리 100조각입니다. 끊김이 47ms 까지 내려갔습니다.
대신 프레임마다 한 조각씩만 처리하니 프레임 수만큼 대기가 끼어들어 총 3117ms 가 걸렸습니다.

<x-calc>
<script type="application/json">
{
  "label": "조각을 몇 개로 나눌까",
  "inputs": [
    { "id": "w", "label": "전체 작업량", "options": [500, 1500, 3000], "default": 1500, "suffix": "ms" },
    { "id": "n", "label": "조각 수", "options": [1, 10, 100, 300], "default": 100, "suffix": "조각" }
  ],
  "bars": [
    { "label": "한 조각이 프레임을 잡는 시간", "expr": "w / n", "unit": "ms", "accent": true },
    { "label": "다 끝나는 데 걸리는 시간", "expr": "w + n * 16", "unit": "ms" }
  ],
  "verdict": { "expr": "bars[0] <= 16", "yes": "프레임 예산 안", "no": "눈에 보이는 끊김" },
  "note": "프레임 16ms, 한 프레임에 한 조각씩 처리한다고 볼 때입니다."
}
</script>
</x-calc>

두 막대가 서로 반대로 움직입니다. 잘게 자를수록 부드러워지고 그만큼 늦게 끝납니다.
**쪼개기는 프리즈를 지연과 맞바꾸는 일입니다.** 공짜로 빨라지는 것이 아닙니다.

## 그래서 기준은 둘뿐이다

| 작업 | 방법 | 왜 |
| --- | --- | --- |
| Unity API 를 안 건드리는 계산 | `await Task.Run(...)` | 진짜로 다른 스레드가 대신합니다. 총 시간도 안 늘어납니다 |
| Unity API 를 만져야 하는 일 | 조각내고 `await Task.Yield()` | 메인에서 할 수밖에 없으니 프레임 예산 안으로 잘라 넣습니다 |
| 파일, 네트워크, DB 대기 | 그냥 `await` | 기다리는 데는 원래 스레드가 안 듭니다. `Task.Run` 으로 감싸면 손해입니다 |

경로 탐색, 직렬화, 노이즈 생성, 압축처럼 순수 계산이면 첫째 줄입니다.
수천 개 오브젝트를 생성하거나 `Transform` 을 건드려야 하면 둘째 줄입니다.

둘째 줄을 고를 때 조각 크기의 기준은 **프레임 예산에서 남는 몫** 입니다.
60fps 목표면 한 프레임이 16.6ms 인데 렌더와 물리와 스크립트가 이미 먹고 있습니다.
남는 것이 4ms 라면 조각도 4ms 여야 합니다. 조각 하나가 프레임 예산을 통째로 쓰면 쪼갠 의미가 없습니다.

## 게임이 멈추는 자리

실제 게임에서 프레임이 멎는 이유는 결국 하나로 모입니다.
**메인 스레드가 한 프레임 안에 안 끝나는 일을 붙잡고 있는 것.** 붙잡는 방식만 다릅니다.

| 붙잡는 방식 | 자리 | 처방 |
| --- | --- | --- |
| 계산이 길다 | 맵 생성, 경로 탐색, 세이브 직렬화, 큰 JSON 파싱 | Unity API 를 안 쓰면 `Task.Run`, 쓰면 조각내기 |
| 한 프레임에 너무 많이 만든다 | `Instantiate` 수백 개, 목록 UI 를 한 번에 채우기 | 프레임당 개수를 정해 나눠 만들기, 오브젝트 풀 |
| 동기 로딩 | `Resources.Load`, `SceneManager.LoadScene`, Addressables 의 `WaitForCompletion` | 비동기 버전으로 바꾸고 `await` |
| 동기 입출력 | `File.ReadAllBytes`, 동기 소켓 읽기 | 비동기 API 로 바꾸고 `await` |
| 블로킹 | `.Result`, `.Wait()` | 쓰지 않습니다. 기다리는 데 메인 스레드를 통째로 붙잡습니다 |
| 할당이 몰린다 | 프레임마다 문자열 조립, 박싱, `new` | 재사용합니다. 수거가 도는 동안 전부 멈춥니다 |

앞의 다섯 줄은 **내가 붙잡은 것**이고 마지막 줄은 **수거가 붙잡은 것**입니다.
원인은 다른데 증상은 같습니다. 프레임 루프가 한 바퀴를 못 돕니다.

여기서 `async` 가 고쳐 주는 것은 **기다리는 줄**뿐입니다. 로딩과 입출력입니다.
**계산하는 줄은 `async` 를 붙여도 그대로입니다.** `Task.Run` 이나 조각내기로만 풀립니다.
이 글에서 잰 A 와 B 가 그 이야기입니다.

한 가지 갈라 둘 것이 있습니다. **에디터에서만 멈추는 것은 따로 봅니다.**
첫 재생 때의 셰이더 컴파일이나 스크립트를 고친 뒤의 도메인 리로드는 빌드에 안 나타납니다.
빌드에서도 멈추는지 먼저 확인하고 원인을 찾는 편이 빠릅니다.

## 직접 재보기

하네스 핵심입니다. 콘솔 프로젝트에 그대로 붙여도 돕니다.

```csharp
// 메인 스레드 흉내: 단일 스레드 컨텍스트 + 프레임 루프
class MainThreadContext : SynchronizationContext
{
    readonly ConcurrentQueue<(SendOrPostCallback cb, object? st)> _q = new();
    public override void Post(SendOrPostCallback d, object? state) => _q.Enqueue((d, state));
    // 한 프레임에는 그 프레임이 시작될 때 쌓여 있던 것만 처리합니다.
    // 처리하다가 새로 들어온 것은 다음 프레임으로 넘어갑니다.
    public void Pump()
    {
        int budget = _q.Count, n = 0;
        while (n < budget && _q.TryDequeue(out var it)) { it.cb(it.st); n++; }
    }
}

static void Burn(int ms)   // Sleep 이 아니라 CPU 를 실제로 태웁니다
{
    var s = Stopwatch.StartNew(); double x = 0;
    while (s.ElapsedMilliseconds < ms) for (int i = 0; i < 10000; i++) x += Math.Sqrt(i);
}

static void Measure(string name, Func<Task> job)
{
    SynchronizationContext.SetSynchronizationContext(Ctx);
    var sw = Stopwatch.StartNew();
    int frames = 0; long worst = 0, last = 0;

    Task t = job();                        // 첫 await 까지는 여기서 동기로 돕니다
    while (sw.ElapsedMilliseconds < 2000)
    {
        Ctx.Pump();                        // 메인으로 돌아온 continuation 처리
        Thread.Sleep(16);                  // 렌더 흉내
        frames++;
        worst = Math.Max(worst, sw.ElapsedMilliseconds - last);
        last = sw.ElapsedMilliseconds;
    }
    Console.WriteLine($"{name} 프레임 {frames}개 최대끊김 {worst}ms");
}
```

`Pump` 의 `budget` 이 중요합니다. 이걸 빼고 큐가 빌 때까지 돌리면
`Task.Yield()` 가 다시 큐에 넣은 것까지 한 프레임 안에서 연달아 처리해버립니다.
그러면 쪼갠 코드도 안 쪼갠 것과 똑같이 멈추고, 측정이 통째로 틀립니다.
Unity 의 PlayerLoop 도 프레임당 한 번 훑는 방식이라 `budget` 쪽이 실제에 가깝습니다.

## 정리

- `async` 를 붙여도 첫 `await` 전까지는 **부른 스레드가 그냥 실행합니다.** 그 구간은 동기 메서드와 같습니다
- `await` 가 놓아주는 것은 **기다리는 시간** 입니다. CPU 연산에는 기다리는 시간이 없어서 놓을 것이 없습니다
- **Unity 는 `await` 뒤를 항상 메인 스레드로 되돌립니다.** 그래서 그 뒤의 계산도 똑같이 프레임을 먹습니다
- 그래서 `await` 의 **위치** 가 전부입니다. 무거운 계산 뒤에 붙은 `await` 는 아무 일도 하지 않습니다
- `Task.Yield()` 는 스레드를 바꾸지 않고 **다음 프레임으로 미룹니다.** 조각이 프레임 예산보다 크면 쪼개도 멈춥니다
- 쪼개면 총 시간이 늘어납니다. 1500ms 짜리 일이 100조각이면 3117ms 입니다
- 계산을 진짜로 치우는 것은 **`Task.Run`** 하나뿐입니다. 다만 Unity API 는 거기서 못 만집니다
- 게임이 멈추는 자리는 결국 하나입니다. **메인이 한 프레임 안에 안 끝나는 일을 붙잡고 있는 것**입니다