반응형

[PART10.예외 처리 기본(7/9)] await using + IAsyncDisposable — 비동기 정리

닫기 자체가 I/O인 리소스를 안전하게 풀어내는 C# 8의 정답 / 동기 Dispose()가 만드는 데드락 / ValueTask 반환의 의미 / Unity 2021.2+에서의 사용법


1. 문제 제기 — 닫는 데에 시간이 걸리는 리소스

Unity 모바일 게임에서 서버에 매치 결과를 전송한 뒤 응답 스트림을 처리하는 코드를 가정해 봅니다. 응답을 다 읽고 나면 네트워크 스트림을 닫아야 하는데, "닫는다"는 동작이 단순히 메모리에서 객체를 지우는 일이 아닙니다. 남은 패킷을 디스크 또는 소켓에 비우고(flush), TCP FIN 패킷을 보내고, 커넥션 풀에 반환할 수 있다면 반환합니다. 이 모든 과정이 네트워크 I/O입니다.

그런데 기존 IDisposable.Dispose()는 동기 메서드입니다. 비동기 메서드 안에서 using으로 호출하면, "닫기"가 끝날 때까지 스레드를 통째로 막아 두고 기다립니다. 이걸 sync-over-async 안티패턴이라고 부릅니다. 결과는 두 가지 중 하나입니다.

  • 운영체제 스레드가 I/O 완료까지 잠들어 스레드 풀이 고갈되거나,
  • ASP.NET Classic 같이 동기화 컨텍스트가 한 스레드에 묶인 환경에서는 데드락이 발생합니다.

이 문제를 해결하려고 C# 8에서 IAsyncDisposable 인터페이스와 await using 문이 도입되었습니다. "닫는 작업도 비동기로 await 하라"가 핵심입니다.

동기 using (문제)

2. 개념 정의 — IAsyncDisposableawait using

비유: 식당 자리 비우기

식당에서 식사를 마치고 자리를 정리한다고 합시다. IDisposable"자리에 앉아서 직원이 청소를 다 끝낼 때까지 기다리는 손님"입니다. 청소가 짧으면 괜찮지만, 청소에 5분이 걸리는 자리라면 손님 한 명이 5분 동안 자리를 차지하고 다른 일을 못 합니다.

IAsyncDisposable"자리는 비우고 청소가 끝나면 알람을 받겠다"는 손님입니다. 손님(스레드)은 다른 일을 할 수 있고, 청소가 끝나면 그 결과만 확인하면 됩니다.

인터페이스 정의

async — 비동기 메서드 키워드 (Asynchronous) 메서드가 await을 만나면 호출한 스레드에 제어를 돌려주고, 결과가 준비되면 이어서 실행하도록 컴파일러가 상태 머신을 생성한다.
예시: public async ValueTask DisposeAsync() { await SomeIoAsync(); } SomeIoAsync 완료 시점에 메서드가 재개됨
await — 비동기 결과 대기 연산자 비동기 작업이 끝날 때까지 기다리되 스레드를 차단하지 않는다. 작업이 이미 완료된 상태면 동기적으로 즉시 진행한다.
예시: await stream.DisposeAsync(); 스트림 정리가 끝날 때까지 비동기로 대기

C#의 IAsyncDisposable은 단 하나의 메서드만 가집니다.

IDisposable
C#
namespace System;

public interface IAsyncDisposable
{
    ValueTask DisposeAsync();
}

Dispose()void를 반환하는 반면 DisposeAsync()ValueTask를 반환합니다. Task가 아닌 ValueTask인 이유는 잠시 뒤 [내부 동작]에서 다룹니다.

사용법

C#
using System;
using System.Threading.Tasks;

public sealed class NetworkChannel : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        // 닫기 자체가 비동기 I/O
        await Task.Yield();
        Console.WriteLine("채널을 비동기로 정리했습니다.");
    }
}

public static class Program
{
    public static async Task SendMatchResultAsync()
    {
        // 'await using' — 블록을 벗어날 때 await DisposeAsync()
        await using var ch = new NetworkChannel();

        // 본문: ch 사용
        Console.WriteLine("매치 결과 전송 중...");
    }
}

