R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

첫날 밤의 줄: 동시성과 병렬성

2

요리사를 더 뽑다: threading 기초

3

칠판의 숫자가 틀렸다: 경쟁 상태와 Lock

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

반죽은 왜 안 빨라질까: GIL과 병렬

9

혼자 도는 홀 매니저: asyncio 첫걸음

10

단체 예약 취소: 구조화된 동시성

11

멈춰 선 홀: 블로킹과 섞어 쓰기

12

평론가의 밤: 종합

심야 주방의 동시성
10교시

강의 시간 7:30–8:20

단체 예약 취소: 구조화된 동시성

오늘의 주문서

#10 · 열째 밤

단체 예약 취소

위기

단체 예약 여섯 테이블. 3번 테이블이 알레르기 문제로 코스를 취소하자, 단체는 다 같이 다른 날 오겠다고 한다. 그런데 나머지 다섯 테이블의 냄비는 계속 끓고 있다.

목표

한 테이블이 실패하면 나머지 요리를 바로 멈추고, 어떤 요리도 정해진 시간을 넘기지 않게 한다.

심야 식당 동시식당

밤 열 시 반, 단체 손님 여섯 테이블이 한꺼번에 앉습니다. 루가 주문을 받아 주방에 넘기고, 주방은 여섯 코스를 동시에 시작합니다. 10분 뒤 3번 테이블에서 손을 듭니다. 땅콩 알레르기를 늦게 말했다고 합니다. 단체 대표는 "그럼 오늘은 다 같이 일어나겠습니다"라고 말합니다.

민호는 그 말을 못 듣고 5번 테이블의 찌개를 계속 끓입니다. 수아가 민호의 불을 끄며 말합니다. "한 테이블이 빠지면 다 멈춘다는 규칙이 어디에도 없었어요."

윤서연 셰프가 타이머를 누릅니다. "같이 시작한 일은 같이 끝나야 해. 그걸 코드로 약속하는 게 오늘 밤 일이야."

