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 BoundI/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 / PythonC# (.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의 차이를 보여주는 간단한 벤치마크 예제를 덧붙이면 독자의 체감이 커진다.