SendMatchResultAsyncasync Task로 선언되었기 때문에 await using을 사용할 수 있습니다. ch가 스코프를 벗어날 때 컴파일러가 자동으로 await ch.DisposeAsync();를 끼워 넣습니다.

위 코드를 컴파일해서 IL을 확인하면 IAsyncDisposable.DisposeAsync()ValueTask를 반환하는 모습을 직접 볼 수 있습니다.

IL
.method public final hidebysig newslot virtual 
    instance valuetype [System.Runtime]System.Threading.Tasks.ValueTask DisposeAsync () cil managed 
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.AsyncStateMachineAttribute::.ctor(class [System.Runtime]System.Type)
    // 비동기 상태 머신 생성, ValueTask 반환
    IL_0002: call valuetype [...]AsyncValueTaskMethodBuilder::Create()
    IL_001d: call instance void [...]AsyncValueTaskMethodBuilder::Start<...>(!!0&)
    IL_0029: call instance valuetype [...]ValueTask [...]AsyncValueTaskMethodBuilder::get_Task()
    IL_002e: ret
}

핵심은 AsyncValueTaskMethodBuilder입니다. Task 기반 async 메서드는 AsyncTaskMethodBuilder를 쓰지만, ValueTask 반환 메서드는 AsyncValueTaskMethodBuilder를 사용합니다. 이 빌더는 동기적으로 즉시 완료되는 경우 힙 할당을 하지 않습니다.


3. 내부 동작 — 컴파일러는 await using을 어떻게 풀어내는가

await using은 새로운 IL 명령어가 아닙니다. 컴파일러가 기존의 try/finally + 비동기 상태 머신으로 풀어내는 신택스 슈가입니다. 그 변환 과정을 따라가 봅시다.

변환 규칙

await using var x = new T();   // T : IAsyncDisposable

finally 안에서 await이 호출된다는 점이 중요합니다. C# 6 이전에는 finallyawait을 넣을 수 없었지만, C# 6에서 허용되었고 C# 8의 await using은 그 메커니즘 위에서 작동합니다.

실제 IL로 확인

await using var r = new AsyncResource(); Console.WriteLine("body"); 한 줄이 어떤 IL로 변환되는지 봅니다. 컴파일러가 만든 상태 머신의 핵심 부분만 잘라내면 다음과 같습니다.

IL
// 1) 리소스 생성
IL_000a: newobj instance void AsyncResource::.ctor()
IL_000f: stloc.1                                  // 로컬 'r'

// 2) try 블록 — 본문 실행
.try {
    IL_001e: ldstr "body"
    IL_0023: call void [System.Console]System.Console::WriteLine(string)
}
catch [System.Object] {                            // 본문에서 던진 예외를 임시 보관
    IL_0034: stfld object Program/'<UsingAsync>d__0'::'<>7__wrap1'
}

// 3) finally 자리에서 DisposeAsync() 호출 + await
IL_003e: ldloc.1
IL_003f: callvirt instance valuetype ValueTask AsyncResource::DisposeAsync()
IL_0044: stloc.s 4                                 // ValueTask 보관
IL_0046: ldloca.s 4
IL_0048: call instance ValueTaskAwaiter ValueTask::GetAwaiter()
IL_004d: stloc.3
IL_004e: ldloca.s 3
IL_0050: call instance bool ValueTaskAwaiter::get_IsCompleted()  // 동기 완료?
IL_0055: brtrue.s IL_0096                          // 완료됐으면 fast-path
IL_0070: call instance void AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<...>(...)
                                                   // 미완료면 콜백 등록 후 leave
IL_0096: ldloca.s 3
IL_0098: call instance void ValueTaskAwaiter::GetResult()        // 예외 재던지기

핵심을 짚어 봅니다.

  • callvirt ... DisposeAsync(): 컴파일러가 자동으로 DisposeAsync 메서드를 호출합니다. 사용자가 Dispose()를 부르는 대신 DisposeAsync()를 부르도록 코드가 다시 짜졌습니다.
  • get_IsCompletedbrtrue.s: ValueTask이미 완료된 상태면 분기로 빠져 동기 경로(fast-path)를 탑니다. 콜백 등록도, 추가 할당도 없습니다.
  • AwaitUnsafeOnCompleted: 미완료 시에만 호출됩니다. 이때 비로소 콜백을 등록하고 메서드는 leave 합니다(스레드 양보).
  • GetResult(): 작업이 완료된 후에 호출되며 예외가 있으면 다시 던집니다. ValueTask를 await하지 않고 버리면 이 호출이 빠지므로 예외가 사라집니다.