10-1. gather의 빈틈: 하나가 실패해도 나머지는 계속 끓는다 (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 served
  • gather는 첫 예외를 곧바로 올려 보냅니다. 그래서 caught:가 먼저 찍힙니다.
  • 그런데 나머지 두 테이블은 취소되지 않습니다. 아무도 기다리지 않는 요리가 뒤에서 계속 끓다가 served를 찍습니다.
  • 실제 프로그램이라면 이미 떠난 손님에게 음식을 내거나, 아무도 결과를 받지 않는 요청이 계속 돕니다.

gather(return_exceptions=True)는 예외를 결과 목록에 담아 줄 뿐, 나머지를 멈추지는 않습니다. "하나가 실패하면 모두 멈춘다"를 원한다면 다른 도구가 필요합니다.

10-2. TaskGroup: 같이 시작한 일은 같이 끝난다 (12분)

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의 약속은 세 가지입니다.

  1. async with 블록을 빠져나올 때, 그 안에서 만든 태스크는 모두 끝나 있습니다. 따로 await하지 않아도 됩니다.
  2. 태스크 하나가 예외를 내면, 아직 돌고 있는 나머지를 모두 취소합니다.
  3. 취소가 끝나면 실패한 예외들을 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'),)
  • T1은 실패보다 먼저 끝났으니 그대로 서빙됩니다.
  • T3이 0.1초에 실패하는 순간, 아직 끓던 T2와 T4에 취소가 들어갑니다. 취소 메시지가 찍히는 순서는 실행마다 다를 수 있습니다.
  • 0.6초를 더 기다려도 served는 더 찍히지 않습니다. gather와 달리 뒤에서 몰래 끓는 냄비가 없습니다.
  • 취소된 태스크 안에서는 await 자리에서 asyncio.CancelledError가 터집니다. 그래서 except로 받아 뒤정리를 할 수 있습니다. 받은 취소는 꼭 다시 올립니다(10-5).

아래 타임라인은 서버에서 진짜 TaskGroup을 돌립니다. 테이블 번호가 클수록 요리가 조금씩 오래 걸립니다(T1 0.3초, T2 0.345초, … T6 약 0.53초). 실패할 테이블로 고른 테이블은 제 요리가 끝날 즈음에 실패합니다.

주방 타임라인

주문

6

실패할 테이블

없음

시간 제한(초)

끔

요리

기다림

취소

실패

단체 여섯 테이블. 실패할 테이블이 3이면 T1, T2는 먼저 끝나고, T3이 빨간 막대로 끝나는 순간 T4~T6이 회색(취소)으로 잘립니다. 실패할 테이블을 1이나 6으로도 바꿔 보세요. '없음'이면 모두 완료, 시간 제한을 0.4초쯤으로 줄이면 그때까지 못 끝낸 테이블이 취소됩니다.
  • 처음 설정(실패할 테이블 3)에서는 T1, T2가 먼저 끝나고, 약 0.4초에 T3이 실패하는 순간 T4~T6 세 막대가 같은 순간에 회색으로 끊깁니다. 이미 끝난 T1, T2는 그대로 남습니다.
  • 실패할 테이블을 1로 바꾸면 0.3초에 나머지 다섯이 모두 잘리고, 6으로 바꾸면 다섯 테이블이 다 끝난 뒤에 T6만 실패합니다.
  • 실패를 "없음"으로 두면 여섯 막대가 끝까지 갑니다. 가장 늦은 T6이 약 0.53초에 끝납니다.
  • 시간 제한을 0.4초쯤으로 줄이면 앞쪽 테이블 몇 개만 끝나고, 제한 시각에 남아 있던 테이블은 회색으로 잘립니다. 이미 끝난 요리가 되돌려지지는 않습니다.

10-3. ExceptionGroup과 except* (7분)

태스크 여럿이 동시에 실패할 수도 있습니다. 그래서 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 밖에서 돌려줘. 주방도 마찬가지야. 사고 보고는 다 받고 나서 퇴근하는 거지, 첫 보고만 듣고 문 닫고 나가면 안 돼.

10-4. 시간 제한: asyncio.timeout과 wait_for (8분)

오늘 밤의 두 번째 규칙은 "어떤 요리도 정해진 시간을 넘기지 않는다"입니다. 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
# True
  • 시간이 넘으면 블록 안의 일이 취소되고, 블록 바깥으로 내장 TimeoutError가 올라옵니다. 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=True
  • timeout_at(시각)은 loop.time() 기준의 절대 시각을 받습니다. 여러 단계가 "마감 시각" 하나를 나눠 쓸 때 좋습니다.
  • timeout(None)은 제한 없이 시작합니다. cm.reschedule(시각)으로 나중에 제한을 정하거나 미룰 수 있습니다.

10-5. 취소: cancel, CancelledError, finally (6분)

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: True
  • cancel()은 태스크를 그 자리에서 죽이지 않습니다. 태스크가 다음에 멈춰 있는 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.6s

0.1초 제한을 걸었는데 0.6초가 걸리고, TimeoutError도 나지 않았습니다. 시간 제한은 취소로 일하는데, 그 취소를 코루틴이 먹어 버렸기 때문입니다. TaskGroup의 "나머지 취소"도 똑같이 무너집니다. 그래서 CancelledError를 받았다면 뒤정리만 하고 다시 올립니다(raise).

셰프의 한마디

취소를 받았으면 불 끄고, 도마 닦고, 그대로 손 들어. '조금만 더 하면 되는데' 하고 계속 끓이는 순간 주방 규칙이 다 무너져. 민호, 너 말하는 거야.

10-6. 취소를 막는 shield, 취소를 세는 cancelling (2분)

영수증 저장처럼 중간에 끊기면 안 되는 일도 있습니다. 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

영업 마감 (10분)

오늘 밤의 두 규칙을 한 주방에 겁니다. 테이블마다 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.6s
  • 평소 밤에는 여섯 테이블이 함께 끓어, 가장 긴 T6에 맞춰 약 0.5초에 끝납니다.
  • 3번 테이블이 0.2초에 실패하면, 나머지 다섯이 그 자리에서 불을 끕니다.
  • 한 요리가 0.6초를 넘기면 그 테이블의 TimeoutError가 묶음으로 올라오고, 역시 나머지가 멈춥니다.

마감 무렵, 창가 자리의 손님이 국수 그릇을 비우고 작은 수첩을 덮습니다. 계산을 마치며 셰프에게 짧게 말합니다. "다음 주 금요일에 다시 올게요."

셰프는 고개를 끄덕이고 수셰프를 돌아봅니다. "취소도 시간 제한도 결국 await 자리에서 일어나. 그런데 주방 구석의 낡은 오븐은 await를 몰라. 루가 그 앞에 서서 기다리면 홀은 어떻게 될까?"

영업 일지 — 열째 밤. 단체 여섯 테이블을 TaskGroup 하나로 묶어, 3번 테이블이 빠지자 나머지 냄비가 동시에 꺼짐. 테이블마다 시간 제한을 걸었고, 취소를 삼키지 않는 규칙을 주방 벽에 붙임.

실습 과제

  1. 타임라인에서 실패할 테이블을 1부터 6까지 바꿔 보세요. 실패한 테이블보다 번호가 작은 테이블만 살아남는 이유를 설명해 보세요. 그다음 실패를 "없음"으로 두고 시간 제한을 0.3초부터 0.55초까지 올리며, 끝까지 가는 테이블이 늘어나는 모습을 보세요.
  2. 10-1의 gather 예제를 TaskGroup으로 바꿔, 2번 테이블이 실패하면 1번과 3번이 cancelled를 찍게 만들어 보세요.
  3. 영업 마감 코드의 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()은 도구를 만드는 쪽의 장치.

← 이전

9교시 · 혼자 도는 홀 매니저: asyncio 첫걸음

다음 →

11교시 · 멈춰 선 홀: 블로킹과 섞어 쓰기

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex