[PART13.비동기와 스레딩 기초(7/15)] 비동기 메서드의 반환형 — Task vs Task<T> vs void
결과가 없으면 Task / 있으면 Task<T> / async void는 이벤트 핸들러 외에는 절대 금지
목차
1. 문제 제기 — "버튼을 누르면 게임이 통째로 죽는다"
Unity에서 데이터를 비동기로 불러오는 메서드를 처음 작성한 신입 개발자는 보통 이런 코드를 짭니다.
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 로만 바꿔도 이 사고는 완전히 사라집니다.
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); // 정상적으로 잡힌다
}
}
차이는 단 한 글자(void → Task)지만, 결과는 앱 강제 종료 와 정상 에러 로깅 만큼 다릅니다. 이 글은 왜 이런 차이가 생기는지, 컴파일러가 세 반환형을 IL 레벨에서 어떻게 다르게 처리하는지, 그리고 Unity 모바일 게임에서 어떤 규칙으로 선택해야 하는지 설명합니다.
2. 개념 정의 — 세 반환형의 의미
2.1 비유 — 택배 송장과 영수증
비동기 메서드의 반환형은 호출자에게 돌려주는 "증빙서류" 라고 생각하면 쉽습니다.
| 반환형 | 비유 | 호출자가 할 수 있는 것 |
|---|---|---|
Task<T> |
상품 교환권 | 나중에 결과 상품을 받음 + 도착 여부 확인 + 분실 시 환불 청구 |
Task |
택배 송장 번호 | 도착 여부 확인 + 분실 시 환불 청구 (상품은 없음) |
void |
아무것도 받지 못함 | 보냈는지조차 알 수 없음 — 사고 나도 모름 |
void 가 위험한 이유가 한눈에 보입니다. 호출자가 작업의 운명을 추적할 방법이 없습니다.
2.2 SVG로 보는 세 반환형 비교

2.3 기본 코드 — 세 반환형의 가장 단순한 형태
async— 비동기 메서드 표시 키워드 (asynchronous) 메서드 안에서await를 사용할 수 있게 해주는 키워드. 컴파일러는 이 키워드가 붙은 메서드를 상태 머신(state machine) 으로 변환한다.
예시:public async Task DoAsync() { await Task.Delay(100); }Task.Delay 가 끝날 때까지 메서드를 일시 중단했다가 재개
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>
public async Task<int> GetValueAsync()
{
await Task.Delay(10);
return 42;
}
// 상태 머신 구조체 필드 정의
.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 (제네릭 아님)
public async Task DoWorkAsync()
{
await Task.Delay(10);
}
// 상태 머신 구조체 필드 정의
.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 (위험)
public async void FireAndForgetAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("async void에서 발생한 예외");
}
// 상태 머신 구조체 필드 정의 — 빌더 타입만 다르다
.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 가 없다. 메서드 시작 시 캡처해둔 SynchronizationContext 의 Post() 를 호출해 예외를 그 컨텍스트 위에서 다시 던진다. |
SynchronizationContext 가 없는 환경(콘솔 앱, 일반 워커 스레드, 일부 Unity 컨텍스트)에서는 ThreadPool 위에서 예외가 던져지고, 이는 잡히지 않는 예외(unhandled exception) 로 취급되어 프로세스를 그대로 종료시킵니다.
핵심: async void 는 예외를 호출자가 잡을 수 있는 경로가 IL 레벨에서부터 존재하지 않습니다.
4. 실전 적용 — 반환형 선택 규칙
4.1 결정 트리