내부적으로는 동기 using과 동일한 구조(try/finally)지만, Dispose() 자리에 await DisposeAsync()가 들어간 것입니다.

동기 using과의 IL 비교

같은 패턴을 동기 using으로 쓰면 IL은 한 줄짜리 callvirt Dispose()로 줄어듭니다.

IL
// using var s = new MemoryStream(); Console.WriteLine("body");
.try {
    IL_0006: ldstr "body"
    IL_000b: call void [System.Console]System.Console::WriteLine(string)
    IL_0010: leave.s IL_001c
}
finally {
    IL_0012: ldloc.0
    IL_0013: brfalse.s IL_001b
    IL_0015: ldloc.0
    IL_0016: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
    IL_001b: endfinally
}

차이는 명확합니다.

  동기 using await using
호출 메서드 Dispose() DisposeAsync()
반환 타입 void ValueTask
finally 안에서 즉시 호출 GetAwaiterIsCompleted 분기 → GetResult
미완료 시 해당 사항 없음 콜백 등록 + 메서드 양보
상태 머신 불필요 필수

Task가 아닌 ValueTask인가

DisposeAsync()ValueTask를 반환하는 이유는 성능입니다. 닫기 작업의 상당수는 동기적으로 즉시 완료됩니다. 예를 들어 MemoryStream처럼 비관리 리소스가 없는 경우, 또는 FileStream이 이미 모든 버퍼를 비운 상태인 경우입니다.

  • Task는 참조 타입(클래스)이므로 반환할 때 항상 힙 할당이 발생합니다.
  • ValueTask는 값 타입(struct)이므로 동기 완료 시 힙 할당이 발생하지 않습니다.

게임 루프처럼 초당 수천 번 정리 작업이 일어나는 환경에서는 이 차이가 GC 압박을 결정합니다.


4. 실전 적용 — 언제 어떻게 쓰는가

시나리오 1: 동기 using vs await using (네트워크 스트림)

Before — 동기 using이 만드는 데드락

서버에서 매치 결과 JSON을 받는 코드입니다.

C#
using System.Net.Http;
using System.Threading.Tasks;

public static async Task<string> FetchMatchResultBadAsync(string url)
{
    using var client = new HttpClient();             // ← 동기 using
    using var stream = await client.GetStreamAsync(url);  // ← 동기 using

    using var reader = new System.IO.StreamReader(stream);
    return await reader.ReadToEndAsync();
}

이 코드는 컴파일은 되지만 두 가지 문제가 있습니다.

  1. HttpClient.Dispose()가 내부적으로 핸들러의 동기 정리를 수행합니다. 핸들러가 keep-alive 커넥션을 닫는 동안 호출 스레드가 차단됩니다.
  2. StreamReader.Dispose()Stream.Dispose()를 호출하고, 네트워크 스트림은 그 안에서 동기적으로 소켓을 닫습니다.

ASP.NET Classic이나 일부 UI 컨텍스트에서는 이 동기 차단이 그대로 데드락으로 이어집니다.

After — await using으로 비동기 정리

C#
using System.Net.Http;
using System.Threading.Tasks;

public static async Task<string> FetchMatchResultGoodAsync(string url)
{
    await using var client = new HttpClient();              // .NET 6+부터 IAsyncDisposable
    await using var stream = await client.GetStreamAsync(url);

    using var reader = new System.IO.StreamReader(stream);  // StreamReader는 IDisposable만 구현
    return await reader.ReadToEndAsync();
}

HttpClient는 .NET 6부터, 네트워크 Stream은 .NET Core 3.0부터 IAsyncDisposable을 구현합니다. StreamReader는 여전히 IDisposable만 구현하므로 동기 using을 쓰되, 그 동기 Dispose는 메모리만 정리하므로 안전합니다.

시나리오 2: 비동기 스트림 (IAsyncEnumerable<T>)

