오늘의 주문서
#12 · 열두째 밤
위기
창가 자리의 손님이 명함을 내민다. 미식 평론가. 오늘 밤 열두 접시 코스를 정식으로 주문한다. 3·7·11번째 접시는 처음 한 번 유난히 오래 걸린다.
목표
열두 접시를 모두 내되, 화구는 셋까지만 쓰고, 3초 안에 끝낸다.
심야 식당 동시식당
금요일 밤 열 시. 약속대로 창가 자리의 손님이 들어옵니다. 늘 혼자, 늘 국수 한 그릇, 늘 손목시계와 작은 수첩. 오늘은 자리에 앉자마자 명함 한 장을 테이블에 올려 둡니다. 미식 평론가.
"지난 몇 주 동안 이 주방을 봤어요. 오늘은 제대로 주문할게요. 코스 열두 접시로." 평론가가 수첩을 펴고 손목시계를 풉니다.
주방이 조용해집니다. 민호는 칼을 떨어뜨리고, 수아는 주문서 꽂이를 다시 정리하고, 탁 씨는 그저 화구를 닦습니다. 루가 주문서를 들고 들어옵니다. 윤서연 셰프가 목에 건 타이머를 0으로 돌립니다. "주방은 프로그램이다. 오늘은 그동안 만든 걸 전부 돌린다."
열한 번의 밤 동안 도구가 많이 쌓였습니다. 손님이 들어올 때 먼저 묻는 질문은 하나입니다. "이 일은 기다리는 일인가, 손이 바쁜 일인가?"
| 상황 | 꺼낼 도구 | 주방으로 치면 | 교시 |
|---|---|---|---|
| 기다리는 일이 아주 많다(연결 수백~수만) | asyncio (gather, TaskGroup) | 혼자 도는 홀 매니저 루 | 9~10 |
| 기다리는 일인데 동기 라이브러리뿐, 수십 개 | threading, ThreadPoolExecutor | 요리사 여럿, 출장 요리사 | 2, 7 |
| 계산이 많다(CPU) | ProcessPoolExecutor, multiprocessing (또는 3.14t의 스레드) | 분점 주방 | 8 |
| asyncio 안의 오래된 블로킹 코드 | asyncio.to_thread | 오븐 앞에 따로 선 사람 | 11 |
| asyncio 안의 무거운 계산 | loop.run_in_executor(프로세스 풀, ...) | 분점에 반죽 맡기기 | 11 |
| 여럿이 한 값을 고친다 | Lock, 또는 큐로 주인을 하나로 | 칠판 앞 잠금, 주문서 꽂이 | 3, 6 |
| 동시에 n개까지만 | Semaphore(n) | 화구 셋 | 4, 11 |
| 같이 끝나야 하고, 시간을 넘기면 안 된다 | TaskGroup, asyncio.timeout | 단체 예약 | 10 |
고르는 순서는 이렇습니다.
to_thread로 감쌉니다.평론가의 주문서는 이렇습니다. 접시는 열두 개, 한 접시에 0.5초. 그런데 3·7·11번째 접시는 처음 한 번은 재료가 덜 풀려 세 배(1.5초)가 걸립니다. 다시 하면 0.5초에 됩니다. 화구는 셋뿐입니다.
필요한 부품은 세 가지입니다. 화구 수를 지키는 세마포어, 한 접시가 늘어지지 않게 하는 시간 제한, 끊긴 접시를 다시 하는 재시도. 이것을 TaskGroup 하나에 담습니다.
import asyncio, time
SLOW = {3, 7, 11} # 처음 한 번은 세 배 걸리는 접시
async def plate(n, attempt):
await asyncio.sleep(0.5 * (3 if n in SLOW and attempt == 0 else 1))
async def order(n, stoves, limit_s, retries):
for attempt in range(retries + 1):
async with stoves: # 화구를 잡은 동안만 요리한다
try:
async with asyncio.timeout(limit_s):
await plate(n, attempt)
return True
except TimeoutError:
pass # 화구를 내려놓고 줄 끝으로 가서 다시
return False
async def service(stoves_n, limit_s, retries):
stoves = asyncio.Semaphore(stoves_n)
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(order(n, stoves, limit_s, retries)) for n in range(1, 13)]
served = sum(t.result() for t in tasks)
print(f"stoves={stoves_n} limit={limit_s} retries={retries}: "
f"served {served}/12, {time.perf_counter() - start:.1f}s")
asyncio.run(service(3, None, 0)) # 시간 제한 없음
asyncio.run(service(3, 0.6, 0)) # 제한만
asyncio.run(service(3, 0.6, 1)) # 제한 + 재시도 한 번
# stoves=3 limit=None retries=0: served 12/12, 3.5s
# stoves=3 limit=0.6 retries=0: served 9/12, 2.2s
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s
# (걸린 시간은 실행마다 0.1초쯤 다르다. 첫 줄은 3.5s나 3.6s)asyncio.timeout(None)은 제한 없음입니다. 그래서 같은 함수로 "제한 없음"과 "0.6초 제한"을 함께 돌릴 수 있습니다.설계에서 짚을 점이 두 가지 있습니다.
async with stoves가 for 안에 있어서, 잘린 접시는 화구를 풀고 다시 줄을 섭니다. 화구를 쥔 채 재시도하면 그사이 다른 접시가 놉니다.False로 돌려줍니다. TaskGroup에서 예외가 나면 나머지가 모두 취소됩니다(10교시). 단체 예약이라면 그게 맞지만, 코스는 한 접시가 실패해도 나머지는 내야 합니다. 같은 이유로 asyncio.gather로 모아도 됩니다.실무에서는 재시도 사이에 await asyncio.sleep(0.05 * 2 ** attempt)처럼 조금씩 길게 쉬는 경우가 많습니다. 모두가 동시에 다시 몰려드는 것을 막기 위해서입니다(5교시의 라이브락과 같은 이유).
아래 타임라인은 서버에서 같은 처리기를 돌립니다. 줄 하나가 접시 하나이고, 시간 제한에 잘린 시도는 회색, 재시도는 r1, r2가 붙습니다. 처음 설정은 일부러 목표를 맞추지 못하게 해 두었습니다.
주방 타임라인
주문
6
화구
2
시간 제한(초)
끔
재시도
0
요리
기다림
취소
실패
셰프의 한마디
시간 제한은 '보통 접시보다는 길게, 망친 접시보다는 짧게'야. 보통 접시까지 자르면 재시도를 아무리 붙여도 소용없어. 숫자를 정하기 전에 보통 걸리는 시간부터 재.
평론가의 코스에는 반죽(계산), 낡은 오븐의 빵(블로킹), 화구 요리(기다림)가 함께 있습니다. 각각을 맞는 사람에게 맡기고, 홀의 루가 모두 기다립니다. 프로세스를 쓰므로 파일로 저장해 실행합니다.
import asyncio, threading, time
from concurrent.futures import ProcessPoolExecutor
def knead(n): # 반죽: 손이 바쁜 일 → 분점 주방(프로세스)
return sum(i * i for i in range(n)) % 97
tally_lock = threading.Lock() # 오븐 스레드와 루프가 함께 고치는 장부
tally = {"plates": 0}
def count_plate():
with tally_lock: # 잠금 안에는 await도, 긴 일도 없다
tally["plates"] += 1
def old_oven(item): # 낡은 오븐: 블로킹 → 스레드
time.sleep(0.4)
count_plate()
return f"{item} baked"
async def stove_dish(n, stoves): # 화구 요리: 세마포어
async with stoves:
await asyncio.sleep(0.2)
count_plate()
return f"#{n}"
async def main():
loop = asyncio.get_running_loop()
stoves = asyncio.Semaphore(3)
with ProcessPoolExecutor(max_workers=2) as dough_room:
async with asyncio.timeout(5): # 코스 전체의 마감
results = await asyncio.gather(
loop.run_in_executor(dough_room, knead, 3_000_000),
asyncio.to_thread(old_oven, "bread"),
*(stove_dish(n, stoves) for n in range(1, 7)),
)
print(results)
print(tally)
if __name__ == "__main__":
asyncio.run(main())
# [56, 'bread baked', '#1', '#2', '#3', '#4', '#5', '#6']
# {'plates': 7}| 일 | 맡은 사람 | 도구 |
|---|---|---|
| 홀 전체, 기다리기 | 루 | asyncio.run, gather |
| 반죽(계산) | 분점 주방 | run_in_executor + ProcessPoolExecutor |
| 낡은 오븐(블로킹) | 오븐 앞 요리사 | asyncio.to_thread |
| 화구 요리 | 화구 셋 | asyncio.Semaphore(3) |
| 장부(공유 상태) | 칠판 잠금 | threading.Lock (스레드와 루프가 함께 쓰므로) |
| 마감 | 셰프의 타이머 | asyncio.timeout(5) |
장부는 오븐 스레드와 루프 양쪽에서 고치므로 threading.Lock을 썼습니다. 루프 쪽에서 이 잠금을 잡아도 괜찮은 이유는, 잠금 안에 await가 없고 숫자 하나만 고치고 바로 풀기 때문입니다. 11교시에서 본 사고는 잠금을 쥔 채 await로 양보할 때 납니다.
평론가 앞에서 주방이 멈추지 않도록, 영업 전에 이 여섯 가지를 확인합니다.
| 점검 | 증상 | 처방 | 교시 |
|---|---|---|---|
| 공유 상태 | 숫자가 가끔 틀린다 | 읽기~쓰기를 Lock으로 묶거나, 큐로 주인을 하나로 | 3, 11 |
| 잠금 순서 | 가끔 영원히 멈춘다 | 모두 같은 순서로 잠근다, acquire(timeout=) | 5 |
| 블로킹 호출 | asyncio가 통째로 느리다 | to_thread, debug=True로 찾기 | 11 |
| 삼킨 취소 | 시간 제한과 TaskGroup이 안 듣는다 | CancelledError는 뒤정리 후 다시 raise | 10 |
| 참조 없는 태스크 | 백그라운드 일이 가끔 사라진다 | 태스크를 모아 붙잡거나 TaskGroup | 9 |
| 끝나지 않는 큐 | join()이 돌아오지 않는다 | 가져간 만큼 task_done(), 종료 신호(None, shutdown()) | 6, 11 |
"참조 없는 태스크"의 처방은 이렇습니다. 태스크를 집합에 넣어 붙잡아 두고, 끝나면 스스로 빠지게 합니다.
import asyncio
running = set() # 태스크를 붙잡아 두는 곳
async def wipe(table):
await asyncio.sleep(0.05)
print("wiped", table)
async def main():
for t in ("T1", "T2"):
task = asyncio.create_task(wipe(t))
running.add(task)
task.add_done_callback(running.discard) # 끝나면 스스로 빠진다
await asyncio.sleep(0.1)
print(len(running))
asyncio.run(main())
# wiped T1
# wiped T2
# 0"끝나지 않는 큐"는 task_done()을 하나라도 빠뜨리면 생깁니다. 의심될 때는 join()에 시간 제한을 걸어 확인합니다.
import asyncio
async def cook(q):
while True:
await q.get()
await asyncio.sleep(0.01)
# q.task_done() ← 잊어버렸다
async def main():
q = asyncio.Queue()
for n in range(3):
q.put_nowait(n)
worker = asyncio.create_task(cook(q))
try:
async with asyncio.timeout(0.3):
await q.join()
except TimeoutError:
print("join never finished:", q.qsize(), "in queue")
worker.cancel()
asyncio.run(main())
# join never finished: 0 in queue큐는 비었는데(0 in queue) join()은 끝나지 않습니다. 꺼낸 주문서 세 장이 "다 했다"는 표시를 받지 못했기 때문입니다.
지난 밤들의 주방을 한 실습대에 모았습니다. 같은 주문 여덟 개를 방식만 바꿔 돌려 보세요. 요리사와 화구 슬라이더는 그 값을 쓰는 방식에서만 효과가 있습니다.
주방 타임라인
주문
6
요리사
3
화구
2
요리
기다림
취소
실패
평론가의 코스를 맞춘 설정은 화구 3, 시간 제한 0.6초, 재시도 1입니다. 서버의 실습대 엔진으로 직접 돌려 확인한 값입니다.
| 화구 | 시간 제한 | 재시도 | 서빙 · 실패 | 걸린 시간 | 목표 |
|---|---|---|---|---|---|
| 2 | 0.4초 | 0 | 0 · 12 | 약 2.4초 | 실패 |
| 3 | 끔 | 0 | 12 · 0 | 약 3.5~3.6초 | 시간 초과 |
| 4 | 끔 | 0 | 12 · 0 | 약 3.0초 | 화구 초과 |
| 3 | 0.4초 | 2 | 0 · 12 | 약 4.9초 | 실패 |
| 3 | 0.6초 | 0 | 9 · 3 | 약 2.2초 | 3접시 실패 |
| 3 | 0.6초 | 1 | 12 · 0 | 약 2.7초 | 성공 |
# 12-2의 service 함수로
asyncio.run(service(3, 0.6, 1))
# stoves=3 limit=0.6 retries=1: served 12/12, 2.7s열두 번째 접시가 나가고 2.7초. 셰프의 타이머가 멈춥니다. 평론가가 수첩에 몇 줄을 적고, 국수 그릇을 비운 뒤 일어섭니다. 계산대 앞에서 셰프에게 수첩의 한 장을 찢어 건넵니다.
"열두 접시가 한 번도 식지 않고 나왔어요. 기다림은 있었지만, 멍하니 기다리는 사람은 없었어요. 또 올게요. 이번엔 손님으로."
평론가가 나가고, 주방에 불이 하나씩 꺼집니다. 셰프가 앞치마 주머니에서 마지막 레시피 카드를 꺼내 수셰프에게 건넵니다. "열두 밤이었어. 내일 밤 열 시에도 문은 열려. 이번엔 네가 주문서를 쓸 차례야. 첫 줄에 뭘 적을래?"
| 교시 | 그 밤 | 남은 한 줄 |
|---|---|---|
| 1 | 첫날 밤의 줄: 동시성과 병렬성 | 기다리는 일과 손이 바쁜 일을 나눈다. 동시성은 번갈아, 병렬성은 같은 순간에. |
| 2 | 요리사를 더 뽑다: threading 기초 | Thread(target=), start(), join(). 결과는 직접 담아 온다. |
| 3 | 칠판의 숫자가 틀렸다: 경쟁 상태와 Lock | 읽기~쓰기 사이에 끼어들면 숫자가 틀린다. with lock:은 짧게. |
| 4 | 화구는 셋뿐: 동기화 도구 | Semaphore, Event, Condition, Barrier. |
| 5 | 칼과 도마: 데드락 | 같은 순서로 잠그거나, 못 잡으면 내려놓는다. |
| 6 | 주문서 꽂이: queue | 생산자와 소비자를 큐로 떼어 놓고, task_done()과 join()으로 끝을 안다. |
| 7 | 출장 요리사 파견: concurrent.futures | ThreadPoolExecutor, Future, as_completed와 map. |
| 8 | 반죽은 왜 안 빨라질까: GIL과 병렬 | 계산은 프로세스로, 또는 3.14t로. |
| 9 | 혼자 도는 홀 매니저: asyncio 첫걸음 | async def, await, gather, create_task. |
| 10 | 단체 예약 취소: 구조화된 동시성 | TaskGroup, except*, asyncio.timeout, 취소는 다시 올린다. |
| 11 | 멈춰 선 홀: 블로킹과 섞어 쓰기 | 블로킹은 to_thread로, asyncio판 도구는 asyncio에서만. |
영업 일지 — 열두째 밤. 평론가의 열두 접시를 화구 3, 시간 제한 0.6초, 재시도 1로 약 2.7초에 모두 냄. 홀은 asyncio, 반죽은 프로세스, 낡은 오븐은 스레드, 화구는 세마포어. 내일부터 주문서는 수셰프가 쓴다.
await asyncio.sleep(0.05 * 2 ** attempt))을 넣어 보세요. 화구를 잡기 전에 쉬어야 할까요, 잡은 뒤에 쉬어야 할까요? 둘 다 돌려 걸린 시간을 비교해 보세요.셰프의 레시피 카드
먼저 묻는다: 기다리는 일인가(asyncio, 스레드), 손이 바쁜 일인가(프로세스, 3.14t)?
asyncio 안의 블로킹은 to_thread, 무거운 계산은 run_in_executor(프로세스 풀).
자원 한도는 Semaphore, 시간 한도는 asyncio.timeout, 함께 끝낼 일은 TaskGroup.
재시도는 화구를 내려놓고 줄 끝에서. 시간 제한은 '보통보다 길게, 망친 것보다 짧게'.
점검 목록: 공유 상태, 잠금 순서, 블로킹 호출, 삼킨 취소, 참조 없는 태스크, 끝나지 않는 큐.
주방은 프로그램이다. 막대를 그려 보면 보인다.
더 배우고 싶다면
제휴 링크이 강의 다음에 이어서 보기 좋은 강의입니다. 제휴 링크라서 이 링크로 수강하면 ReflexKR에 수수료가 돌아가고, 수강료는 똑같습니다. 고르는 기준
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.