반응형

[PART13.비동기와 스레딩 기초(7/15)] 비동기 메서드의 반환형 — Task vs Task<T> vs void

결과가 없으면 Task / 있으면 Task<T> / async void는 이벤트 핸들러 외에는 절대 금지


1. 문제 제기 — "버튼을 누르면 게임이 통째로 죽는다"

Unity에서 데이터를 비동기로 불러오는 메서드를 처음 작성한 신입 개발자는 보통 이런 코드를 짭니다.

C#
public class DataLoader : MonoBehaviour
{
    public async void LoadProfileAsync()  // 무심코 void 선택
    {
        var profile = await FetchFromServer();
        if (profile == null)
            throw new InvalidOperationException("프로필이 없습니다");
        ApplyProfile(profile);
    }
}

// 호출부 — 버튼 클릭 핸들러
public void OnButtonClick()
{
    try
    {
        loader.LoadProfileAsync();  // 예외가 뜨면 잡힐까?
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);  // 절대 실행되지 않습니다
    }
}

FetchFromServer() 가 null을 반환하면 LoadProfileAsync() 안에서 예외가 발생합니다. 그런데 호출부의 try-catch 는 이 예외를 잡지 못합니다. Unity 에디터에서는 콘솔에 빨간 로그가 찍히고 끝나지만, IL2CPP 로 빌드한 모바일 빌드에서는 운이 나쁘면 앱이 그대로 종료됩니다.

같은 메서드를 async void 대신 async Task 로만 바꿔도 이 사고는 완전히 사라집니다.

C#
public async Task LoadProfileAsync()  // void → Task
{
    var profile = await FetchFromServer();
    if (profile == null)
        throw new InvalidOperationException("프로필이 없습니다");
    ApplyProfile(profile);
}

// 호출부
public async void OnButtonClick()  // 이벤트 핸들러는 어쩔 수 없이 void
{
    try
    {
        await loader.LoadProfileAsync();  // await 가 예외를 다시 던져준다
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);  // 정상적으로 잡힌다
    }
}

차이는 단 한 글자(voidTask)지만, 결과는 앱 강제 종료정상 에러 로깅 만큼 다릅니다. 이 글은 왜 이런 차이가 생기는지, 컴파일러가 세 반환형을 IL 레벨에서 어떻게 다르게 처리하는지, 그리고 Unity 모바일 게임에서 어떤 규칙으로 선택해야 하는지 설명합니다.


2. 개념 정의 — 세 반환형의 의미

2.1 비유 — 택배 송장과 영수증

비동기 메서드의 반환형은 호출자에게 돌려주는 "증빙서류" 라고 생각하면 쉽습니다.

반환형 비유 호출자가 할 수 있는 것
Task<T> 상품 교환권 나중에 결과 상품을 받음 + 도착 여부 확인 + 분실 시 환불 청구
Task 택배 송장 번호 도착 여부 확인 + 분실 시 환불 청구 (상품은 없음)
void 아무것도 받지 못함 보냈는지조차 알 수 없음 — 사고 나도 모름

void 가 위험한 이유가 한눈에 보입니다. 호출자가 작업의 운명을 추적할 방법이 없습니다.

2.2 SVG로 보는 세 반환형 비교

async 메서드의 반환형 비교

2.3 기본 코드 — 세 반환형의 가장 단순한 형태

async — 비동기 메서드 표시 키워드 (asynchronous) 메서드 안에서 await 를 사용할 수 있게 해주는 키워드. 컴파일러는 이 키워드가 붙은 메서드를 상태 머신(state machine) 으로 변환한다.
예시: public async Task DoAsync() { await Task.Delay(100); } Task.Delay 가 끝날 때까지 메서드를 일시 중단했다가 재개
C#
public class AsyncReturnTypes
{
    // (1) 결과가 있는 비동기 — Task<T>
    public async Task<int> GetUserScoreAsync(int userId)
    {
        await Task.Delay(100);   // 비동기 작업 (네트워크 호출 가정)
        return 9999;             // T(int) 결과 반환
    }

    // (2) 결과가 없는 비동기 — Task
    public async Task SaveUserScoreAsync(int userId, int score)
    {
        await Task.Delay(100);   // 비동기 작업
        // return 문 없음
    }

    // (3) 이벤트 핸들러용 — async void
    public async void OnSaveButtonClicked(object sender, EventArgs e)
    {
        try
        {
            await SaveUserScoreAsync(1, 9999);
        }
        catch (Exception ex)
        {
            // 이벤트 핸들러 안에서는 반드시 try-catch
            Debug.LogError(ex);
        }
    }
}

