같은 async/await 코드인데 결과가 4가지? 7개 런타임으로 본 비동기 설계의 9가지 갈림길

들어가며
async/await는 복잡한 동시성 코드를 순차 코드처럼 읽히게 해 줍니다. Python, Rust, Swift, JavaScript, C# 등 여러 언어가 이 문법을 쓰다 보니 어느 언어에서나 똑같이 동작한다고 생각하기 쉽습니다.
그런데 실제로는 그렇지 않습니다. 연구 「A Design Space Exploration of Async/Await」에 따르면, 백그라운드 로그를 남기는 짧은 예제 하나가 7개 런타임에서 4가지 다른 출력을 냅니다. 이 글에서는 이런 차이가 왜 생기는지, 연구가 정리한 9가지 설계 차원을 따라가며 살펴보겠습니다.
핵심 요약
async/await는 언어마다 실행 의미가 다릅니다. 같은 예제가 7개 런타임에서 4가지 결과를 냅니다.- 이 차이는 단순한 성능 문제가 아닙니다. 관찰 가능한 동작 자체를 바꾸는 설계 선택 때문에 생깁니다.
- 연구는 이 선택들을 태스크의 시작·종료·취소 단계에 걸친 9개 설계 차원으로 분류했습니다.
- 설계마다 성능·메모리·사용 편의성·실행 의미를 맞바꾸기 때문에 유일한 정답은 없습니다.
순차형 비동기성(Straight-line Asynchrony)이란?
이벤트 루프를 직접 다루거나 콜백을 중첩하는 방식과 달리, async/await는 비동기 코드를 위에서 아래로 읽히는 순차 코드처럼 쓰게 해 줍니다. 연구는 이 패러다임을 **순차형 비동기성(straight-line asynchrony)**이라고 부릅니다. 관련 언어 설계는 15년 넘게 발전해 왔고, 그 사이 언어마다 서로 다른 선택을 했습니다.
백그라운드 로그 하나가 만드는 네 가지 결과
예제 프로그램을 의사 코드로 옮기면 다음과 같습니다.
async def write_to_log():
print("A")
await sleep(2) # 느린 로그 쓰기를 흉내 냄
print("B")
async def fire_and_forget():
spawn write_to_log() # 태스크를 만들고 기다리지 않은 채 반환
async def main():
await fire_and_forget()
await sleep(1)
print("C")
간단해 보이지만 런타임별 출력은 이렇게 갈립니다.
| 런타임 | 출력 |
|---|---|
| Python asyncio, C#, Rust Tokio, Swift | AC |
| JavaScript | ACB |
| Rust Smol | C |
| Python Trio | ABC |
세 가지 변형 프로그램까지 함께 비교하면, 7개 런타임 중 모든 출력이 일치하는 쌍은 하나도 없습니다. 짧은 프로그램 하나를 정확히 이해하려 해도 호출이 즉시 실행되는지, await가 반드시 중단되는지, 태스크 수명과 참조 강도, 예외 전파, 취소 방식까지 알아야 한다는 뜻입니다.
1단계: 태스크 시작
즉시성(Eagerness)
비동기 함수를 호출했을 때 어떻게 평가할지 정합니다. 흔히 'hot' 호출은 이미 실행 중인 태스크를 반환하고, 'cold' 호출은 await하기 전까지 아무 일도 하지 않는 객체를 반환합니다.
- Lazy: 코루틴만 반환하고 실행은 진행하지 않습니다.
- Eager: 현재 스레드에서 바로 실행하다가
await시점에 태스크로 스케줄링합니다.
중단(Suspension)
await 지점에서 실행 중단을 보장하는지 정합니다.
- Static:
await지점에서 반드시 중단합니다. - Dynamic: 태스크를
await할 때도 중단을 보장하지 않습니다.
2단계: 태스크 종료
수명 범위(Extent)
태스크가 기본적으로 얼마나 오래 존재할 수 있는지 정합니다.
- Indefinite: 런타임이 끝날 때까지 존재할 수 있습니다.
- Dynamic: 자신을 만든 스코프가 끝날 때까지만 존재합니다.
참조 강도(Reference Strength)
수명 범위가 Indefinite일 때 런타임이 태스크를 어떤 참조로 붙잡고 있는지 정합니다.
- Strong: 강한 참조로 보유합니다.
- Weak: 약한 참조로 보유합니다.
정리 방식(Destruction)
태스크가 수명 범위 끝에 도달했을 때 어떻게 처리할지 정합니다.
- Awaited: 완료될 때까지 기다립니다.
- Cancelled: 취소합니다. 경우에 따라 취소된 태스크를 기다리기도 합니다.
- Terminated: 프로그램이 종료됩니다.
예외 전파(Propagation)
await하지 않은 태스크에서 발생한 예외를 어떻게 다룰지 정합니다.
- Destructive: 그 태스크에 의존하는 태스크에서 예외가 다시 발생합니다.
- Never: 예외가 해당 태스크 안에 머뭅니다.
💡 Swift와 Trio의 차이
Swift와 Python+Trio는 둘 다 Dynamic 수명 범위를 택했습니다. 그래서 write_to_log 태스크는 fire_and_forget 함수보다 오래 살 수 없습니다. 결과가 갈리는 지점은 정리 방식입니다.
- Swift는 Cancelled 방식이라 함수 스코프가 끝날 때 태스크를 취소합니다. 따라서
B가 출력되지 않고 결과는AC입니다. - Trio는 Awaited 방식이라 로그 쓰기가 끝날 때까지 기다립니다. 따라서 결과는
ABC입니다.
수명에 대한 철학은 같아도 스코프가 끝나는 순간의 처리 방식 하나 때문에 출력이 달라집니다.
3단계: 태스크 취소
취소 인지(Awareness)
- Unaware: 태스크가 취소에 반응할 수 없습니다.
- Aware: 태스크가 취소에 반응할 수 있습니다.
취소 방향(Direction)
태스크 그래프에서 취소를 어떤 방향으로 전달할지 정합니다.
- Top-Down: 루트 태스크에서 시작해, 의존하는 쪽에서 의존 대상 쪽으로 전달합니다.
- Bottom-Up: 루트의 의존 대상에서 시작해, 그 태스크에 의존하는 쪽으로 전달합니다.
- Simultaneous: 전이적으로 연결된 모든 의존 대상에 한꺼번에 전달합니다.
취소 지속성(Persistence)
취소를 인지할 수 있는 태스크에서 취소 상태가 얼마나 유지되는지 정합니다.
- Transient: 취소를 무시하고 정상적으로 진행할 수 있습니다.
- Persistent: 취소를 무시할 수는 있지만, 취소된 상태는 계속 남습니다.
형식 의미론으로 분기 지점 추적하기
연구는 이 설계 공간을 비동기 프로그램의 핵심 계산 체계에 대한 **형식 의미론(formal semantics)**으로 옮기고, 실행 추적으로 예제 결과가 달라지는 이유를 보여 줍니다.
- 실행 추적 도식에서 각 상자는 추상 기계의 상태를, 각 화살표는 적용된 규칙이 표시된 **작은 단계 축약(small-step reduction)**을 뜻합니다.
- 대부분의 규칙은 모든 런타임에서 똑같이 동작합니다.
- 강조 표시된 규칙이 결과를 갈라놓는 설계 선택입니다.
- 도식은 함수 호출의 즉시(eager)·부분 즉시(semi-eager)·지연(lazy) 평가, 태스크 수명 범위, 취소, 대기, 런타임 종료에 따른 분기를 하나로 연결해 보여 줍니다.
마치며: 개발자가 챙겨 갈 점
9개 설계 차원에는 모두 성능·메모리 사용량·사용 편의성·실행 의미 사이의 절충이 있고, 언어마다 나름의 설계 근거가 있습니다. 어느 쪽이 옳다고 할 수는 없지만, 실무에서 다음 사항은 기억해 두면 좋습니다.
- fire-and-forget 패턴은 조심하세요. 기다리지 않은 태스크가 끝까지 실행된다는 보장은 언어마다 다릅니다.
- 언어를 옮길 때는 비동기 의미론을 다시 확인하세요. Python에서 Rust로, JavaScript에서 Swift로 코드를 옮길 때 문법이 비슷해도 동작은 다를 수 있습니다.
- 같은 언어 안에서도 런타임에 따라 다릅니다. 같은 Python이라도 asyncio와 Trio, 같은 Rust라도 Tokio와 Smol은 결과가 다릅니다.
async/await는 비동기 코드를 순차 코드처럼 보이게 해 주지만, 실제로 순차적으로 실행된다는 뜻은 아닙니다. 쓰고 있는 런타임이 태스크를 어떻게 시작하고, 언제 끝내고, 어떻게 취소하는지 알고 있어야 예상치 못한 버그를 막을 수 있습니다.