오늘의 주문서
#09 · 아홉째 밤
위기
홀 개편 첫날. 홀 직원은 루 한 명, 테이블은 열 개. 한 테이블의 요리를 주방 앞에서 기다리면 나머지 아홉 테이블이 멈춘다.
목표
테이블 열 개의 요리를 직원을 늘리지 않고 0.5초 안에 모두 내보낸다.
심야 식당 동시식당
오늘 밤부터 홀이 바뀝니다. 홀 직원은 루 한 명. 테이블은 열 개입니다. 셰프는 직원을 더 뽑지 않았습니다.
루는 쉬지 않고 홀을 한 바퀴씩 돕니다. 주문을 받아 주방에 넣고, 요리를 기다리는 대신 바로 다음 테이블로 갑니다. 주방에서 벨이 울리면 그 접시를 들고 나갑니다. 한 바퀴, 또 한 바퀴. 벨(이벤트)에 반응하며 같은 길을 도는 모습에, 첫날부터 요리사들이 붙인 별명이 이벤트 루프입니다.
루가 앞치마 끈을 묶으며 말합니다. "테이블 앞에 서서 멍하니 기다리는 게 제일 싫어요. 기다리는 동안 다른 테이블이 저를 기다리잖아요."
셰프가 고개를 끄덕입니다. "어제까지는 사람을 늘렸지. 오늘은 한 사람이 기다리는 시간을 쓰는 법이야. 파이썬에선 asyncio라고 불러."
함수 앞에 async를 붙이면 코루틴 함수가 됩니다. 이상한 점부터 봅시다. 불러도 실행되지 않습니다.
import asyncio
async def take_order(table):
print("order", table)
return f"T{table}"
c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaitedtake_order(1)은 "order 1"을 출력하지 않았습니다. 함수 몸통은 한 줄도 돌지 않았습니다.was never awaited 경고를 냅니다. 이 경고가 보이면 거의 언제나 await를 빠뜨린 것입니다.쪽지를 실행하는 방법은 두 가지입니다. 맨 바깥에서는 asyncio.run()에 넘기고, 다른 코루틴 안에서는 await 합니다.
print(asyncio.run(take_order(1)))
# order 1
# T1
c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutine코루틴 객체는 한 번만 쓸 수 있습니다. 같은 주문을 다시 하려면 take_order(2)를 다시 불러 새 쪽지를 만듭니다.
await는 "이 일이 끝날 때까지 나는 여기서 기다린다. 그동안 루프는 다른 일을 해도 좋다"는 뜻입니다. await는 async def 안에서만 쓸 수 있습니다. 보통 def 안에 쓰면 SyntaxError: 'await' outside async function이 납니다.
import asyncio
import time
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait) # 주방을 기다리는 동안 루는 자유롭다
print(f"T{n} served")
return f"T{n}"
async def one_by_one():
a = await table(1, 0.3)
b = await table(2, 0.3)
return [a, b]
start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62sasyncio.sleep(0.3)은 time.sleep(0.3)의 asyncio판입니다. 기다리는 동안 루프에 차례를 넘깁니다.await table(1, ...)가 끝나야 다음 줄로 가기 때문입니다. 루가 1번 테이블 앞에서 요리가 나올 때까지 서 있는 것과 같습니다.await는 "동시에"가 아니라 "기다린다"입니다. 동시에 기다리려면 다음 절의 도구가 필요합니다.asyncio.run(main())은 프로그램의 입구입니다.
main() 코루틴을 끝날 때까지 돌린다.프로그램에서 보통 한 번, 맨 바깥에서만 부릅니다. 이미 루프가 도는 곳에서 다시 부르면 RuntimeError: asyncio.run() cannot be called from a running event loop이 납니다.
이벤트 루프는 스레드 하나에서 도는 반복문입니다. 하는 일은 루가 홀을 도는 것과 같습니다.
await까지 실행한다.세 테이블을 동시에 맡기면(다음 절의 gather) 루의 동선은 이렇습니다. 1번은 0.3초, 2번은 0.2초, 3번은 0.1초가 걸리는 요리입니다.
| 시각 | 루가 하는 일 | 출력 |
|---|---|---|
| 0.0초 | T1 주문을 넣고 await에서 자리를 뜬다 | T1 order |
| 0.0초 | T2 주문을 넣고 자리를 뜬다 | T2 order |
| 0.0초 | T3 주문을 넣고 자리를 뜬다 | T3 order |
| 0.0~0.1초 | 할 일이 없으니 가장 빠른 벨을 기다린다 | |
| 0.1초 | T3 벨. 접시를 들고 나간다 | T3 served |
| 0.2초 | T2 벨 | T2 served |
| 0.3초 | T1 벨 | T1 served |
두 가지를 기억하세요.
await에서만 넘어갑니다. 스레드는 운영체제가 아무 때나 끊지만(3교시), 코루틴은 스스로 await 할 때만 자리를 비웁니다. 그래서 await 사이의 코드는 중간에 끊기지 않습니다.asyncio.gather(코루틴, 코루틴, ...)는 여러 코루틴을 한꺼번에 루프에 올리고, 모두 끝날 때까지 기다립니다.
async def together():
return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))
start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31sexecutor.map과 같습니다.gather(*coros)처럼 별표로 풉니다.아래 실습대에서 "한 테이블씩 await"와 "gather로 한꺼번에"를 번갈아 돌려 보세요. 줄 하나가 테이블 하나이고, 막대는 모두 "기다림" 색입니다. 루가 실제로 손을 쓰는 시간은 거의 없고, 대부분 주방을 기다린 시간이기 때문입니다.
주방 타임라인
주문
6
요리
기다림
취소
실패
gather에 넘긴 코루틴 하나가 예외를 내면, gather는 그 예외를 그대로 올립니다. 결과를 모두 받고 싶다면 return_exceptions=True를 줍니다.
async def table(n):
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"T{n} wrong dish")
return f"T{n}"
async def main():
results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
print(results)
try:
await asyncio.gather(table(1), table(2), table(3))
except ValueError as e:
print("ValueError:", e)
asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dishreturn_exceptions=True이면 예외도 결과 리스트의 한 칸에 들어갑니다. 결과를 하나씩 isinstance(r, Exception)으로 확인하세요.False)일 때 예외가 올라와도 나머지 테이블은 취소되지 않고 계속 돕니다. "하나가 실패하면 나머지를 멈춰라"는 10교시의 TaskGroup이 해 줍니다.셰프의 한마디
was never awaited 경고는 주문서를 써 놓고 주방에 안 넣었다는 뜻이야. 코루틴 함수를 불렀으면 반드시 await 하거나 태스크로 만들어. 아니면 그 요리는 영영 안 나와.
asyncio.create_task(코루틴)은 코루틴을 태스크로 감싸 루프에 바로 예약합니다. 태스크는 루가 손이 빌 때(지금 코루틴이 await 할 때) 시작됩니다. 끝날 때까지 기다리지 않고 다음 줄로 넘어가므로, 주문을 먼저 넣어 두고 다른 일을 할 수 있습니다.
async def table(n, wait):
print(f"T{n} order")
await asyncio.sleep(wait)
print(f"T{n} served")
return f"T{n}"
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(table(1, 0.3))
t2 = asyncio.create_task(table(2, 0.2))
print("Lou: 2 tasks created")
await asyncio.sleep(0.1) # 루가 잠깐 다른 일을 한다
print("Lou: back", t1.done(), t2.done())
print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s"Lou: 2 tasks created"가 T1 order보다 먼저 나옵니다. 태스크는 만들자마자 예약되지만, main이 await로 자리를 비울 때 처음 실행됩니다.await t1은 이미 돌고 있는 태스크가 끝나기를 기다립니다. 두 태스크가 함께 기다렸으니 0.5초가 아니라 약 0.3초입니다.gather는 넘겨받은 코루틴을 내부에서 태스크로 감싸 줍니다. 결과만 모아 받으면 될 때는 gather, 따로 들고 있다가 상태를 보거나 나중에 기다릴 때는 create_task를 씁니다.이벤트 루프는 태스크를 약한 참조로만 들고 있습니다. 변수나 컬렉션에 담지 않은 태스크는, 끝나기 전에 쓰레기 수거로 사라질 수 있습니다. 물 채우기처럼 결과가 필요 없는 일을 맡길 때도 참조를 쥐고 있어야 합니다.
background = set()
async def refill_water(table):
await asyncio.sleep(0.1)
print(f"T{table} water")
async def main():
for n in (1, 2, 3):
task = asyncio.create_task(refill_water(n))
background.add(task) # 참조를 쥐고 있는다
task.add_done_callback(background.discard) # 끝나면 스스로 빠진다
print("pending", len(background))
await asyncio.sleep(0.2)
print("pending", len(background))
asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0asyncio.create_task(refill_water(n))처럼 결과를 버리는 한 줄은 대개 잘 돌지만, "대개"가 문제입니다. 가끔 사라지는 태스크는 찾기 가장 어려운 버그입니다.
둘 다 "잠깐 기다린다"지만, 루에게는 전혀 다른 일입니다.
async def polite(n):
await asyncio.sleep(0.3) # 기다리는 동안 루프에 차례를 넘긴다
async def stubborn(n):
time.sleep(0.3) # 루프째 멈춘다
async def main(fn):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in range(3)))
print(fn.__name__, f"{time.perf_counter() - start:.2f}s")
asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90sasyncio.sleep은 await 하는 순간 루프에 "0.3초 뒤에 깨워 줘"라고 맡기고 자리를 비웁니다.time.sleep은 스레드 자체를 멈춥니다. 루프가 도는 스레드는 하나뿐이니, 루가 테이블 앞에 서서 멍하니 기다리는 것과 같습니다. 그동안 다른 테이블은 아무도 봐 주지 않습니다.gather를 써도 소용없습니다.time.sleep뿐 아니라 오래 걸리는 계산, 기다리는 동안 차례를 넘기지 않는 옛 라이브러리도 똑같이 루프를 멈춥니다. 이 문제와 해법은 11교시의 주제입니다.
asyncio에는 비슷한 이름이 셋 나옵니다.
| 이름 | 주방으로 치면 | 하는 일 |
|---|---|---|
| 코루틴(coroutine) | 주문서 쪽지 | async def를 부르면 생긴다. 혼자서는 돌지 않는다 |
| 태스크(Task) | 주방에 넣은 주문 | 코루틴을 감싸 루프에 올린다. done(), result(), cancel() |
| 퓨처(Future) | 나중에 울릴 벨 | "언젠가 값이 들어올 자리". 태스크는 퓨처의 한 종류 |
print(issubclass(asyncio.Task, asyncio.Future)) # True
async def main():
coro = take_order(1)
task = asyncio.create_task(coro)
print(type(coro).__name__, type(task).__name__) # coroutine Task7교시의 concurrent.futures.Future와 생각이 같습니다. 다만 asyncio의 퓨처는 await 할 수 있고, 같은 루프 안에서만 씁니다. 직접 퓨처를 만들 일은 드뭅니다. 보통은 코루틴과 태스크면 충분합니다.
스레드는 운영체제가 만드는 것이라, 하나마다 스택 메모리와 운영체제의 관리 비용이 붙습니다. 태스크는 파이썬 객체 하나일 뿐입니다.
async def table(n):
await asyncio.sleep(0.5)
return n
async def main():
return await asyncio.gather(*(table(n) for n in range(10_000)))
# 10,000 tasks: 약 0.57초 (0.5초를 기다리는 테이블 1만 개)
# 1,000 threads: 약 0.62초 (0.5초를 기다리는 스레드 1천 개)
# 이 PC에서 잰 값태스크 1만 개를 띄우는 비용이 스레드 1천 개보다 적습니다. 기다리는 일이 수천, 수만 개라면(채팅 서버, 많은 주소에 요청 보내기) asyncio가 잘 맞는 이유입니다.
오늘 밤의 목표는 테이블 열 개의 요리를 직원을 늘리지 않고 0.5초 안에 모두 내보내는 것이었습니다. 루 한 명, 이벤트 루프 하나, gather 하나면 됩니다.
import asyncio
import time
async def table(n):
await asyncio.sleep(0.3) # 주방에서 요리가 나오기를 기다린다
return f"T{n}"
async def hall():
return await asyncio.gather(*(table(n) for n in range(1, 11)))
start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30s스레드는 하나도 만들지 않았습니다. 0.3초짜리 테이블 열 개가 약 0.3초에 끝났습니다.
| 스레드 (2~7교시) | asyncio (오늘부터) | |
|---|---|---|
| 일하는 사람 | 요리사 여럿 | 루 한 명 |
| 차례가 바뀌는 때 | 운영체제가 아무 때나 | await에서만 |
| 하나의 비용 | 크다(운영체제 스레드) | 작다(파이썬 객체) |
| 조심할 것 | 경쟁 상태, 데드락 | 블로킹 호출, await 빠뜨리기 |
루가 빈 쟁반을 내려놓습니다. "열 테이블, 한 바퀴에 끝났어요." 셰프가 타이머를 보며 묻습니다. "좋아. 그런데 내일은 단체 예약이야. 여섯 테이블 중 하나가 취소되면, 나머지 다섯 테이블 요리는 어떻게 멈출 거지?"
영업 일지 — 아홉째 밤. 루 혼자 테이블 열 개를 맡음. 기다리는 동안 자리를 뜨는 법(await)과 한꺼번에 기다리는 법(gather)으로 0.3초에 서빙. 내일은 단체 예약.
take_order를 await 없이 부르는 줄과 asyncio.run()으로 부르는 줄을 차례로 실행하고, 출력과 경고를 비교해 보세요.main에서 await asyncio.sleep(0.1)을 지우면 출력 순서가 어떻게 바뀔까요? 먼저 예상해 적고, 실행해 확인해 보세요.셰프의 레시피 카드
async def는 코루틴을 만든다. 부르기만 하면 실행되지 않는다(was never awaited 경고).
asyncio.run(main()): 루프를 열고 main을 끝까지 돌리고 닫는다. 맨 바깥에서 한 번.
await: 끝날 때까지 기다리며 루프에 차례를 넘긴다. 차례는 await에서만 바뀐다.
await asyncio.gather(*coros): 동시에 기다리고 결과는 넣은 순서로. return_exceptions=True.
task = asyncio.create_task(coro): 먼저 맡겨 두기. 참조를 쥐고 있을 것(약한 참조).
asyncio.sleep은 양보하고, time.sleep은 루프를 멈춘다.
코루틴(쪽지) → 태스크(넣은 주문) ⊂ 퓨처(나중에 울릴 벨). 태스크 1만 개도 가볍다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.