Async/Await의 본질: 우리는 왜 ‘기다림’을 ‘멈춤’으로 착각하는가?
많은 개발자가 await를 보면 “코드가 여기서 멈춘다"라고 직관적으로 이해합니다. 하지만 이는 논리적 흐름의 멈춤일 뿐, 물리적인 스레드가 멈추는 블로킹은 아닙니다. async/await는 동기 코드처럼 보이게 만들면서, 실제로는 실행 흐름을 나눠 코루틴처럼 동작합니다.
이 글은 JavaScript, Python, C#을 비교해 async/await가 어떻게 “동기처럼 보이는 비동기"를 만드는지 설명합니다.
1. 가장 큰 오해: 멈춤이 아니라 양보
await는 실행 흐름을 잠시 중단시키지만, 스레드 전체를 멈추지는 않습니다. 핵심은 다음입니다.
await는 현재 함수의 실행 컨텍스트를 일시 중단한다.- 제어권은 즉시 호출자나 이벤트 루프로 돌아간다.
- 작업이 끝나면 중단 지점부터 다시 이어서 실행된다.
비유로 말하면, 식당에서 진동벨을 받고 자리에 앉는 것과 같습니다. 나는 기다리지만, 카운터 앞을 막고 서 있지는 않습니다.
2. 논리적 계층: 모든 것은 코루틴이다
async/await의 핵심은 서브루틴이 아니라 코루틴의 개념입니다.
- 실행 중
await를 만나면 상태를 저장하고 밖으로 빠져나온다. - 비동기 작업이 완료되면 저장된 상태에서 다시 재개된다.
- 문법은 다르지만 구조는 제너레이터 + 프라미스/퓨처 조합과 유사하다.
2.1 진입과 반환의 틀을 깨다
- 일반 함수(Subroutine): 한 번 호출되면 끝날 때까지 멈추지 않는다. (Run-to-completion)
- async 함수(Coroutine): 중간에 멈출 수 있고(Suspend), 상태를 저장한 채로 나중에 재개(Resume)할 수 있다.
2.2 await는 사실 yield에 가깝다
await를 만나면 실행 컨텍스트를 캡처하고, 즉시 호출자에게 제어권을 넘긴다.- 호출자에게는 Promise/Task/Future 같은 “약속 어음"이 반환된다.
- 코드는 멈춘 것이 아니라, 이미 반환하고 빠져나온 상태다.
실행 흐름 다이어그램
[caller] -> async func 실행
-> await 도달 (suspend)
-> 제어권 반환
[loop] -> 다른 작업 실행
-> 작업 완료 이벤트
-> async func resume
3. 물리적 계층: 누가 비동기 작업을 처리하는가?
차이점은 다음 두 질문에 있습니다.
- 기다리는 동안 스레드는 무엇을 하는가?
- 재개될 때 어디서 실행되는가?
3.1 JavaScript와 Python: 싱글 스레드 이벤트 루프
모델은 이벤트 루프 기반 협력적 멀티태스킹입니다.
await는 작업을 백그라운드에 위임하고, 메인 루프는 즉시 다른 작업을 수행한다.- 완료된 작업은 큐에 쌓이고, 루프가 비었을 때 재개된다.
- 재개 위치는 이벤트 루프가 도는 동일 스레드다.
주의점은 간단합니다. 동기 함수가 오래 걸리면 루프 전체가 막힙니다. Python에서 time.sleep()을 asyncio 안에서 호출하면 모든 코루틴이 멈춘 것처럼 보입니다.
3.2 C# (.NET): 상태 머신 + 스레드 풀
C#은 컴파일러가 async 메서드를 상태 머신으로 변환합니다.
await지점에서 스레드는 풀로 반환된다.- 작업이 끝나면 컨텍스트에 따라 원래 스레드나 다른 풀 스레드에서 재개된다.
SynchronizationContext가 있으면 원래 컨텍스트로 돌아가고, 없으면 풀 스레드에서 이어진다.
Wait()는 스레드를 점유한 채로 멈추지만, await는 스레드를 놓아줍니다. 이 차이가 처리량과 확장성에서 큰 차이를 만듭니다.
3.3 C++20 코루틴: 상태 머신 + 실행자(Executor) 의존
C++20 코루틴도 컴파일러가 상태 머신으로 변환한다는 점은 동일합니다. 하지만 재개가 어디서, 어떤 스레드에서 일어나는지는 표준이 정하지 않습니다.
co_await는 현재 코루틴의 상태를 저장하고 호출자(또는 프레임워크)로 제어권을 넘긴다.- 재개는 사용하는 라이브러리/프레임워크의 실행자(Executor) 정책에 따라 결정된다.
- 기본적으로 “스레드 풀에서 이어진다”가 아니라, 어떤 스레드로 돌아갈지는 구현에 달려 있다.
즉, C#은 런타임과 스레드 풀의 결합이 명확한 반면, C++은 코루틴 자체는 저수준 도구이고 스케줄링 정책은 라이브러리가 맡습니다.
3.4 언어별 비동기 흐름의 공통점과 분기점
비동기 문법은 서로 닮았지만, “누가 스케줄링을 책임지느냐”가 핵심 분기입니다.
- JS/Python: 이벤트 루프가 단일 스레드에서 태스크를 순차적으로 재개한다.
- C#: 런타임이 스레드 풀과 컨텍스트를 관리하며 재개 지점을 결정한다.
- C++: 코루틴은 상태 머신만 제공하고, 실행자/프레임워크가 재개 정책을 정한다.
4. Wait vs Await: 가독성 이상의 의미
async/await는 단순히 콜백 지옥을 피하는 문법이 아닙니다. 리소스 관리 그 자체입니다.
| 구분 | Blocking (Wait, Sleep) | Non-Blocking (await) |
|---|---|---|
| 스레드 상태 | 대기 중이며 점유 | 해방되어 다른 작업 가능 |
| 처리량 | 스레드 수에 제한 | 적은 스레드로 높은 처리량 |
| 적합한 작업 | CPU Bound | I/O Bound |
5. 비동기 문법의 유사성과 차이: “await”와 “스레드”
많은 언어가 async/await 형태를 공유하지만, 실제로는 “스레드 비동기”와 “이벤트 루프 비동기”가 섞여 있습니다.
5.1 문법적 유사성
- 공통점:
async함수는 즉시 반환하고,await는 결과가 준비될 때까지 논리적 대기 상태로 들어간다. - 결과: 코드 흐름은 동기처럼 보이지만, 실행은 분할되어 스케줄러가 재개한다.
5.2 기능적 차이점: await가 “스레드”를 의미하지는 않는다
- JS/Python:
await는 스레드를 만들지 않는다. 이벤트 루프에 제어권을 돌려준다. - C#:
await는 스레드를 생성하지 않지만, 재개는 스레드 풀에서 일어날 수 있다. - C++:
co_await는 스레드를 만들지 않는다. 어떤 스레드에서 재개되는지는 실행자에 달렸다.
결론적으로 await는 “스레드 생성/병렬 실행”과 별개이며, “재개 시점과 위치를 스케줄러에 맡기는 문법”에 가깝습니다.
6. 비동기 흐름 비교 요약
| 언어 | 스케줄링 주체 | 기본 재개 스레드 | 비고 |
|---|---|---|---|
| JavaScript | 이벤트 루프 | 메인 스레드 | 단일 스레드 협력적 멀티태스킹 |
| Python | 이벤트 루프 | 루프 스레드 | 동기 함수 혼용 시 전체 블로킹 |
| C# | 런타임 + 스레드 풀 | 컨텍스트/풀 스레드 | SynchronizationContext 영향 |
| C++20 | 실행자/프레임워크 | 구현 의존 | 표준은 스케줄링 정책 미정 |
7. 비교 요약: 같은 문법, 다른 세상
| 구분 | JavaScript / Python | C# (.NET) |
|---|---|---|
| 기반 모델 | 싱글 스레드 이벤트 루프 | 멀티 스레드 + 스레드 풀 |
| await의 의미 | “루프에 양보, 완료되면 큐에 넣기” | “스레드 반납, 완료되면 컨텍스트/풀에서 재개” |
| 재개 위치 | 동일 스레드 | 컨텍스트/풀 스레드 |
| 주의점 | 긴 동기 연산은 전체 블로킹 | 긴 CPU 작업은 별도 스레드로 분리 |
8. 짧은 코드 비교
JavaScript: 블로킹 vs 비블로킹
// 블로킹 예시: 긴 CPU 작업
function heavy() {
const end = Date.now() + 2000;
while (Date.now() < end) {}
}
console.log('A');
heavy();
console.log('B');
// 비블로킹 예시: I/O 대기
async function run() {
console.log('A');
await new Promise(r => setTimeout(r, 2000));
console.log('B');
}
run();
Python: asyncio에서의 차이
import asyncio
import time
async def bad():
time.sleep(2) # 이벤트 루프 전체가 막힘
async def good():
await asyncio.sleep(2) # 루프에 양보
C#: Wait() vs await
// 블로킹
Task.Delay(2000).Wait();
// 논블로킹
await Task.Delay(2000);
9. 핵심 요약
- async/await는 동기 코드처럼 보이게 하지만, 실제로는 실행 흐름을 쪼개는 코루틴 메커니즘이다.
- JavaScript와 Python은 이벤트 루프 기반으로 단일 스레드를 효율적으로 돌려쓴다.
- C#은 스레드 풀과 상태 머신으로 스레드를 점유하지 않고 재개한다.
- 본질은 “기다리는 것"이 아니라 “나중에 할 일로 미뤄두고 자원을 반납하는 것"이다.
블로그 작성 팁
- JS 이벤트 루프 모델과 C# 스레드 풀 모델을 대비하는 그림을 넣으면 이해가 빠르다.
- Sleep과 await의 차이를 보여주는 간단한 벤치마크 예제를 덧붙이면 독자의 체감이 커진다.