await foreach는 내부적으로 IAsyncEnumerator<T>를 사용하고, 이 인터페이스는 IAsyncDisposable을 상속받습니다. 즉, await foreach가 끝나면 컴파일러가 자동으로 await enumerator.DisposeAsync()를 호출합니다.

C#
using System.Collections.Generic;
using System.Threading.Tasks;

public static async IAsyncEnumerable<int> StreamScoresAsync()
{
    for (int i = 0; i < 3; i++)
    {
        await Task.Delay(10);
        yield return i;
    }
}

public static async Task ConsumeAsync()
{
    // await foreach 가 끝나면 자동으로 await enumerator.DisposeAsync()
    await foreach (var score in StreamScoresAsync())
    {
        System.Console.WriteLine(score);
    }
}

이 코드의 IL을 분석하면, foreach 루프 종료 시 컴파일러가 IAsyncEnumerator<int>.DisposeAsync()를 자동으로 호출합니다. yield return 안에 열린 리소스(파일 핸들 등)가 있어도 안전하게 닫힙니다.

시나리오 3: IDisposableIAsyncDisposable 동시 구현

라이브러리를 만든다면 두 인터페이스를 모두 구현해 호출자가 선택할 수 있게 해 주는 것이 정중한 설계입니다. Microsoft 권장 패턴은 다음과 같습니다.

C#
using System;
using System.Threading.Tasks;

public sealed class GameSession : IDisposable, IAsyncDisposable
{
    private bool _disposed;

    // 1) 비동기 정리의 본 작업
    private async ValueTask DisposeAsyncCore()
    {
        if (_disposed) return;
        _disposed = true;

        // 비동기 정리 — 서버에 세션 종료 알림
        await Task.Yield();
    }

    // 2) IAsyncDisposable
    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore();
        Dispose(disposing: false);   // 비관리 리소스만
        GC.SuppressFinalize(this);
    }

    // 3) IDisposable
    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (_disposed) return;
        _disposed = true;
        // 동기 정리 경로
    }
}

호출자는 비동기 컨텍스트에서는 await using을, 동기 컨텍스트에서는 using을 자유롭게 선택할 수 있습니다.

시나리오 4: Unity — UniTask + CancellationTokenSource

Unity 2021.2부터 .NET Standard 2.1과 C# 9가 지원되므로 IAsyncDisposable을 사용할 수 있습니다. 다만 UnityWebRequestIDisposable만 구현하기 때문에 await using을 직접 쓸 수 없습니다. 흔한 우회책은 비동기 작업 자체를 묶어 정리하는 래퍼를 만드는 것입니다.

참고: UniTask는 Cysharp가 만든 Unity용 비동기 라이브러리로, GC 할당이 적어 모바일 환경에서 자주 사용합니다. Task 대신 UniTask를 반환하면 동기 완료 시 힙 할당이 0이 됩니다.
C#
using System;
using System.Threading;
using System.Threading.Tasks;

public sealed class MatchmakingSession : IAsyncDisposable
{
    private readonly CancellationTokenSource _cts = new();

    public CancellationToken Token => _cts.Token;

    public async ValueTask DisposeAsync()
    {
        // 1) 진행 중인 모든 비동기 작업에 취소 신호
        _cts.Cancel();

        // 2) 서버에 매치메이킹 취소 통지 (비동기 I/O)
        await Task.Yield();

        // 3) CancellationTokenSource 자체 정리 (동기)
        _cts.Dispose();
    }
}

// 사용처
public static async Task RunAsync()
{
    await using var session = new MatchmakingSession();
    // session.Token 으로 UniTask 작업들에 취소 토큰 전파
}

await using이 끝나는 순간 Cancel() → 비동기 정리 → Dispose() 순서가 결정적으로 보장됩니다. OnDestroy나 씬 전환에서 토큰 누락으로 작업이 좀비처럼 떠 있는 사고를 줄여 줍니다.


5. 함정과 주의사항

함정 1 — IAsyncDisposable만 구현한 타입을 동기 using으로 쓰면 컴파일 거부

C# 컴파일러는 using 블록의 대상이 IDisposable이어야 한다고 강제합니다.

❌ 잘못된 패턴

C#
using System.Threading.Tasks;

