| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- unity6
- 김진홍 옮김
- 게임 수학
- 생능출판
- JavaScript
- The Elements of Computing Systems 2/E
- HANBIT Academy
- 입출력과 사칙연산
- 잡생각 정리글
- C++
- C
- 주우석
- (주)책만
- booksr.co.kr
- 밑바닥부터 만드는 컴퓨팅 시스템 2판
- Shimon Schocken
- BOJ
- hanbit.co.kr
- 일기
- 이득우의 게임수학
- https://insightbook.co.kr/
- 데이터 통신과 컴퓨터 네트워크
- 이득우
- 메타버스
- 박기현
- Noam Nisan
- 전공자를 위한 C언어 프로그래밍
- 백준
- 알고리즘
- C#
- Today
- Total
cyphen156
C# 병렬성과 비동기 Async와 Await에 대한 고찰 본문
사실 이 글을 C# 언어 카테고리에 둘지, 아니면 유니티 엔진 카테고리에 둘지에 대해 고민을 좀 했다.
이유는 같은 비동기(async/await) 문법을 쓰더라도
유니티 엔진에서는 자체적으로 언어 차원의 제약을 넘어서 자체적으로 비동기 처리를 핸들링하는 방법이 따로 있기 때문이다.
대표적으로 Coroutine, AsyncOperation, Addressables이 있다.
하지만 본질적으로 비동기 문법 자체는 언어 레벨에서 정의된 개념이다.
Unity는 그것을 엔진 구조에 맞게 커스터마이징한 구현체일 뿐이다.
그래서 이 글은 Unity 기술 문서라기보다는, 언어 차원에서 ‘동기와 비동기’가 무엇이고 어떻게 작동하는가를 고찰하는 쪽에 가깝다.
글을 쓰게 된 계기
Unity에서 실제 외부 네트워크를 고려해서 Addressables 기반 리소스 로딩 시스템을 재설계하던 중, 문득 이런 생각이 들었다.
“로딩 UI는 코루틴으로 만들어놨으니까, 리소스 로드는 동기(WaitForCompletion)로 처리해도 되지 않을까?”
그런데 5분쯤 다시 생각해보니,
“아니다. 그렇게 하면 안 된다.”
물론 지금 설계 중인 시스템은 씬 단위 로드(Catalog/Label)이기 때문에 “모든 리소스가 로드될 때까지 씬 변경은 대기해야 한다”는 점은 맞다.
하지만 리소스를 동기 하면,
이 리소스 로드 함수는 업데이트 루프 바깥의 단일 호출 함수이기 때문에
“로딩 UI를 코루틴으로 구성해놨더라도 프로그램이 멈추게 된다.”
그 이유는 간단했다.
코루틴이 실제 병렬 처리를 하는 게 아니라, 논리적 병렬성만 흉내내는 구조이기 때문이다.
코루틴은 병렬이 아니다
Unity의 코루틴은 메인 스레드의 Update 루프 이후,
같은 프레임 내에서 엔진의 코루틴 스케줄러에 의해 한 번 실행되며, 마치 병렬처럼 번갈아 동작하는 것처럼 보이게 만들어준다.
즉, “진짜 병렬성”이 아니라 “프레임 단위 분할 실행”일 뿐이다.
핵심은 바로 그 “처럼”이다.
잠시 왔다 가는 것일 뿐이지, 실제로 동시에 실행되는 것이 아니다.
리소스 동기 로드를 하면 생기는 일
그런데 내가 설계했던 리소스 로드는, 코루틴이 아닌 순차 함수 호출이었다.
즉, 업데이트 루프조차 아닌 곳에서
모든 리소스를 한 번에 동기 로드하면,
프레임이 완전히 멈추게 된다.
그리고 프레임이 멈춘다는 것은 곧
로딩 UI 코루틴 또한 멈춘다는 뜻이다.
문제의 본질
이건 단순히 Unity의 문제를 넘어서,
“동기와 비동기 작업의 본질” 에 대한 질문이었다.
우리가 프로그램을 만들다 보면 누구나 한 번쯤 고민한다.
바로 “무거운 작업을 어떻게 처리할 것인가”이다.
예를 들어, 사용자가 버튼을 눌렀을 때
내부에서 대용량 데이터를 읽거나, 외부 서버로부터 파일을 받아와야 한다면
프로그램은 그 시간 동안만 잠깐 “멈춘”다.
사용자 입장에서는 아무 입력도 안 먹히고, 화면도 갱신되지 않으니 프로그램에 문제가 있다고 생각하게 된다.
“프로그램이 멈췄다”고 느끼게 된다.
이 문제를 해결하기 위해 등장한 방법이 바로 한 흐름 안에서 멈추지 않고 여러 일을 번갈아 처리하는 방식(비동기)과 여러 흐름을 실제로 동시에 처리하는 방식(멀티 스레드와 멀티 태스크)이다.
스레드와 태스크
햄버거 가게를 예시로 들어보자.
만약 사장 혼자 일하고 있다면 4명의 손님이 왔을 때
주문을 사장 혼자 받고, 주문에 따른 햄버거도 혼자서 모두 만들어야 하기 때문에
손님들은 상당히 오래 기다려야 한다.
매장 청소는 덤이다.
스레드는 하나의 논리적인 흐름이다.
가령 햄버거 집에서 알바를 4명 고용해 두명은 카운터를 맡고, 한명은 햄버거를 만들고, 한명은 매장 청소를 한다면 각 알바 한명 한명이 하나의 스레드를 형성했다고 볼 수 있다.
태스크는 스레드에서 파생되는 처리해야하는 작업의 단위이다.
가령 카운터를 보는 알바 두 명이 손님(각 2명)들에게 주문을 받고 있고, 햄버거 만드는 알바가 이 주문에 따른 햄버거를 만들어 낸다면 태스크는 (2, 2, 4, 0)개 할당되었다고 볼 수 있다.
조금 억지스러워 보일 수도 있지만,
결과적으로 손님 입장에서는 훨씬 빠르게 햄버거를 받을 수 있게 되었다.
즉, 프로그램에 논리적 다중 흐름(비동기) 과 물리적 다중 흐름(병렬)을 부여함으로서 “주 흐름(Main Thread)이 멈춰 있는 동안 다른 흐름(Sub Thread)을 함께 돌려서, 사용자에게 ‘끊김 없이 동작하는 것처럼’ 보이게 하자”는 발상이다.
이러한 다중 흐름을 제어하는 철학적 방식이 바로 동기(Synchronous) 와 비동기(Asynchronous) 처리 모델이다.
동기(Synchronous)와 비동기(Asynchronous)
이렇게 여러 흐름이 존재하는 환경에서 이들을 어떻게 관리하고 제어할 것인가를 결정하는 방식이 바로 동기(Synchronous)와 비동기(Asynchronous) 처리 모델이다.
- 동기 처리(Synchronous)
: 하나의 작업이 끝나야 다음 작업이 시작되는 순차적 처리.
(예: File.ReadAllText() — 완료될 때까지 다음 코드 실행 불가) - 비동기 처리(Asynchronous)
: 작업을 미리 예약하고, 완료되면 알림(callback/await)을 통해 이어붙이는 구조.
(예: await File.ReadAllTextAsync() — 읽는 동안 다른 코드 실행 가능)
await는 병렬이 아니다
C#의 async/await 문법은 흔히 멀티스레드처럼 오해되지만,
실제로는 “실행을 잠시 중단하고 나중에 이어붙이는 문법” 에 가깝다.
즉, await는 새로운 스레드를 생성하지 않는다.
await Task.Delay(1000); // 이 1초 동안 CPU가 쉬는 게 아니라, 다음 코드로 제어가 넘어간다.
반대로 다음과 같은 코드는 실제로 병렬 처리를 수행한다.
await Task.Run(() => HeavyComputation());
Task.Run()은 실제 스레드풀을 사용하여 별도의 스레드에서 코드를 실행한다.
이때 비로소 “병렬성(Parallelism)”이 발생한다.
Unity의 await와 코루틴의 차이
Unity의 await (예: Addressables.LoadAssetAsync)는
내부적으로 엔진의 Job System이나 I/O 스레드에서 비동기 처리를 수행하지만,
await 자체는 메인 스레드에서 재개 시점을 등록하는 역할만 한다.
반면, Coroutine은 완전히 메인 스레드 안에서 돌아간다.
Update 루프 이후, 같은 프레임 내에서 한 번씩 실행되며
프레임 단위 분할 실행(의사 비동기) 으로 동작한다.
| 스레드 | 메인 스레드 | 메인 스레드(대기 중 재개) | 별도 스레드 |
| 병렬성 | ❌ 없음 | ⚙️ 제한적 | ✅ 있음 |
| 제어 방식 | yield / Scheduler | await / SynchronizationContext | ThreadPool |
| 목적 | 프레임 분할 | 논리적 비동기 | 물리적 병렬 |
즉, 코루틴은 병렬처럼 보이지만 병렬이 아니며, await는 비동기지만 스레드를 새로 만들지 않는다.
진짜 병렬성은 Task.Run()처럼 스레드풀을 통해 여러 CPU 코어를 사용하는 경우에만 발생한다.
사실 병렬성이라는게 위에 설명한 것처럼 그렇게 간단하지만은 않다.
비동기든 병렬이든 간에, 컨텍스트 스위칭(context switching)과 동기화 비용(synchronization cost)이 필연적으로 발생한다.
특히 비동기의 경우, 단일 스레드 내에서 “작업을 잠시 중단했다가 재개하는 구조”이기 때문에 CPU 점유는 효율적일지라도 전체 실행 시간은 오히려 더 길어질 수도 있다.
그럼에도 불구하고 비동기를 사용하는 이유는 “성능”이 아니라 “사용자 경험(UX)” 때문이다.
비동기는 응답성(responsiveness) 을 유지하기 위한 기술이지, 연산 효율을 극대화하는 기술이 아니다.
즉,
- 비동기 = 끊김 없는 사용자 경험을 위한 선택,
- 병렬 = 처리량 향상을 위한 선택이다.
'프로그래밍 > C#' 카테고리의 다른 글
| 파일 정합성 인증 - 무결성 검사 정책 정하기 (0) | 2026.01.10 |
|---|---|
| C# 어드레서블 카탈로그 따라하기 – 어드레서블에 ContentManifest 끼얹기 (0) | 2025.12.23 |
| C# 문자열 변수 할당 최적화 (0) | 2025.04.01 |
| C# 반복자 패턴 IEnumerator과 yield키워드 (0) | 2025.02.25 |
| C# 버전별 핵심 기능 (1.0 ~ 7.0) (0) | 2023.12.01 |