4.2 Before/After — Unity 모바일 게임 시나리오
시나리오 1: 게임 데이터 저장 메서드 — 결과 없음
❌ Before — async void 로 작성
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 로 변경
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 레벨에서 AsyncTaskMethodBuilder 가 Task 를 반환하므로 await 가 예외를 호출자에게 다시 던질 수 있습니다.
시나리오 2: 사용자 점수 조회 — 결과 있음
// ❌ 결과를 멤버 변수로 흘려보내는 패턴 (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();
// ✅ 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 의 빌더 필드 타입만 비교하면 됩니다.
// ❌ async void
.field public valuetype AsyncVoidMethodBuilder '<>t__builder'
// ✅ async Task
.field public valuetype AsyncTaskMethodBuilder '<>t__builder'
소스 코드에서는 키워드 한 글자만 바뀌었지만, IL 에서는 호출자에게 돌려줄 객체의 종류가 통째로 달라집니다. 이 한 글자가 예외를 잡을 수 있느냐 없느냐 를 결정합니다.
5. 함정과 주의사항
5.1 ❌ 함정 1: 라이브러리 메서드를 async void 로 노출
// ❌ 절대 금지 — 라이브러리(공용 모듈) 메서드는 async void 금지
public class NetworkClient
{
public async void SendRequestAsync(string url) // 사용자가 await 못 함
{
var response = await httpClient.GetAsync(url);
ProcessResponse(response);
}
}
// ✅ 라이브러리 메서드는 항상 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 누락
// ❌ 위험 — 예외가 새어 나가면 앱이 죽는다
public class UIController : MonoBehaviour
{
public async void OnLoadButtonClicked()
{
var data = await LoadAsync(); // 예외 발생 가능
UpdateUI(data);
}
}
// ✅ 이벤트 핸들러는 메서드 전체를 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: 단위 테스트가 통과해도 동작 검증이 안 됨
// ❌ async void 메서드는 테스트가 거짓 통과한다
[Test]
public void SaveTest_async_void()
{
var system = new SaveSystem();
system.SaveGameAsync_void(testData); // async void
// 테스트 러너는 이 줄을 지나면 즉시 다음으로 진행
// SaveGameAsync 가 끝나기 전에 테스트가 "성공" 으로 종료
Assert.IsTrue(File.Exists(savePath)); // 파일이 아직 없을 수 있음
}
// ✅ 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
// ⚠️ 주의 — 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 미연결
// ❌ GameObject 가 파괴된 뒤에 await 가 끝나면 MissingReferenceException
public async Task LoadAndApplyAsync()
{
var data = await FetchAsync(); // 5초 걸림
// 그동안 사용자가 씬을 바꿔서 이 GameObject 가 파괴됨
transform.position = data.spawnPoint; // 💥 MissingReferenceException
}
// ✅ 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# 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
// AsyncMethodBuilderAttribute 로 사용자 정의 빌더 지정 가능
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public struct MyTask { /* ... */ }
이 기능 덕분에 Unity 생태계의 UniTask 가 등장했습니다. UniTask 는 자체 빌더를 사용해 GC 할당 없는 async UniTask 와 async UniTask<T> 를 제공합니다.
6.4 UniTask — Unity 모바일 실전 표준
// ✅ 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 할당이고, UniTaskVoid 는 async 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.SetException이SynchronizationContext로 직송하기 때문이다.- 이벤트 핸들러는 메서드 전체를
try-catch로 감싼다.async void의 유일한 안전망은 자기 자신뿐이다. - 단위 테스트는
async Task에서만 신뢰할 수 있다.async void는 테스트가 거짓 통과한다.
Unity 실전 체크리스트
- [ ] 일반 메서드의 반환형은
Task또는Task<T>인가? - [ ]
async void는OnButtonClicked같은 이벤트 핸들러에만 있는가? - [ ] 모든
async void메서드는 메서드 전체가try-catch로 감싸져 있는가? - [ ]
Update()에async void가 들어가지 않았는가? - [ ] 모바일 핫패스라면
Task<T>대신UniTask<T>도입을 검토했는가? - [ ]
await후에도 GameObject 가 살아있다는 보장이 필요한 곳에destroyCancellationToken을 연결했는가?
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(9/15)] 취소 — CancellationToken · CancellationTokenSource (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny (1) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(5/15)] Task와 Task<T> — "언젠가 끝날 작업" 객체 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(4/15)] ThreadPool — 스레드를 재사용하는 공용 풀 (0) | 2026.05.08 |