---
title: "자물쇠가 없어도 데드락은 걸린다"
description: "화면이 얼어붙었는데 예외도 로그도 없습니다. lock 은 한 줄도 없습니다. .Result 한 줄이면 충분합니다."
author: "유니얼"
pubDate: 2026-09-14
category: "programming/csharp/concurrency"
tags: ["C#", "Unity", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-async-deadlock/
---
버튼을 눌렀더니 화면이 얼어붙습니다. 예외도 없고 로그도 없습니다.
코드를 뒤져봐도 `lock` 이 한 줄도 없습니다.

```csharp
void OnClick()
{
    var data = 불러오기().Result;
    화면갱신(data);
}
```

이 한 줄이면 충분합니다. **자물쇠 없이도 데드락이 걸립니다.**

## 멈추는지 재봤다

메인 스레드 하나에 16ms 짜리 프레임 루프를 돌리는 하네스를 만들었습니다.
그 위에서 `불러오기()` 를 여러 방식으로 부르고, **2초 안에 안 끝나면 데드락으로 봅니다.**
`불러오기()` 안은 `await Task.Delay(100); return 42;` 두 줄뿐입니다.

| | 결과 | 큐에 남은 continuation |
| --- | --- | --- |
| A. 그냥 `.Result` | **2초가 지나도 안 끝남** | 1개 |
| B. `.GetAwaiter().GetResult()` | **2초가 지나도 안 끝남** | 1개 |
| C. `Task.Run` 으로 감싼 뒤 `.Result` | 104ms 에 정상 종료 | 0개 |
| D. `ConfigureAwait(false)` + `.Result` | 102ms 에 정상 종료 | 0개 |
| E. 프레임 루프를 돌리면서 `await` | 130ms 에 정상 종료 | 0개 |

오른쪽 칸을 보세요. **일은 이미 끝났습니다.**
이어서 실행될 코드가 메인 스레드 대기열에 얌전히 한 개 들어와 있습니다.
그런데 그 대기열을 비울 수 있는 유일한 스레드가 `.Result` 에서 서 있습니다.

## 네 단계로 보면 빈틈이 없다

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "STEP 1", "title": "메인이 메서드를 부른다", "text": "async 메서드는 첫 await 를 만나기 전까지 부른 스레드가 그냥 실행합니다. await 를 만나면 거기서 끊기고, 아직 안 끝난 Task 를 부른 쪽에 돌려줍니다." },
    { "tag": "STEP 2", "title": "메인이 .Result 에서 선다", "text": "Task 가 아직 안 끝났으니 블로킹입니다. 메인 스레드가 그 줄에서 아무것도 안 하고 서 있습니다. 프레임 루프도 같이 멈춥니다." },
    { "tag": "STEP 3", "title": "기다리던 일이 끝난다", "text": "타이머든 네트워크든 대기가 끝납니다. 이어서 실행할 코드가 메인 스레드 대기열에 들어갑니다. 여기까지는 아무 문제가 없습니다." },
    { "tag": "STEP 4", "title": "꺼내 줄 사람이 없다", "text": "대기열을 비우는 것은 메인 스레드입니다. 그 메인이 STEP 2 에서 서 있습니다. Task 는 영원히 안 끝나고, .Result 도 영원히 안 돌아옵니다." }
  ]
}
</script>
</x-steps>

기다리는 두 당사자를 적어 보면 이렇습니다.

```
메인 스레드     ->  Task 가 끝나기를 기다림       (.Result 에서 서 있음)
이어갈 코드     ->  메인에서 실행될 차례를 기다림   (대기열에 1개)
```

> **데드락은 스레드 둘이 엇갈린 것이 아닙니다.
> 스레드 하나와 차례 하나가 엇갈린 것입니다.**

실제로 이 재현에는 **워커 스레드가 등장하지도 않습니다.**
`Task.Delay` 의 기다림은 타이머가 하니까 스레드를 안 씁니다. 스레드는 메인 하나뿐입니다.
자물쇠 둘을 엇갈리게 잡아야만 데드락이 나는 것이 아닙니다.

## 왜 하필 메인으로 돌아오나

`await` 뒤를 어디서 이어갈지는 환경이 정합니다.
**Unity 는 항상 메인 스레드로 되돌립니다.**
`Transform` 이나 `GameObject` 를 워커에서 건드리면 터지니까 그러라고 있는 장치입니다.

여기에 뒷면이 있습니다. 되돌릴 곳이 메인 하나로 정해져 있으니,
**그 메인이 막혀 있으면 돌아올 자리가 사라집니다.**