세 메서드의 본문 로직은 비슷하지만 반환형만 다릅니다. 이 차이가 호출자에게 무엇을 줄 수 있는지를 결정합니다.

Task<T>Task호출자에게 객체를 돌려주기 때문에 await 로 결과를 받고 예외를 잡고 완료 시점을 알 수 있습니다. async void 는 돌려줄 객체가 아예 없습니다.


3. 내부 동작 — 컴파일러가 만드는 세 가지 빌더

3.1 상태 머신과 메서드 빌더

상태 머신 (state machine) async 메서드는 컴파일 시점에 IAsyncStateMachine 인터페이스를 구현하는 별도의 구조체로 변환된다. 이 구조체에는 <>1__state 필드가 있어서 메서드가 어디까지 실행됐는지 단계를 기록한다.
메서드 빌더 (method builder) 상태 머신과 호출자 사이를 잇는 어댑터. 반환형(Task<T>/Task/void)에 따라 세 종류 중 하나가 자동으로 선택된다. 결과를 호출자에게 전달하고, 예외가 발생하면 적절히 라우팅한다.
컴파일러가 생성하는 상태 머신과 빌더

3.2 IL로 보는 세 빌더 — 결정적 차이

세 가지 반환형을 가진 메서드를 컴파일해서 IL을 비교해보면, 컴파일러가 빌더 타입만 다르고 나머지 구조는 똑같다는 사실이 드러납니다.

Task<T> 버전 IL — AsyncTaskMethodBuilder<int>

C#
public async Task<int> GetValueAsync()
{
    await Task.Delay(10);
    return 42;
}
IL
// 상태 머신 구조체 필드 정의
.field public valuetype System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<int32>
    '<>t__builder'

// MoveNext() 정상 종료 — 결과를 Task<int> 에 저장
IL_008a: call instance void
    AsyncTaskMethodBuilder`1<int32>::SetResult(!0)   // 42를 Task에 박음

// MoveNext() catch 블록 — 예외를 Task<int> 에 저장
IL_0075: call instance void
    AsyncTaskMethodBuilder`1<int32>::SetException(class System.Exception)

Task 버전 IL — AsyncTaskMethodBuilder (제네릭 아님)

C#
public async Task DoWorkAsync()
{
    await Task.Delay(10);
}
IL
// 상태 머신 구조체 필드 정의
.field public valuetype System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    '<>t__builder'

// MoveNext() 정상 종료 — 결과 없이 Task 만 완료
IL_008a: call instance void
    AsyncTaskMethodBuilder::SetResult()              // 인자 없음

// MoveNext() catch 블록 — 예외를 Task 에 저장
IL_0075: call instance void
    AsyncTaskMethodBuilder::SetException(class System.Exception)

void 버전 IL — AsyncVoidMethodBuilder (위험)

C#
public async void FireAndForgetAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("async void에서 발생한 예외");
}
IL
// 상태 머신 구조체 필드 정의 — 빌더 타입만 다르다
.field public valuetype System.Runtime.CompilerServices.AsyncVoidMethodBuilder
    '<>t__builder'

// MoveNext() catch 블록 — Task 가 없으므로 SynchronizationContext 로 직송
IL_007e: call instance void
    AsyncVoidMethodBuilder::SetException(class System.Exception)
//  ↑ 동일한 메서드 이름이지만, 내부 구현이 완전히 다르다.
//  AsyncTaskMethodBuilder.SetException → Task.Exception 에 저장
//  AsyncVoidMethodBuilder.SetException → SynchronizationContext.Post(throw)

3.3 IL 해설 — 같은 호출, 다른 운명

세 IL 모두 마지막에 SetException(e) 를 호출합니다. 메서드 이름은 같지만 동작이 완전히 다릅니다.

빌더 SetException(e) 가 하는 일
AsyncTaskMethodBuilder<T> 반환된 Task<T>Exception 속성에 예외를 저장. 호출자가 await 하면 그 시점에 예외가 다시 던져진다.
AsyncTaskMethodBuilder 반환된 Task 에 예외 저장. 위와 동일.
AsyncVoidMethodBuilder 반환할 Task 가 없다. 메서드 시작 시 캡처해둔 SynchronizationContextPost() 를 호출해 예외를 그 컨텍스트 위에서 다시 던진다.

SynchronizationContext 가 없는 환경(콘솔 앱, 일반 워커 스레드, 일부 Unity 컨텍스트)에서는 ThreadPool 위에서 예외가 던져지고, 이는 잡히지 않는 예외(unhandled exception) 로 취급되어 프로세스를 그대로 종료시킵니다.

핵심: async void 는 예외를 호출자가 잡을 수 있는 경로가 IL 레벨에서부터 존재하지 않습니다.