public sealed class AsyncOnly : IAsyncDisposable
{
    public ValueTask DisposeAsync() => default;
}

public static async Task BadAsync()
{
    using var x = new AsyncOnly();   // ❌ 컴파일 에러: CS1674
    await Task.Yield();
}

error CS1674: 'AsyncOnly': 'using' 문에 사용된 형식은 IDisposable로 암시적으로 변환할 수 있어야 합니다. 형태의 에러가 납니다. C# 8 이전에는 CS1674만 떴지만, 최근 컴파일러는 "혹시 await using을 의도하셨나요?" 형태로 안내해 줍니다.

✅ 올바른 패턴

C#
public static async Task GoodAsync()
{
    await using var x = new AsyncOnly();   // ✅ async 컨텍스트
    await Task.Yield();
}

해당 메서드가 async가 아니라면 메서드 자체를 async Task로 바꾸거나, 호출 스택 위에서 비동기로 정리해 줘야 합니다.

함정 2 — ValueTaskawait 없이 버리면 누수

DisposeAsync()가 반환한 ValueTask를 무시하면 두 가지 문제가 동시에 발생합니다.

❌ 잘못된 패턴 — fire-and-forget

C#
public static async Task LeakAsync()
{
    var session = new MatchmakingSession();

    // 사용...

    _ = session.DisposeAsync();   // ❌ ValueTask 버리기
    // 메서드 종료
}

문제 1: DisposeAsync 안에서 던진 예외가 누구에게도 전달되지 않습니다. 문제 2: ValueTaskIValueTaskSource 풀링을 사용하는 구현체에서 await 한 번을 통해 토큰을 풀로 반납합니다. await을 하지 않으면 풀 슬롯이 회수되지 않아 누수가 일어날 수 있습니다.

✅ 올바른 패턴

C#
public static async Task SafeAsync()
{
    await using var session = new MatchmakingSession();
    // 사용...
    // 메서드 종료 시 컴파일러가 await session.DisposeAsync()
}

await using을 쓰면 컴파일러가 알아서 await을 보장합니다. 예외 분실도, 풀 누수도 발생하지 않습니다.

함정 3 — ValueTask를 두 번 await 하지 않기

Task와 달리 ValueTask는 한 번만 await 가능하다는 제약이 있습니다.

❌ 잘못된 패턴

C#
public static async Task DoubleAwaitAsync()
{
    ValueTask vt = SomeAsync();
    await vt;   // ✅ 첫 번째 await
    await vt;   // ❌ 두 번째 await — 동작 정의되지 않음
}

DisposeAsync가 반환한 ValueTask를 변수에 보관해 두 번 await 하는 건 금지입니다. 풀링 구현체에서는 토큰이 이미 다른 작업에 재사용된 뒤일 수 있어 잘못된 결과를 받습니다.

✅ 올바른 패턴

여러 번 처리해야 하면 AsTask()로 변환합니다.

C#
ValueTask vt = SomeAsync();
Task t = vt.AsTask();   // ✅ Task는 여러 번 await 가능
await t;
await t;

함정 4 — 동기 using이 데드락을 일으키는 컨텍스트

UI 스레드(WPF/WinForms) 또는 ASP.NET Classic처럼 단일 스레드 동기화 컨텍스트가 있는 환경에서, 비동기 메서드 안에서 동기 using을 쓰면 Dispose() 안의 동기 I/O가 컨텍스트와 충돌해 데드락을 일으킵니다. Unity의 메인 스레드도 마찬가지로 Dispose()가 메인 스레드를 막으면 한 프레임이 통째로 늘어집니다.

C#
// ❌ Unity 메인 스레드에서 동기 close — 프레임 드랍
public async Task SaveAsync(byte[] data)
{
    using var stream = System.IO.File.OpenWrite("save.dat");  // Dispose가 동기 flush
    await stream.WriteAsync(data);
}   // ← 여기서 Dispose() 동기 flush, 메인 스레드 차단

// ✅ await using — flush도 비동기로
public async Task SaveAsyncGood(byte[] data)
{
    await using var stream = System.IO.File.OpenWrite("save.dat");
    await stream.WriteAsync(data);
}   // ← 여기서 await stream.DisposeAsync(), 메인 스레드 양보

