asyncio에서 await가 실제로 뭘 하는지 드디어 이해했어요
파이썬 비동기 코드 처음 봤을 때 솔직히 그냥 “마법”이라고 생각했어요.
await를 붙이면 기다리고, 안 붙이면 안 기다리고. 그냥 그렇게 외우고 썼거든요. 근데 어느 날 이런 생각이 들더라고요. “그래서 이게 내부에서 진짜로 어떻게 돌아가는 거야?”
전공자 친구들 공부하는 거 보면 그냥 쓸 줄 아는 걸 넘어서, 왜 그렇게 동작하는지까지 파고드는 게 보이더라고요. 저도 그렇게 공부하고 싶었어요. 비전공자라서 어렵긴 했지만, 파헤쳐보니까 오히려 “아, 파이썬이 이렇게 생겼구나” 싶어서 재밌었어요.
오늘은 await가 내부적으로 실제로 뭘 하는지, 이벤트 루프가 어떻게 일을 나눠서 처리하는지 최대한 쉽게 풀어볼게요.
await는 “기다려”가 아니라 “잠깐 비켜줘”예요
많은 분들이 await를 “여기서 기다린다”고 이해하시는데, 사실 더 정확하게는 “나 지금 할 수 없으니까, 다른 애 먼저 해” 에 가까워요.
예를 들어볼게요. 카페 알바생이 커피 기계 돌아가는 동안 다른 손님 주문 받는 거 생각하면 돼요. 커피가 나올 때까지 멍하니 서 있는 게 아니라, 그 시간에 다른 일을 처리하는 거잖아요.
await도 똑같아요. “이 작업 결과 나올 때까지 나는 이벤트 루프한테 제어권을 돌려줄게. 다 되면 나 다시 불러줘.” 이게 await가 하는 일이에요.
한 줄 정리: await = 제어권을 이벤트 루프에 돌려주는 신호
코루틴이 뭔지부터 짚고 가요
await를 이해하려면 코루틴(coroutine)을 먼저 알아야 해요.
일반 함수는 호출하면 끝날 때까지 쭉 실행돼요. 중간에 멈출 수가 없어요. 근데 코루틴은 달라요. 중간에 멈췄다가 나중에 다시 이어서 실행할 수 있어요.
파이썬에서 async def로 만든 함수가 바로 코루틴이에요.
async def 커피_만들기():
print("원두 갈기 시작")
await 에스프레소_추출() # 여기서 잠깐 멈출 수 있어요
print("커피 완성")
여기서 await 지점이 바로 중단점(suspension point) 이에요. 코루틴이 여기서 실행을 멈추고, 이벤트 루프한테 “나 잠깐 쉴게”라고 알리는 지점이에요.
한 줄 정리: 코루틴은 중간에 멈췄다 재개할 수 있는 특별한 함수예요
이벤트 루프가 실제로 하는 일
이벤트 루프는 쉽게 말하면 작업 관리자예요.
내부적으로 이런 식으로 돌아가요.
- 실행 가능한 코루틴 목록을 관리해요 (Ready Queue)
- 그 중 하나를 꺼내서 실행시켜요
- 코루틴이
await를 만나면 제어권을 돌려받아요 - 대기 중인 작업(IO 완료, 타이머 등)이 있는지 확인해요
- 완료된 게 있으면 그 코루틴을 다시 Ready Queue에 넣어요
- 2번으로 돌아가요
이게 계속 반복되는 거예요. 이걸 이벤트 루프라고 부르는 이유가 있었네요. 진짜로 루프(반복)니까요.
import asyncio
async def task_a():
print("A 시작")
await asyncio.sleep(1) # 여기서 이벤트 루프에 제어권 반납
print("A 완료")
async def task_b():
print("B 시작")
await asyncio.sleep(0.5)
print("B 완료")
async def main():
await asyncio.gather(task_a(), task_b())
asyncio.run(main())
실행하면 이렇게 돼요:
A 시작
B 시작
B 완료
A 완료
A가 sleep(1)에서 기다리는 동안, B가 치고 들어와서 먼저 끝나는 거예요. 동시에 실행되는 것처럼 보이지만, 실제로는 하나의 스레드에서 번갈아가며 실행되는 거예요.
한 줄 정리: 이벤트 루프는 코루틴들의 제어권을 번갈아 넘겨주는 관리자예요
await 내부에서 실제로 일어나는 일 (스택 프레임 이야기)
여기서부터 좀 깊이 들어가는데, 최대한 쉽게 설명해볼게요.
파이썬은 함수가 실행될 때 스택 프레임(Stack Frame) 이라는 걸 만들어요. 그 함수의 지역 변수, 현재 어디까지 실행됐는지 같은 정보를 담는 상자라고 생각하면 돼요.
일반 함수는 실행이 끝나면 이 상자가 사라져요. 근데 코루틴은 달라요. await로 멈출 때 이 상자를 그대로 보존해요. 어디까지 실행됐는지, 변수 값이 뭐였는지 전부 기억한 채로 대기 상태가 되는 거예요.
그러다가 대기하던 작업이 완료되면, 이벤트 루프가 그 코루틴을 다시 깨우고 멈췄던 바로 그 지점부터 다시 실행해요. 변수 값도 그대로고, 실행 위치도 그대로예요.
이게 가능한 이유가 파이썬 내부적으로 코루틴 객체가 __await__ 메서드를 가지고 있고, 이게 제너레이터(generator) 기반으로 동작하기 때문이에요. 코루틴의 조상이 사실 제너레이터거든요.
한 줄 정리: await로 멈춰도 스택 프레임이 보존되어서 정확히 그 지점부터 재개돼요
대기 객체(Awaitable)가 뭔지도 알아야 해요
await 뒤에는 아무 거나 붙일 수 없어요. 대기 가능한 객체(Awaitable) 만 올 수 있어요.
파이썬에서 Awaitable은 크게 세 가지예요.
- 코루틴 객체:
async def로 만든 함수를 호출한 결과 - Task:
asyncio.create_task()로 만든 것, 이벤트 루프가 코루틴을 스케줄링할 때 사용 - Future: 아직 완료되지 않은 어떤 결과를 나타내는 객체
이 셋 다 내부적으로 __await__ 메서드를 구현하고 있어요. 이벤트 루프는 이걸 통해서 “이 작업 끝났니?”를 계속 확인해요.
특히 Task는 흥미로운데요. asyncio.create_task()를 쓰면 코루틴이 이벤트 루프의 스케줄 큐에 즉시 등록돼요. await를 만나기도 전에 이미 이벤트 루프가 알고 있는 거예요. 이 차이가 나중에 성능 최적화에서 중요하게 작용하더라고요.
한 줄 정리: await 뒤엔 Awaitable 객체만 오고, 이벤트 루프가 이걸 스케줄링해요
비전공자가 이걸 이해하는 데 도움됐던 비유
솔직히 처음엔 공식 문서 보면서 “이게 무슨 말이야” 싶었어요.
그때 저한테 제일 도움됐던 비유가 맛집 웨이팅 시스템이에요.
- 이벤트 루프 = 웨이팅 앱
- 코루틴 = 손님
- await = “자리 나면 알려줘” 버튼
- 스택 프레임 = 손님의 주문 내역 (자리 날 때까지 보관)
손님이 자리 없다고 밖에서 멍하니 기다리는 게 아니라, 웨이팅 등록하고 근처 카페 가서 다른 거 하다가 알림 오면 돌아오는 거예요. 이게 비동기의 핵심이에요.
그리고 여러 손님이 동시에 웨이팅 걸 수 있잖아요. 웨이팅 앱(이벤트 루프)이 순서 관리하면서 자리 나면 각각 알려주는 거고요.
한 줄 정리: 비동기는 기다리는 게 아니라, 기다리는 동안 다른 걸 하는 구조예요
마무리하며
처음에 그냥 “await 붙이면 비동기”로만 알고 있었는데, 파고들수록 파이썬이 정말 정교하게 설계됐다는 게 느껴지더라고요.
비전공자라서 이런 깊은 내용이 낯설 수 있는데, 사실 이 정도 이해하면 나중에 asyncio 관련 버그 만났을 때 훨씬 빠르게 원인을 찾을 수 있어요. 데이터 엔지니어링에서도 비동기 처리 자주 쓰이거든요. API 여러 개 동시에 호출하거나, 대용량 데이터 파이프라인 만들 때요.
전공자처럼 깊게 공부하고 싶다면, 이런 “왜 이렇게 동작하지?”를 계속 파고드는 습관이 제일 중요한 것 같아요. 저도 아직 배워가는 중이지만요.
다음엔 Task와 Future의 차이, 그리고 asyncio.gather vs asyncio.wait 내부 동작 차이도 정리해볼게요. 여러분도 궁금한 거 있으면 댓글로 알려주세요!