고약한 점이 하나 더 있습니다. 되돌리는 장치가 없는 환경에서는 같은 코드가 멀쩡히 돕니다.
콘솔 프로그램이나 테스트에서는 통과하고 에디터에서 누르는 순간 얼어붙습니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": ".Result 로 받는다", "code": "void OnClick()\n{\n    var data = 불러오기().Result;   // 여기서 메인이 선다\n    화면갱신(data);\n}", "stats": [["결과", "영영 안 끝남"], ["대기열에 갇힌 코드", "1개"], ["프레임", "멈춤"]], "tone": "bad" },
    { "label": "await 로 받는다", "code": "async void OnClick()\n{\n    var data = await 불러오기();     // 메인을 놓아준다\n    화면갱신(data);                  // await 뒤라 여기도 메인\n}", "stats": [["결과", "130ms 에 완료"], ["대기열에 갇힌 코드", "0개"], ["프레임", "계속 돈다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

두 코드의 차이는 `.Result` 를 `await` 로 바꾼 것뿐입니다.
`await` 는 메서드를 그 줄에서 빼내고, `.Result` 는 스레드를 붙잡은 채로 세워 둡니다.
**같은 "기다린다" 인데 스레드를 놓느냐 붙잡느냐가 반대입니다.**

## 안 걸리는 방법이 셋인데 쓸 것은 하나다

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "ConfigureAwait(false)", "code": "await 어쩌고().ConfigureAwait(false);\n// 이 아래는 워커에서 이어진다\ntransform.position = p;   // UnityException", "text": "되돌리기를 꺼 버리는 것이라 데드락은 풀립니다. 대신 그 뒤로 Unity API 를 못 만집니다. 데드락을 예외로 바꾼 셈입니다.", "stats": [["데드락", "안 걸림"], ["await 뒤에서 Unity API", "예외"]], "tone": "bad" },
    { "label": "Task.Run 으로 감싸기", "code": "var data = Task.Run(() => 불러오기()).Result;", "text": "스레드풀 스레드는 되돌릴 곳이 없으니 안 걸립니다. 대신 그 스레드를 묶어 두고 메인도 서 있습니다. 둘이 같이 놉니다. 그리고 저 안에서 누가 메인으로 넘기면 다시 걸립니다.", "stats": [["데드락", "안 걸림"], ["묶이는 스레드", "2개"], ["프레임", "여전히 멈춤"]], "tone": "bad" },
    { "label": "await 로 올리기", "code": "async Task 준비하기()\n{\n    var data = await 불러오기();\n    화면갱신(data);\n}", "text": "블로킹을 아예 없앱니다. 메인은 기다리는 동안 프레임을 돌리고, 차례가 되면 대기열에서 꺼내 이어갑니다. await 뒤도 메인이라 Unity API 를 그냥 씁니다.", "stats": [["데드락", "안 걸림"], ["묶이는 스레드", "0개"], ["프레임", "계속 돈다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

앞의 둘은 증상만 옮깁니다. **`async` 로 시작했으면 부르는 쪽까지 `async` 로 올라가는 것이 원칙**이고,
중간에 한 곳이라도 블로킹으로 끊으면 그 지점이 데드락이나 병목이 됩니다.

이벤트 핸들러처럼 반환값을 못 주는 자리는 `async void` 가 불가피합니다.
그때는 안에서 예외를 전부 잡아야 합니다. 밖으로 나가면 아무도 못 받습니다.

```csharp
async void OnClick()
{
    try { var data = await 불러오기(); 화면갱신(data); }
    catch (Exception e) { Debug.LogException(e); }
}
```

## 로딩이 걸리면 더 나쁘다

`UnityWebRequest` 나 `SceneManager.LoadSceneAsync` 같은 `AsyncOperation` 계열은
**완료 통지 자체를 프레임 루프가 합니다.**

```
프레임 N     | 요청을 띄우고 바로 반환. 네트워크 대기는 스레드를 안 쓴다
프레임 N+1   | 프레임 루프가 isDone 을 확인. 아직 아님
   ...       | 대기
프레임 N+40  | 프레임 루프가 isDone 을 확인 -> 완료 이벤트 -> 이어갈 코드를 대기열에
프레임 N+41  | 대기열에서 꺼내 실행
```

앞서 본 `Task.Delay` 는 타이머가 메인과 무관하게 끝을 알려 줍니다.
그래서 메인이 막혀도 **Task 는 완료된 상태**이고, 이어갈 코드만 대기열에 갇힙니다.

`AsyncOperation` 은 다릅니다. 프레임이 안 돌면 **끝났다는 것을 알아차릴 사람조차 없습니다.**
Task 가 완료되지도 않습니다. 로딩 바가 0퍼센트에서 안 움직이는 모양이 이것입니다.

## 자주 걸리는 자리 넷

| 자리 | 무엇을 하다가 | 어떻게 보이나 |
| --- | --- | --- |
| `Awake` · `Start` | `var cfg = 설정받기().Result;` 로 초기화를 기다림 | 첫 씬에서 그대로 멈춤 |
| `OnDestroy` · `OnApplicationQuit` | 정리하려고 `Wait()` | 에디터가 플레이 종료에서 멈춤. 강제 종료해야 함 |
| `SemaphoreSlim` | 잡은 채로 `await` 를 건넜다가 같은 것을 다시 잡음 | 자기 자신을 기다림 |
| 에디터 도메인 리로드 | 취소 토큰 없이 띄운 `Task` 가 안 죽음 | 스크립트 컴파일 후 멈춘 것처럼 보임 |

`lock` 은 컴파일러가 `await` 를 건너뛰는 것을 막아 줍니다.
`SemaphoreSlim` 은 안 막습니다. 그래서 세 번째 줄이 눈에 잘 안 띕니다.

## 멈췄을 때 보는 순서

**하나.** `.Result`, `.Wait()`, `.GetAwaiter().GetResult()` 를 전체 검색합니다.
유니티 프로젝트에서 이 셋은 원칙적으로 0개여야 합니다.

**둘.** 0개인데 멈추면 `SemaphoreSlim.Wait()`, `ManualResetEventSlim`, `Monitor.Wait`, `Thread.Join` 을 봅니다.

**셋.** 그것도 아니면 취소 토큰 없이 띄워 둔 `Task` 가 종료 경로에서 안 죽는 것입니다.

`UniTask` 를 쓰면 이 종류가 구조적으로 덜 생깁니다. 되돌리는 장치를 안 타고 PlayerLoop 에 직접 붙기 때문입니다.
다만 **`GetAwaiter().GetResult()` 는 UniTask 에도 있고 거기서도 똑같이 멈춥니다.**
도구가 바꾸는 것은 기본 동작이지 막아 주는 것이 아닙니다.

## 직접 재보기

"메인 역할" 스레드를 하나 띄우고 `Join` 이 시간 안에 돌아오는지로 판정합니다.

```csharp
class MainThreadContext : SynchronizationContext
{
    readonly ConcurrentQueue<(SendOrPostCallback cb, object? st)> _q = new();
    public int Pending => _q.Count;
    public override void Post(SendOrPostCallback d, object? state) => _q.Enqueue((d, state));
}

static async Task<int> LoadAsync()
{
    await Task.Delay(100);
    return 42;
}

static void Main()
{
    var ctx = new MainThreadContext();
    var th = new Thread(() =>
    {
        SynchronizationContext.SetSynchronizationContext(ctx);
        int v = LoadAsync().Result;          // 여기서 영영 안 나온다
        Console.WriteLine(v);
    });
    th.IsBackground = true;
    th.Start();

    Console.WriteLine(th.Join(2000) ? "정상 종료" : "데드락");
    Console.WriteLine($"대기열에 남은 것 {ctx.Pending}개");
}
```

`Join` 이 `false` 를 돌려주고 남은 것이 1개로 찍히면 그것이 데드락입니다.
`IsBackground = true` 가 없으면 프로그램 자체가 안 끝나니 꼭 넣으세요.

`.Result` 를 `await` 로 바꾸려면 저 스레드 안에서 프레임 루프를 돌려야 합니다.
`while (!t.IsCompleted) { 대기열비우기(); Thread.Sleep(16); }` 한 줄이면 130ms 에 끝납니다.
Unity 가 매 프레임 하는 일이 바로 이것입니다.

## 정리

- `.Result` 와 `.Wait()` 는 기다리는 것이 아니라 **막는 것**입니다. 스레드를 붙잡은 채로 세워 둡니다
- 메인에서 부르면 이어갈 코드가 대기열에 갇히고, 그 대기열을 비울 스레드가 서 있어서 **영원히 안 풀립니다**
- 이 데드락에는 **워커 스레드도 자물쇠도 없어도 됩니다.** 스레드 하나와 차례 하나면 충분합니다
- Unity 가 `await` 뒤를 메인으로 되돌리기 때문에 생깁니다. 되돌리지 않는 환경에서는 같은 코드가 멀쩡히 돕니다
- `ConfigureAwait(false)` 는 데드락을 **예외로 바꿉니다.** `Task.Run` 으로 감싸면 스레드 둘이 같이 놉니다
- `AsyncOperation` 은 완료 통지도 프레임 루프가 해서 **Task 가 완료되지도 않습니다**
- 답은 하나입니다. 블로킹을 지우고 부르는 쪽까지 `await` 로 올립니다