오늘의 주문서
#06 · 여섯째 밤
위기
홀에서는 주문이 쏟아지는데, 민호와 수아는 같은 주문서를 동시에 집고 탁 씨는 할 일이 없다. 누가 무엇을 만들지 아무도 정하지 않았다.
목표
주문 12개를 요리사 셋이 나눠, 빠짐없이 한 번씩만 만든다. 그리고 모두 끝났다는 것을 확실히 안다.
심야 식당 동시식당
여섯째 밤, 금요일. 홀 직원이 주문서를 들고 주방으로 뛰어 들어와 아무에게나 건넵니다. 민호와 수아가 같은 주문서를 동시에 집어 들고, 같은 국수가 두 그릇 나옵니다. 그사이 탁 씨는 칼을 든 채 할 일을 기다립니다.
윤서연 셰프가 주방 입구 위에 긴 쇠막대를 박습니다. 주문서 꽂이입니다. "홀은 여기에 꽂기만 해. 요리사는 여기서 빼기만 해. 서로 말 걸지 마."
주문을 만들어 내는 쪽(홀 직원)과 처리하는 쪽(요리사)을 떼어 놓고, 그 사이에 줄을 하나 두는 설계를 생산자·소비자 패턴이라고 합니다. 파이썬에서는 queue.Queue가 그 줄입니다.
import queue
rail = queue.Queue() # 주문서 꽂이
for n in range(1, 5):
rail.put(f"#{n}") # 꽂는다
print(rail.qsize(), rail.get(), rail.get(), rail.qsize())
# 4 #1 #2 2먼저 꽂은 주문서가 먼저 나옵니다(FIFO, 선입선출). 이제 홀 직원 한 명과 요리사 두 명을 스레드로 돌려 봅시다.
import threading
import time
rail = queue.Queue()
served = []
def hall(): # 생산자: 주문을 받아 꽂는다
for n in range(1, 7):
time.sleep(0.05)
rail.put(f"#{n}")
def cook(name): # 소비자: 꽂이에서 빼서 만든다
for _ in range(3):
order = rail.get() # 꽂이가 비어 있으면 여기서 기다린다
time.sleep(0.1)
served.append((name, order))
ts = [threading.Thread(target=hall),
threading.Thread(target=cook, args=("minho",)),
threading.Thread(target=cook, args=("sua",))]
for t in ts: t.start()
for t in ts: t.join()
print(sorted(order for _, order in served))
# ['#1', '#2', '#3', '#4', '#5', '#6']Lock이 없습니다. Queue 안에 이미 잠금과 4교시의 Condition이 들어 있어서, put과 get이 알아서 한 번에 한 명씩 처리합니다. 큐가 잠금을 대신합니다.get()은 꽂이가 비어 있으면 주문서가 꽂힐 때까지 기다립니다. 요리사가 "주문 있어요?"를 계속 물으며 바쁘게 돌 필요가 없습니다.스레드끼리 데이터를 주고받아야 한다면, 공유 변수에 잠금을 거는 것보다 큐로 건네는 쪽을 먼저 생각하세요. 잠금을 직접 다루는 코드가 줄어들수록 3교시와 5교시의 사고도 줄어듭니다.
앞 예제는 요리사마다 "세 개만 만들고 퇴근"이라고 정해 두었습니다. 실제 주방에서는 주문이 몇 개 올지 모릅니다. 요리사는 계속 꽂이를 보고 있고, 셰프는 꽂은 주문이 모두 끝났는지만 알고 싶습니다.
rail = queue.Queue()
def cook(name):
while True:
order = rail.get()
time.sleep(0.1)
rail.task_done() # "이 주문서는 끝났다"
for name in ("minho", "sua", "tak"):
threading.Thread(target=cook, args=(name,), daemon=True).start()
start = time.perf_counter()
for n in range(1, 10):
rail.put(f"#{n}")
rail.join() # 꽂은 주문서가 모두 끝날 때까지
print(f"all 9 done: {time.perf_counter() - start:.2f}s")
# all 9 done: 0.30s큐는 안에 아직 안 끝난 주문 수를 세고 있습니다.
| 동작 | 안 끝난 주문 수 |
|---|---|
put() 한 번 | 1 늘어난다 |
task_done() 한 번 | 1 줄어든다 |
join() | 0이 될 때까지 기다린다 |
get()으로 꺼냈다고 끝난 게 아닙니다. 요리가 끝난 뒤 task_done()을 불러야 숫자가 줄어듭니다.while True로 영원히 꽂이를 보고 있으니 daemon=True로 만들었습니다. 주문이 모두 끝나면 join()이 풀리고, 프로그램이 끝날 때 요리사들도 함께 퇴근합니다.task_done()을 꺼낸 것보다 많이 부르면 ValueError: task_done() called too many times가 납니다.셰프의 한마디
task_done을 빼먹으면 join은 영원히 안 풀려. 요리 중에 예외가 나도 마찬가지고. 그래서 나는 늘 try/finally로 감싸. 'finally: rail.task_done()'. 접시를 깨뜨렸어도 주문서는 내려놔야지.
꽂이에 칸 수를 정할 수 있습니다. Queue(maxsize=2)면 주문서가 두 장까지만 꽂힙니다. 가득 찬 꽂이에 put()을 하면 넣는 쪽이 기다립니다. 주방이 밀리면 홀이 주문 받는 속도를 늦추는 셈입니다.
rail = queue.Queue(maxsize=2) # 주문서 두 장까지만 꽂힌다
rail.put("#1")
rail.put("#2")
print(rail.full())
# True
try:
rail.put("#3", timeout=0.5) # 0.5초 기다려도 자리가 안 나면
except queue.Full:
print("Full after 0.5s")
# Full after 0.5s
try:
rail.put_nowait("#3") # = put("#3", block=False)
except queue.Full:
print("Full right away")
# Full right away
print(rail.get_nowait(), rail.get(timeout=0.5))
# #1 #2
try:
rail.get(timeout=0.5) # 꽂이가 비었다
except queue.Empty:
print("Empty after 0.5s")
# Empty after 0.5s| 부르는 법 | 꽂이가 가득/비었을 때 |
|---|---|
put(x), get() | 자리가 날 때까지(주문서가 올 때까지) 기다린다 |
put(x, timeout=0.5), get(timeout=0.5) | 0.5초까지 기다리고 queue.Full / queue.Empty |
put_nowait(x), get_nowait() | 기다리지 않고 바로 queue.Full / queue.Empty |
maxsize를 주지 않거나 0이면 꽂이는 끝없이 길어집니다. 홀이 주방보다 훨씬 빠르면 주문서가 끝없이 쌓여 메모리를 채웁니다. 크기를 정해 두면 빠른 쪽이 느린 쪽에 맞춰 기다립니다. 이것을 배압(backpressure) 이라고 부릅니다.
rail = queue.Queue(maxsize=2)
def cook(): # 느린 요리사 한 명
while True:
rail.get()
time.sleep(0.1)
rail.task_done()
threading.Thread(target=cook, daemon=True).start()
start = time.perf_counter()
for n in range(1, 7):
rail.put(f"#{n}") # 빠른 홀: 자리가 없으면 put에서 기다린다
print(f"hall done at {time.perf_counter() - start:.1f}s")
rail.join()
print(f"kitchen done at {time.perf_counter() - start:.1f}s")
# hall done at 0.3s
# kitchen done at 0.6s홀은 주문서 여섯 장을 순식간에 꽂고 싶었지만, 꽂이가 두 칸이라 0.3초 동안 put에서 기다렸습니다. maxsize를 빼면 홀은 바로 끝나고, 꽂이에 주문서가 쌓입니다.
아래 타임라인에서 직접 확인해 보세요. 위쪽 줄이 홀 직원, 아래쪽 줄이 요리사입니다. 홀 직원은 주문을 받는 데 0.1초(주황색), 꽂이에 자리가 날 때까지 기다리는 시간(노란색)을 씁니다.
주방 타임라인
주문
6
홀 직원
1
요리사
3
꽂이 크기
3
요리
기다림
취소
실패
daemon=True로 요리사를 그냥 버려 두는 대신, "오늘 영업 끝"을 알려 깔끔하게 퇴근시키고 싶을 때가 있습니다. 전통적인 방법은 파수꾼(sentinel), 즉 "영업 끝"이라고 적힌 특별한 주문서를 꽂는 것입니다.
STOP = None # 파수꾼: "영업 끝" 주문서
rail = queue.Queue()
served = []
def cook(name):
while True:
order = rail.get()
if order is STOP:
break # 파수꾼을 받으면 퇴근
time.sleep(0.05)
served.append(order)
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
for _ in cooks:
rail.put(STOP) # 요리사 수만큼 꽂는다
for t in cooks: t.join()
print(len(served), [t.is_alive() for t in cooks])
# 6 [False, False]파수꾼은 요리사 수만큼 꽂아야 합니다. 하나만 꽂으면 한 명만 퇴근하고, 나머지는 빈 꽂이 앞에서 영원히 기다립니다. 꽂이가 FIFO라서, 파수꾼은 앞서 꽂힌 주문이 모두 빠진 뒤에야 나옵니다.
Python 3.13부터는 꽂이 자체를 닫을 수 있습니다. Queue.shutdown()을 부르면 그 뒤의 put()은 queue.ShutDown을 냅니다. get()은 남은 주문서를 다 내준 뒤에 queue.ShutDown을 냅니다. 요리사 수를 셀 필요가 없습니다.
rail = queue.Queue()
served = []
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown: # 꽂이가 닫혔고, 남은 주문서도 없다
return
time.sleep(0.05)
served.append(order)
rail.task_done()
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
rail.shutdown() # 더 받지 않는다. 꽂힌 주문서는 마저 만든다
for t in cooks: t.join()
print(len(served))
# 6
try:
rail.put("#7")
except queue.ShutDown:
print("rail is closed")
# rail is closed급하게 문을 닫아야 할 때는 shutdown(immediate=True)를 씁니다. 꽂이에 남은 주문서를 모두 버리고, 기다리던 get()도 곧바로 queue.ShutDown을 냅니다.
rail = queue.Queue()
served = []
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown:
return
time.sleep(0.1)
served.append(order)
rail.task_done()
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua")]
for t in cooks: t.start()
for n in range(1, 7):
rail.put(f"#{n}")
time.sleep(0.15) # 두 사람이 #3, #4를 만드는 중
rail.shutdown(immediate=True) # 꽂이에 남은 #5, #6은 버리고 지금 닫는다
rail.join() # 손에 든 #3, #4가 끝나면 풀린다
for t in cooks: t.join()
print(sorted(served), rail.qsize())
# ['#1', '#2', '#3', '#4'] 0shutdown() | shutdown(immediate=True) | |
|---|---|---|
새 put() | queue.ShutDown | queue.ShutDown |
| 꽂혀 있던 주문서 | 마저 내준다 | 버린다(안 끝난 주문 수에서도 뺀다) |
get() | 꽂이가 빈 뒤에 queue.ShutDown | 바로 queue.ShutDown |
| 요리사 손에 든 주문 | 끝까지 만든다 | 끝까지 만든다 |
immediate=True도 이미 손에 든 주문까지 빼앗지는 않습니다. 그래서 join()은 그 주문들의 task_done()이 불린 뒤에 풀립니다.
queue 모듈에는 꺼내는 순서가 다른 꽂이가 더 있습니다.
PriorityQueue: 작은 값부터 꺼냅니다. VIP 테이블 주문을 먼저 만들 때 씁니다. (우선순위, 번호, 주문) 튜플로 넣는 것이 관례입니다.
import itertools
vip_first = queue.PriorityQueue()
ticket = itertools.count(1) # 같은 우선순위면 먼저 온 순서
for prio, dish in [(2, "noodle"), (1, "VIP soup"), (2, "rice"), (1, "VIP noodle")]:
vip_first.put((prio, next(ticket), dish))
while not vip_first.empty():
print(vip_first.get())
# (1, 2, 'VIP soup')
# (1, 4, 'VIP noodle')
# (2, 1, 'noodle')
# (2, 3, 'rice')가운데 번호가 없으면 우선순위가 같을 때 세 번째 값끼리 비교합니다. 주문이 dict처럼 비교할 수 없는 값이면 터집니다.
pq = queue.PriorityQueue()
pq.put((1, {"dish": "soup"}))
try:
pq.put((1, {"dish": "rice"})) # 우선순위가 같으면 dict끼리 비교하다가
except TypeError as e:
print(e)
# '<' not supported between instances of 'dict' and 'dict'접시 더미처럼 마지막 것부터 꺼내는 LifoQueue, 가장 단순한 SimpleQueue도 있습니다.
plates = queue.LifoQueue() # 접시 더미: 마지막에 올린 것부터
for p in ("p1", "p2", "p3"):
plates.put(p)
print(plates.get(), plates.get(), plates.get())
# p3 p2 p1
simple = queue.SimpleQueue() # 가장 단순한 꽂이: maxsize, task_done, join이 없다
simple.put("#1")
print(simple.get(), hasattr(simple, "task_done"))
# #1 False| 꽂이 | 꺼내는 순서 | 쓰는 곳 |
|---|---|---|
Queue | 먼저 넣은 것부터 | 거의 모든 경우 |
PriorityQueue | 작은 값부터 | VIP 먼저, 급한 작업 먼저 |
LifoQueue | 마지막에 넣은 것부터 | 접시 더미, 최근 것부터 처리 |
SimpleQueue | 먼저 넣은 것부터 | 크기 제한과 완료 추적이 필요 없을 때 |
오늘 밤의 목표는 주문 12개를 요리사 셋이 빠짐없이 한 번씩만 만들고, 모두 끝났다는 것을 확실히 아는 것이었습니다. 홀 직원 두 명이 번갈아 주문을 받고, 꽂이는 네 칸입니다.
rail = queue.Queue(maxsize=4) # 꽂이는 네 칸
served = []
def hall(orders): # 홀 직원: 주문을 받아 꽂는다
for n in orders:
time.sleep(0.02)
rail.put(f"#{n}")
def cook(name):
while True:
try:
order = rail.get()
except queue.ShutDown:
return
time.sleep(0.1)
served.append((name, order))
rail.task_done()
start = time.perf_counter()
halls = [threading.Thread(target=hall, args=(range(k, 13, 2),)) for k in (1, 2)]
cooks = [threading.Thread(target=cook, args=(n,)) for n in ("minho", "sua", "tak")]
for t in halls + cooks: t.start()
for t in halls: t.join() # 주문 받기가 끝나면
rail.shutdown() # 꽂이를 닫고
rail.join() # 꽂힌 주문서가 모두 끝날 때까지
for t in cooks: t.join()
orders = [o for _, o in served]
print(len(orders), len(set(orders)), f"{time.perf_counter() - start:.2f}s")
# 12 12 0.44s (시간은 대략)만든 요리 12개, 서로 다른 주문도 12개. 같은 주문이 두 번 나오지도, 빠지지도 않았습니다. 탁 씨도 제 몫을 했습니다. 이 코드에는 Lock이 한 줄도 없습니다.
셰프가 꽂이를 툭 칩니다. "이제 홀과 주방이 서로 말을 안 해도 돼. 그런데 다음 주 금요일에 단체 주문이 30개 들어오면? 요리사를 30명 뽑을 거야?"
영업 일지 — 여섯째 밤. 주문서 꽂이(Queue)를 달자 같은 주문을 두 번 만드는 일이 사라짐. 꽂이를 닫고(shutdown) join으로 모든 주문이 끝난 것을 확인한 뒤 마감.
cook에서 #5 주문만 ValueError가 나게 바꿔 보세요. rail.join()이 풀릴까요? 영원히 기다리지 않도록 waiter = threading.Thread(target=rail.join, daemon=True)로 띄우고 waiter.join(timeout=1) 뒤 waiter.is_alive()를 찍어 확인한 다음, try/finally로 고쳐 보세요.PriorityQueue로 바꿔, 짝수 번 주문을 VIP로 먼저 만들게 해 보세요. shutdown()은 PriorityQueue에서도 똑같이 쓸 수 있습니다.셰프의 레시피 카드
생산자·소비자: 꽂는 쪽은 rail.put(x), 빼는 쪽은 rail.get(). 큐가 잠금을 대신한다.
모두 끝났나? 일이 끝날 때마다 rail.task_done(), 기다리는 쪽은 rail.join().
Queue(maxsize=n): 가득 차면 넣는 쪽이 기다린다(배압). timeout=, put_nowait/get_nowait, queue.Full/queue.Empty.
종료: 파수꾼 None을 요리사 수만큼, 또는 3.13부터 rail.shutdown() → queue.ShutDown. immediate=True면 남은 주문서를 버린다.
PriorityQueue((우선순위, 번호, 주문)), LifoQueue(접시 더미), SimpleQueue(가장 단순).
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.