4. 실전 적용 — 반환형 선택 규칙

4.1 결정 트리

반환형 선택 결정 트리

4.2 Before/After — Unity 모바일 게임 시나리오

시나리오 1: 게임 데이터 저장 메서드 — 결과 없음

❌ Before — async void 로 작성

C#
public class SaveSystem : MonoBehaviour
{
    // 잘못된 패턴 — 라이브러리성 메서드를 async void 로 노출
    public async void SaveGameAsync(GameData data)
    {
        var json = JsonUtility.ToJson(data);
        await File.WriteAllTextAsync(savePath, json);
        // 실패 시 예외가 어디로 가는지 호출자가 알 수 없음
    }
}

// 호출부
public class GameManager : MonoBehaviour
{
    private SaveSystem saveSystem;

    public async Task OnLevelClearAsync()
    {
        try
        {
            saveSystem.SaveGameAsync(currentData);   // 즉시 반환 (Task 가 없어 await 불가)
            await ShowVictoryAnimationAsync();        // 저장 완료 전에 진행
            // 저장이 끝났는지 호출자가 확인할 방법이 없다
        }
        catch (IOException e)
        {
            Debug.LogError("저장 실패");  // 잡히지 않는다
        }
    }
}

문제 두 가지: (1) 호출자가 저장 완료를 기다릴 수 없어 저장 도중 다른 화면으로 넘어가면 데이터 일부가 손상될 수 있고, (2) IOException 이 발생하면 try-catch 를 무시하고 프로세스가 종료됩니다.

✅ After — async Task 로 변경

C#
public class SaveSystem : MonoBehaviour
{
    // 올바른 패턴 — 호출자에게 Task 를 돌려준다
    public async Task SaveGameAsync(GameData data)
    {
        var json = JsonUtility.ToJson(data);
        await File.WriteAllTextAsync(savePath, json);
    }
}

public class GameManager : MonoBehaviour
{
    public async Task OnLevelClearAsync()
    {
        try
        {
            await saveSystem.SaveGameAsync(currentData);  // 완료까지 대기
            await ShowVictoryAnimationAsync();             // 저장 후 애니메이션
        }
        catch (IOException e)
        {
            Debug.LogError($"저장 실패: {e.Message}");    // 정상적으로 잡힌다
            ShowSaveFailedDialog();
        }
    }
}

async Task 로 바꾸면 IL 레벨에서 AsyncTaskMethodBuilderTask 를 반환하므로 await 가 예외를 호출자에게 다시 던질 수 있습니다.

시나리오 2: 사용자 점수 조회 — 결과 있음

C#
// ❌ 결과를 멤버 변수로 흘려보내는 패턴 (async void 강요)
public class ScoreLoader : MonoBehaviour
{
    public int LoadedScore;  // 외부 상태에 의존

    public async void LoadScoreAsync(int userId)
    {
        LoadedScore = await FetchFromServer(userId);
        // 호출자는 LoadedScore 가 갱신됐는지 어떻게 알지?
    }
}

// 호출부 — 폴링 + 타임아웃 같은 임시방편 코드 양산
while (scoreLoader.LoadedScore == 0) await Task.Yield();
C#
// ✅ Task<T> 로 결과를 직접 돌려준다
public class ScoreLoader : MonoBehaviour
{
    public async Task<int> LoadScoreAsync(int userId)
    {
        return await FetchFromServer(userId);  // 결과를 Task<int> 에 담아 반환
    }
}

// 호출부 — 한 줄
int score = await scoreLoader.LoadScoreAsync(userId);

판단 기준: 메서드가 값을 만들어낸다면 그것을 멤버 변수가 아니라 반환값으로 흘려보내야 합니다. 그러려면 Task<T> 가 강제됩니다.

4.3 IL 비교 — async void → async Task 변경의 효과

SaveGameAsync 의 빌더 필드 타입만 비교하면 됩니다.

IL
// ❌ async void
.field public valuetype AsyncVoidMethodBuilder '<>t__builder'

// ✅ async Task
.field public valuetype AsyncTaskMethodBuilder '<>t__builder'

소스 코드에서는 키워드 한 글자만 바뀌었지만, IL 에서는 호출자에게 돌려줄 객체의 종류가 통째로 달라집니다. 이 한 글자가 예외를 잡을 수 있느냐 없느냐 를 결정합니다.


5. 함정과 주의사항

5.1 ❌ 함정 1: 라이브러리 메서드를 async void 로 노출

