오늘의 주문서
#10 · 열째 밤
위기
단체 예약 여섯 테이블. 3번 테이블이 알레르기 문제로 코스를 취소하자, 단체는 다 같이 다른 날 오겠다고 한다. 그런데 나머지 다섯 테이블의 냄비는 계속 끓고 있다.
목표
한 테이블이 실패하면 나머지 요리를 바로 멈추고, 어떤 요리도 정해진 시간을 넘기지 않게 한다.
심야 식당 동시식당
밤 열 시 반, 단체 손님 여섯 테이블이 한꺼번에 앉습니다. 루가 주문을 받아 주방에 넘기고, 주방은 여섯 코스를 동시에 시작합니다. 10분 뒤 3번 테이블에서 손을 듭니다. 땅콩 알레르기를 늦게 말했다고 합니다. 단체 대표는 "그럼 오늘은 다 같이 일어나겠습니다"라고 말합니다.
민호는 그 말을 못 듣고 5번 테이블의 찌개를 계속 끓입니다. 수아가 민호의 불을 끄며 말합니다. "한 테이블이 빠지면 다 멈춘다는 규칙이 어디에도 없었어요."
윤서연 셰프가 타이머를 누릅니다. "같이 시작한 일은 같이 끝나야 해. 그걸 코드로 약속하는 게 오늘 밤 일이야."
9교시의 asyncio.gather로 단체 예약을 처리해 봅시다. 2번 테이블이 0.1초 만에 실패합니다.
import asyncio
async def table(n, sec, fail=False):
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
async def main():
try:
await asyncio.gather(table(1, 0.3), table(2, 0.1, fail=True), table(3, 0.5))
except ValueError as e:
print("caught:", e)
await asyncio.sleep(0.6) # 가게를 조금 더 열어 둔다
asyncio.run(main())
# caught: T2 allergy
# T1 served
# T3 servedgather는 첫 예외를 곧바로 올려 보냅니다. 그래서 caught:가 먼저 찍힙니다.served를 찍습니다.gather(return_exceptions=True)는 예외를 결과 목록에 담아 줄 뿐, 나머지를 멈추지는 않습니다. "하나가 실패하면 모두 멈춘다"를 원한다면 다른 도구가 필요합니다.
asyncio.TaskGroup(3.11)은 태스크를 한 묶음으로 다룹니다. 이렇게 묶어 관리하는 방식을 구조화된 동시성(structured concurrency)이라고 부릅니다.
import asyncio
async def table(n, sec):
await asyncio.sleep(sec)
return f"T{n}"
async def main():
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, 0.1 * n)) for n in range(1, 4)]
# 여기 오면 세 태스크가 모두 끝났다
print([t.result() for t in tasks])
asyncio.run(main())
# ['T1', 'T2', 'T3']TaskGroup의 약속은 세 가지입니다.
async with 블록을 빠져나올 때, 그 안에서 만든 태스크는 모두 끝나 있습니다. 따로 await하지 않아도 됩니다.ExceptionGroup 하나에 모아 올립니다.tg.create_task가 태스크를 붙잡아 두므로, 9교시에서 본 "참조를 잃어 사라지는 태스크" 걱정도 없습니다. 이제 3번 테이블이 실패하는 단체 예약입니다.
import asyncio
async def table(n, sec, fail=False):
try:
await asyncio.sleep(sec)
if fail:
raise ValueError(f"T{n} allergy")
print(f"T{n} served")
except asyncio.CancelledError:
print(f"T{n} cancelled")
raise # 받은 취소는 다시 올린다
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(table(1, 0.05))
tg.create_task(table(2, 0.3))
tg.create_task(table(3, 0.1, fail=True))
tg.create_task(table(4, 0.5))
except* ValueError as eg:
print("group failed:", eg.exceptions)
await asyncio.sleep(0.6)
asyncio.run(main())
# T1 served
# T2 cancelled (T2와 T4의 순서는 실행마다 다르다)
# T4 cancelled
# group failed: (ValueError('T3 allergy'),)served는 더 찍히지 않습니다. gather와 달리 뒤에서 몰래 끓는 냄비가 없습니다.await 자리에서 asyncio.CancelledError가 터집니다. 그래서 except로 받아 뒤정리를 할 수 있습니다. 받은 취소는 꼭 다시 올립니다(10-5).아래 타임라인은 서버에서 진짜 TaskGroup을 돌립니다. 테이블 번호가 클수록 요리가 조금씩 오래 걸립니다(T1 0.3초, T2 0.345초, … T6 약 0.53초). 실패할 테이블로 고른 테이블은 제 요리가 끝날 즈음에 실패합니다.
주방 타임라인
주문
6
실패할 테이블
없음
시간 제한(초)
끔
요리
기다림
취소
실패
태스크 여럿이 동시에 실패할 수도 있습니다. 그래서 TaskGroup은 예외 하나가 아니라 예외 묶음을 올립니다.
import asyncio
async def boom(exc, sec):
await asyncio.sleep(sec)
raise exc
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(boom(ValueError("T2 allergy"), 0.1))
tg.create_task(boom(KeyError("T5"), 0.1))
except Exception as e:
print(type(e).__name__, "|", e)
print(e.exceptions)
asyncio.run(main())
# ExceptionGroup | unhandled errors in a TaskGroup (2 sub-exceptions)
# (ValueError('T2 allergy'), KeyError('T5'))ExceptionGroup(3.11)은 예외 목록을 .exceptions에 담은 예외입니다. 묶음 안에서 종류별로 골라 받으려면 except*를 씁니다.
eg = ExceptionGroup("kitchen", [ValueError("T2 allergy"), KeyError("T5"), ValueError("T6 salt")])
try:
raise eg
except* ValueError as g:
print("ValueError:", [str(e) for e in g.exceptions])
except* KeyError as g:
print("KeyError:", g.exceptions)
# ValueError: ['T2 allergy', 'T6 salt']
# KeyError: (KeyError('T5'),)except는 하나만 실행됩니다. except*는 맞는 블록이 모두 실행됩니다. 위에서는 두 블록이 다 돕니다.g는 언제나 ExceptionGroup입니다. 맞는 예외만 골라 담은 작은 묶음입니다.except*에도 맞지 않은 예외는 남은 묶음으로 다시 올라갑니다.eg = ExceptionGroup("kitchen", [ValueError("T2"), KeyError("T5")])
try:
try:
raise eg
except* ValueError:
print("handled ValueError")
except ExceptionGroup as rest:
print("left:", rest.exceptions)
print(isinstance(eg, Exception), eg.subgroup(ValueError).exceptions)
# handled ValueError
# left: (KeyError('T5'),)
# True (ValueError('T2'),)except*에는 규칙이 두 가지 있습니다. 둘 다 3.14에서 실행 전에 문법 오류로 막힙니다.
src1 = '''
def f():
try:
pass
except* ValueError:
return 1
'''
src2 = '''
try:
pass
except ValueError:
pass
except* KeyError:
pass
'''
for src in (src1, src2):
try:
compile(src, "<menu>", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
# SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
# SyntaxError: cannot have both 'except' and 'except*' on the same 'try'except* 블록 안에서는 return, break, continue를 쓸 수 없습니다. 여러 블록이 차례로 실행되어야 하는데, 중간에 빠져나가 버리면 안 되기 때문입니다. 결과는 변수에 담아 두고 try 바깥에서 돌려줍니다.try에 except와 except*를 섞을 수 없습니다. 둘 다 필요하면 try를 두 겹으로 나눕니다. 이 강의의 실습대 엔진도 그렇게 썼습니다.셰프의 한마디
except* 안에서 return 하고 싶어지면, 결과를 변수에 적어 두고 try 밖에서 돌려줘. 주방도 마찬가지야. 사고 보고는 다 받고 나서 퇴근하는 거지, 첫 보고만 듣고 문 닫고 나가면 안 돼.
오늘 밤의 두 번째 규칙은 "어떤 요리도 정해진 시간을 넘기지 않는다"입니다. asyncio.timeout()(3.11)은 블록 전체에 시간 제한을 겁니다.
import asyncio, time
async def stew(sec):
await asyncio.sleep(sec)
return "stew"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.2):
await stew(1.0)
except TimeoutError:
print(f"timeout after {time.perf_counter() - start:.2f}s")
try:
print(await asyncio.wait_for(stew(0.1), timeout=0.2))
print(await asyncio.wait_for(stew(1.0), timeout=0.2))
except TimeoutError:
print("wait_for: timeout")
print(asyncio.TimeoutError is TimeoutError)
asyncio.run(main())
# timeout after 0.20s
# stew
# wait_for: timeout
# TrueTimeoutError가 올라옵니다. 3.11부터 asyncio.TimeoutError는 내장 TimeoutError와 같은 것입니다.asyncio.wait_for(코루틴, timeout=)은 코루틴 하나에 제한을 거는 예전부터 있던 방법입니다. 결과를 바로 돌려받을 때 편합니다.asyncio.timeout은 여러 await를 한 블록으로 묶을 수 있습니다. "주문 받기부터 서빙까지 전부 합쳐 20분"처럼요.제한을 "몇 초 뒤"가 아니라 "몇 시 몇 분"으로 걸 수도 있고, 블록에 들어간 뒤에 바꿀 수도 있습니다.
import asyncio, time
async def main():
loop = asyncio.get_running_loop()
start = time.perf_counter()
deadline = loop.time() + 0.3 # 지금부터 0.3초 뒤라는 '시각'
try:
async with asyncio.timeout_at(deadline):
await asyncio.sleep(1)
except TimeoutError:
print(f"timeout_at: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
try:
async with asyncio.timeout(None) as cm: # 처음엔 제한 없음
cm.reschedule(loop.time() + 0.2) # 나중에 0.2초로 정한다
print(cm.when() is not None)
await asyncio.sleep(1)
except TimeoutError:
print(f"reschedule: {time.perf_counter() - start:.1f}s, expired={cm.expired()}")
asyncio.run(main())
# timeout_at: 0.3s
# True
# reschedule: 0.2s, expired=Truetimeout_at(시각)은 loop.time() 기준의 절대 시각을 받습니다. 여러 단계가 "마감 시각" 하나를 나눠 쓸 때 좋습니다.timeout(None)은 제한 없이 시작합니다. cm.reschedule(시각)으로 나중에 제한을 정하거나 미룰 수 있습니다.TaskGroup도, 시간 제한도 결국 같은 일을 합니다. 태스크에 취소를 보내는 것입니다. 직접 보낼 때는 task.cancel()을 씁니다.
import asyncio
async def braise():
print("stove on")
try:
await asyncio.sleep(10)
finally:
print("stove off") # 취소돼도 불은 끈다
async def main():
task = asyncio.create_task(braise())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled:", task.cancelled())
asyncio.run(main())
# stove on
# stove off
# cancelled: Truecancel()은 태스크를 그 자리에서 죽이지 않습니다. 태스크가 다음에 멈춰 있는 await 자리에서 CancelledError가 터지게 할 뿐입니다.try/finally에 둡니다. 취소든 성공이든 실패든 반드시 실행됩니다.CancelledError는 Exception이 아니라 BaseException을 물려받습니다. except Exception:이 취소를 실수로 삼키지 않게 하려는 설계입니다.취소를 삼키면 어떻게 될까요?
import asyncio, time
async def stubborn():
try:
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass # 취소를 삼켰다
await asyncio.sleep(0.5) # 그리고 계속 요리한다
return "late"
async def main():
start = time.perf_counter()
try:
async with asyncio.timeout(0.1):
print(await stubborn())
except TimeoutError:
print("timeout")
print(f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())
# late
# 0.6s0.1초 제한을 걸었는데 0.6초가 걸리고, TimeoutError도 나지 않았습니다. 시간 제한은 취소로 일하는데, 그 취소를 코루틴이 먹어 버렸기 때문입니다. TaskGroup의 "나머지 취소"도 똑같이 무너집니다. 그래서 CancelledError를 받았다면 뒤정리만 하고 다시 올립니다(raise).
셰프의 한마디
취소를 받았으면 불 끄고, 도마 닦고, 그대로 손 들어. '조금만 더 하면 되는데' 하고 계속 끓이는 순간 주방 규칙이 다 무너져. 민호, 너 말하는 거야.
영수증 저장처럼 중간에 끊기면 안 되는 일도 있습니다. asyncio.shield()는 바깥에서 온 취소가 안쪽 태스크까지 내려가지 않게 막습니다.
import asyncio
async def save_receipt():
await asyncio.sleep(0.3)
print("receipt saved")
return "ok"
async def main():
inner = asyncio.create_task(save_receipt())
try:
async with asyncio.timeout(0.1):
await asyncio.shield(inner)
except TimeoutError:
print("waiter gave up")
print(await inner) # 안쪽 태스크는 계속 돌았다
asyncio.run(main())
# waiter gave up
# receipt saved
# ok기다리던 쪽은 0.1초에 포기했지만, 안쪽 태스크는 끝까지 돌았습니다. 안쪽 태스크의 참조를 들고 있어야 나중에 결과를 받을 수 있습니다.
Task.cancelling()은 그 태스크에 들어온 취소 요청 수를, Task.uncancel()은 그 수를 하나 줄입니다. asyncio.timeout과 TaskGroup이 "이 취소는 내가 보낸 것인가"를 구분할 때 안에서 쓰는 도구입니다. 직접 쓸 일은 드뭅니다.
import asyncio
async def worker():
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
me = asyncio.current_task()
print("cancelling:", me.cancelling())
me.uncancel()
print("after uncancel:", me.cancelling())
return "rescued"
async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.05)
t.cancel()
print(await t)
asyncio.run(main())
# cancelling: 1
# after uncancel: 0
# rescued오늘 밤의 두 규칙을 한 주방에 겁니다. 테이블마다 0.6초 제한을 걸고, 여섯 테이블을 TaskGroup 하나로 묶습니다.
import asyncio, time
async def table(n, sec, allergy, stopped):
try:
async with asyncio.timeout(0.6): # 어떤 요리도 0.6초를 넘기지 않는다
await asyncio.sleep(sec)
if n == allergy:
raise ValueError(f"T{n} allergy")
return f"T{n}"
except asyncio.CancelledError:
stopped.append(f"T{n}") # 불 끄고 손 든다
raise
async def service(menu, allergy=None):
start, stopped = time.perf_counter(), []
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(table(n, s, allergy, stopped)) for n, s in menu.items()]
print("served:", [t.result() for t in tasks])
except* ValueError as eg:
print("cancelled by:", [str(e) for e in eg.exceptions], "stopped:", sorted(stopped))
except* TimeoutError as eg:
print("too slow:", len(eg.exceptions))
print(f"{time.perf_counter() - start:.1f}s")
MENU = {1: 0.30, 2: 0.35, 3: 0.20, 4: 0.40, 5: 0.45, 6: 0.50}
asyncio.run(service(MENU))
asyncio.run(service(MENU, allergy=3))
asyncio.run(service({**MENU, 6: 0.9}))
# served: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']
# 0.5s
# cancelled by: ['T3 allergy'] stopped: ['T1', 'T2', 'T4', 'T5', 'T6']
# 0.2s
# too slow: 1
# 0.6sTimeoutError가 묶음으로 올라오고, 역시 나머지가 멈춥니다.마감 무렵, 창가 자리의 손님이 국수 그릇을 비우고 작은 수첩을 덮습니다. 계산을 마치며 셰프에게 짧게 말합니다. "다음 주 금요일에 다시 올게요."
셰프는 고개를 끄덕이고 수셰프를 돌아봅니다. "취소도 시간 제한도 결국 await 자리에서 일어나. 그런데 주방 구석의 낡은 오븐은 await를 몰라. 루가 그 앞에 서서 기다리면 홀은 어떻게 될까?"
영업 일지 — 열째 밤. 단체 여섯 테이블을 TaskGroup 하나로 묶어, 3번 테이블이 빠지자 나머지 냄비가 동시에 꺼짐. 테이블마다 시간 제한을 걸었고, 취소를 삼키지 않는 규칙을 주방 벽에 붙임.
gather 예제를 TaskGroup으로 바꿔, 2번 테이블이 실패하면 1번과 3번이 cancelled를 찍게 만들어 보세요.table에서 raise를 지우고 그 자리에 await asyncio.sleep(0.3)(취소를 무시하고 계속 끓이기)을 넣은 뒤 allergy=3으로 돌려 보세요. 걸린 시간이 어떻게 바뀌는지 보고, 무엇이 잘못됐는지 적어 보세요.셰프의 레시피 카드
gather는 하나가 실패해도 나머지를 멈추지 않는다. 같이 끝나야 하는 일은 TaskGroup(3.11)으로 묶는다.
async with asyncio.TaskGroup() as tg: tg.create_task(...) — 블록을 나오면 모두 끝나 있고, 하나가 실패하면 나머지는 취소.
실패는 ExceptionGroup으로 모여 온다. except* ValueError 로 종류별로 받는다(return 금지, except와 섞기 금지).
시간 제한: async with asyncio.timeout(초) 또는 wait_for(코루틴, timeout=). 넘으면 내장 TimeoutError.
취소는 await 자리에서 CancelledError로 온다. finally로 뒤정리하고, 받은 취소는 다시 raise.
끊기면 안 되는 일은 asyncio.shield(task). cancelling()/uncancel()은 도구를 만드는 쪽의 장치.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.