FileStream은 .NET Core 3.0부터 IAsyncDisposable을 구현하며, DisposeAsync()가 비동기 flush를 수행합니다.


6. C# 버전별 변화

C# 7 이전 — 비동기 정리 부재

C# 7까지는 IAsyncDisposable이 BCL에 존재하지 않았습니다. 비동기 정리를 흉내내려면 try/finally 안에서 await을 직접 호출해야 했습니다.

C#
// C# 7 — 수동 try/finally
public async Task LegacyAsync()
{
    var session = new MatchmakingSession();
    try
    {
        // 사용
    }
    finally
    {
        await session.CloseAsync();   // 사용자가 직접 호출
    }
}

문제는 잊어버리기 쉽다는 것과, 라이브러리마다 정리 메서드 이름이 제각각이라는 것이었습니다(CloseAsync, ShutdownAsync, DisposeCoreAsync...).

C# 8 — IAsyncDisposable + await using 도입

IAsyncDisposable 인터페이스가 표준화되었고, await using이 신택스 슈가로 추가되었습니다. .NET Core 3.0과 .NET Standard 2.1이 이 인터페이스를 노출합니다.

C#
// C# 8 — 표준화된 await using
public async Task ModernAsync()
{
    await using var session = new MatchmakingSession();
    // 사용
}   // 컴파일러가 await session.DisposeAsync()

같은 시기에 IAsyncEnumerable<T> + await foreach도 도입되어, 비동기 스트림과 비동기 정리가 한 세트로 묶였습니다.

C# 8 — using 선언 결합

await using은 블록 형태와 선언 형태 둘 다 가능합니다.

C#
// 블록 형태
public async Task BlockFormAsync()
{
    await using (var s = new NetworkChannel())
    {
        // 사용
    }   // 여기서 await s.DisposeAsync()
}

// 선언 형태 (C# 8의 using 선언)
public async Task DeclFormAsync()
{
    await using var s = new NetworkChannel();
    // 사용
}   // 메서드 끝에서 await s.DisposeAsync()

선언 형태가 들여쓰기를 줄여 가독성이 좋아서 보통 권장됩니다. 다만 정리 시점을 메서드 끝이 아닌 특정 지점으로 명시하고 싶다면 블록 형태가 명확합니다.

C# 9 ~ — Unity 지원

Unity 2021.2부터 .NET Standard 2.1이 기본 프로파일이 되어 IAsyncDisposable을 모바일 빌드에서도 사용할 수 있게 되었습니다. 그 이전 Unity 버전(.NET Standard 2.0)은 IAsyncDisposable이 BCL에 없으므로 사용 불가입니다.

Unity 버전 .NET 프로파일 IAsyncDisposable
~2021.1 .NET Standard 2.0 사용 불가
2021.2+ .NET Standard 2.1 사용 가능

7. 정리

이번 글에서 다룬 핵심을 한눈에 정리합니다.

  • [ ] IAsyncDisposable은 닫기 자체가 비동기 I/O인 리소스(네트워크, 파일 flush, DB)를 위해 C# 8에서 도입되었다.
  • [ ] Dispose()void인 반면 DisposeAsync()ValueTask를 반환한다 — 동기 완료 시 힙 할당을 피하기 위함.
  • [ ] await using x;try { ... } finally { await x.DisposeAsync(); }로 컴파일된다.
  • [ ] IAsyncDisposable만 구현한 타입을 동기 using으로 쓰면 CS1674가 발생한다 — await using으로 바꾸거나 두 인터페이스를 모두 구현한다.
  • [ ] DisposeAsync()ValueTaskawait 없이 버리면 예외 분실 + 풀 슬롯 누수가 동시에 일어난다.
  • [ ] ValueTask는 한 번만 await 한다. 여러 번 필요하면 AsTask()로 변환.
  • [ ] Unity에서는 2021.2(.NET Standard 2.1)+에서 사용 가능하며, UnityWebRequest는 여전히 IDisposable만 구현한다.
  • [ ] async 메서드 내부에서는 항상 await using을 우선 검토한다. 메인 스레드 차단 한 줄이 한 프레임 드랍을 만든다.
반응형

+ Recent posts