C#
// ❌ 절대 금지 — 라이브러리(공용 모듈) 메서드는 async void 금지
public class NetworkClient
{
    public async void SendRequestAsync(string url)  // 사용자가 await 못 함
    {
        var response = await httpClient.GetAsync(url);
        ProcessResponse(response);
    }
}
C#
// ✅ 라이브러리 메서드는 항상 Task / Task<T>
public class NetworkClient
{
    public async Task<HttpResponseMessage> SendRequestAsync(string url)
    {
        var response = await httpClient.GetAsync(url);
        return response;
    }
}

원칙: 호출자(라이브러리 사용자)에게 비동기 작업의 제어권을 넘겨준다. 호출자는 await, Task.WhenAll, try-catch 중 무엇이든 자기 사정에 맞게 선택할 수 있어야 합니다. async void 는 이 모든 선택지를 박탈합니다.

5.2 ❌ 함정 2: 이벤트 핸들러에서 try-catch 누락

C#
// ❌ 위험 — 예외가 새어 나가면 앱이 죽는다
public class UIController : MonoBehaviour
{
    public async void OnLoadButtonClicked()
    {
        var data = await LoadAsync();  // 예외 발생 가능
        UpdateUI(data);
    }
}
C#
// ✅ 이벤트 핸들러는 메서드 전체를 try-catch 로 감싼다
public class UIController : MonoBehaviour
{
    public async void OnLoadButtonClicked()
    {
        try
        {
            var data = await LoadAsync();
            UpdateUI(data);
        }
        catch (Exception e)
        {
            Debug.LogException(e);
            ShowErrorDialog(e.Message);
        }
    }
}

async void 는 예외를 잡을 외부 경로가 없으므로 메서드 자체에서 모든 예외를 흡수해야 합니다.

5.3 ❌ 함정 3: 단위 테스트가 통과해도 동작 검증이 안 됨

C#
// ❌ async void 메서드는 테스트가 거짓 통과한다
[Test]
public void SaveTest_async_void()
{
    var system = new SaveSystem();
    system.SaveGameAsync_void(testData);   // async void
    // 테스트 러너는 이 줄을 지나면 즉시 다음으로 진행
    // SaveGameAsync 가 끝나기 전에 테스트가 "성공" 으로 종료
    Assert.IsTrue(File.Exists(savePath));  // 파일이 아직 없을 수 있음
}
C#
// ✅ async Task 면 테스트 러너가 완료까지 대기한다
[Test]
public async Task SaveTest_async_Task()
{
    var system = new SaveSystem();
    await system.SaveGameAsync(testData);  // 완료까지 대기
    Assert.IsTrue(File.Exists(savePath));   // 파일이 반드시 존재
}

테스트가 거짓 통과(false positive)한다는 사실 자체가 위험합니다. 통과한 테스트가 실제로 동작을 보장하지 않기 때문입니다.

5.4 ❌ 함정 4: Unity 라이프사이클에서의 async void

C#
// ⚠️ 주의 — Start() 같은 단발성 라이프사이클 메서드만 허용. Update() 는 금지
public class BadUsage : MonoBehaviour
{
    private async void Update()  // ❌ 매 프레임 호출 + 비동기 = 폭주
    {
        await LoadDataAsync();   // 60 FPS 면 초당 60번 새로운 비동기 작업 시작
        UpdateUI();
    }

    private async void Start()   // ✅ 한 번만 호출되므로 OK (이벤트 핸들러 성격)
    {
        try
        {
            await InitializeAsync();
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
}

Start(), OnEnable(), OnDisable() 처럼 한 번만 호출되는 라이프사이클 메서드는 async void 가 가능하지만, Update() 는 매 프레임 비동기 작업이 누적되므로 절대 사용하지 않습니다. 이 경우 Coroutine 이나 UniTask.Forget() 패턴을 사용합니다.

5.5 Unity 실전 추가 함정 — destroyCancellationToken 미연결

C#
// ❌ GameObject 가 파괴된 뒤에 await 가 끝나면 MissingReferenceException
public async Task LoadAndApplyAsync()
{
    var data = await FetchAsync();         // 5초 걸림
    // 그동안 사용자가 씬을 바꿔서 이 GameObject 가 파괴됨
    transform.position = data.spawnPoint;  // 💥 MissingReferenceException
}
C#
// ✅ destroyCancellationToken (Unity 2022.2+) 으로 안전하게 취소
public async Task LoadAndApplyAsync()
{
    var data = await FetchAsync(destroyCancellationToken);
    transform.position = data.spawnPoint;  // 파괴되면 여기 도달하지 않음
}

이 함정은 반환형과 직접 관련되진 않지만, async Task 를 쓰기 시작하면 반드시 동반되는 패턴이라 함께 익혀둡니다.


6. C# 버전별 변화

6.1 C# 5.0 (2012) — async/await 도입과 세 반환형

C# 5.0 이 async/await 와 함께 Task, Task<T>, void 반환형을 모두 도입했습니다. 이 시점부터 컴파일러는 메서드 빌더 세 종류(AsyncTaskMethodBuilder<T>, AsyncTaskMethodBuilder, AsyncVoidMethodBuilder) 를 자동 선택했습니다.

6.2 C# 7.0 (2017) — ValueTask / ValueTask<T> 추가

C#
// C# 7.0+ — 결과가 동기적으로 즉시 준비되는 경우 GC 할당 회피
public async ValueTask<int> GetCachedScoreAsync(int userId)
{
    if (cache.TryGetValue(userId, out var score))
        return score;  // Task 객체 할당 없이 즉시 반환

    return await FetchFromServer(userId);
}

Task<T> 는 참조 타입(class) 이라 매번 힙에 할당되지만, ValueTask<T> 는 구조체(struct) 라 동기 완료 경로에서 할당이 0입니다. 자주 호출되는 핫패스에서 결과가 캐시될 가능성이 높을 때만 도입합니다(잘못 쓰면 더 느려질 수 있음).

6.3 C# 7.0 — Custom Async Method Builder

C#
// AsyncMethodBuilderAttribute 로 사용자 정의 빌더 지정 가능
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public struct MyTask { /* ... */ }

이 기능 덕분에 Unity 생태계의 UniTask 가 등장했습니다. UniTask 는 자체 빌더를 사용해 GC 할당 없는 async UniTaskasync UniTask<T> 를 제공합니다.

6.4 UniTask — Unity 모바일 실전 표준

C#
// ✅ Unity 모바일 게임에서 사실상의 표준
public class GameLoader : MonoBehaviour
{
    public async UniTask<UserData> LoadUserAsync()  // Task<T> 대신
    {
        var data = await UnityWebRequest.Get(url).SendWebRequest();
        return Parse(data);
    }

    // async UniTaskVoid: async void 의 안전한 대체재
    public async UniTaskVoid OnButtonClicked()
    {
        try
        {
            var user = await LoadUserAsync();
            ApplyUI(user);
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
}

UniTask / UniTask<T> 는 구조체 기반이라 0 GC 할당이고, UniTaskVoidasync void 의 예외 라우팅 문제를 해결합니다. Unity 모바일에서는 Task<T> 보다 UniTask<T> 를 사용하는 것이 권장됩니다(추정 — 프로젝트마다 도입 여부 판단 필요).


7. 정리

세 반환형의 본질은 호출자에게 무엇을 돌려줄 것인가 입니다.

반환형 언제 쓰는가 호출자 능력 IL 빌더
async Task<T> 결과가 있는 비동기 메서드 (대부분의 경우) 결과 받음 + 완료 대기 + 예외 catch AsyncTaskMethodBuilder<T>
async Task 결과가 없는 비동기 메서드 (저장·전송 등) 완료 대기 + 예외 catch AsyncTaskMethodBuilder
async void 이벤트 핸들러 전용 아무것도 못 함 AsyncVoidMethodBuilder

기억해야 할 5가지

  • 결과가 있으면 Task<T>, 없으면 Task. 일반 메서드의 기본 선택지는 이 둘 중 하나다.
  • async void 는 이벤트 핸들러 전용. 라이브러리·공용 모듈·테스트 가능한 코드에서는 절대 쓰지 않는다.
  • async void 의 예외는 호출자가 잡을 수 없다. IL 레벨에서 AsyncVoidMethodBuilder.SetExceptionSynchronizationContext 로 직송하기 때문이다.
  • 이벤트 핸들러는 메서드 전체를 try-catch 로 감싼다. async void 의 유일한 안전망은 자기 자신뿐이다.
  • 단위 테스트는 async Task 에서만 신뢰할 수 있다. async void 는 테스트가 거짓 통과한다.

Unity 실전 체크리스트

  • [ ] 일반 메서드의 반환형은 Task 또는 Task<T> 인가?
  • [ ] async voidOnButtonClicked 같은 이벤트 핸들러에만 있는가?
  • [ ] 모든 async void 메서드는 메서드 전체가 try-catch 로 감싸져 있는가?
  • [ ] Update()async void 가 들어가지 않았는가?
  • [ ] 모바일 핫패스라면 Task<T> 대신 UniTask<T> 도입을 검토했는가?
  • [ ] await 후에도 GameObject 가 살아있다는 보장이 필요한 곳에 destroyCancellationToken 을 연결했는가?
반응형

+ Recent posts