R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

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

2

요리사를 더 뽑다: threading 기초

3

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

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

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

9

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

10

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

11

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

12

평론가의 밤: 종합

심야 주방의 동시성
6교시

강의 시간 4:10–5:00

주문서 꽂이: queue

오늘의 주문서

#06 · 여섯째 밤

주문서 꽂이

위기

홀에서는 주문이 쏟아지는데, 민호와 수아는 같은 주문서를 동시에 집고 탁 씨는 할 일이 없다. 누가 무엇을 만들지 아무도 정하지 않았다.

목표

주문 12개를 요리사 셋이 나눠, 빠짐없이 한 번씩만 만든다. 그리고 모두 끝났다는 것을 확실히 안다.

심야 식당 동시식당

여섯째 밤, 금요일. 홀 직원이 주문서를 들고 주방으로 뛰어 들어와 아무에게나 건넵니다. 민호와 수아가 같은 주문서를 동시에 집어 들고, 같은 국수가 두 그릇 나옵니다. 그사이 탁 씨는 칼을 든 채 할 일을 기다립니다.

윤서연 셰프가 주방 입구 위에 긴 쇠막대를 박습니다. 주문서 꽂이입니다. "홀은 여기에 꽂기만 해. 요리사는 여기서 빼기만 해. 서로 말 걸지 마."

6-1. 꽂는 사람과 빼는 사람 (8분)

주문을 만들어 내는 쪽(홀 직원)과 처리하는 쪽(요리사)을 떼어 놓고, 그 사이에 줄을 하나 두는 설계를 생산자·소비자 패턴이라고 합니다. 파이썬에서는 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교시의 사고도 줄어듭니다.

6-2. 모든 주문이 끝났다는 것을 아는 법: task_done과 join (8분)

앞 예제는 요리사마다 "세 개만 만들고 퇴근"이라고 정해 두었습니다. 실제 주방에서는 주문이 몇 개 올지 모릅니다. 요리사는 계속 꽂이를 보고 있고, 셰프는 꽂은 주문이 모두 끝났는지만 알고 싶습니다.

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()'. 접시를 깨뜨렸어도 주문서는 내려놔야지.

6-3. 꽂이가 가득 차면 (10분)

꽂이에 칸 수를 정할 수 있습니다. 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

요리

기다림

취소

실패

홀 직원 2명, 요리사 2명, 꽂이 3칸, 주문 10개. 꽂이를 1로 줄이면 홀 줄의 노란 기다림이 길어지고, 8로 늘리면 홀은 0.5초쯤에 끝납니다. 요리사를 늘리면 전체 시간이 줄어듭니다.
  • 시작 값(요리사 2명, 꽂이 3칸)으로 돌리면 약 1.6초가 걸리고, 꽂이에는 주문서가 최대 3장 걸립니다. 홀 직원은 0.3초쯤부터 기다리기 시작합니다.
  • 꽂이를 8칸으로 늘리면 홀 직원은 기다리지 않고 약 0.5초에 일을 마칩니다. 그래도 전체 시간은 그대로 약 1.6초입니다. 주방이 느린 것은 꽂이를 늘려도 해결되지 않습니다.
  • 요리사를 4명으로 늘리면 전체 시간이 약 1.0초로 줄어듭니다. 병목이 어디인지는 노란 막대가 어느 줄에 길게 생기는지 보면 압니다.

6-4. 영업 끝 신호: 파수꾼과 shutdown (9분)

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'] 0
shutdown()shutdown(immediate=True)
새 put()queue.ShutDownqueue.ShutDown
꽂혀 있던 주문서마저 내준다버린다(안 끝난 주문 수에서도 뺀다)
get()꽂이가 빈 뒤에 queue.ShutDown바로 queue.ShutDown
요리사 손에 든 주문끝까지 만든다끝까지 만든다

immediate=True도 이미 손에 든 주문까지 빼앗지는 않습니다. 그래서 join()은 그 주문들의 task_done()이 불린 뒤에 풀립니다.

6-5. 다른 모양의 꽂이 (5분)

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먼저 넣은 것부터크기 제한과 완료 추적이 필요 없을 때

영업 마감 (10분)

오늘 밤의 목표는 주문 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으로 모든 주문이 끝난 것을 확인한 뒤 마감.

실습 과제

  1. 6-2의 cook에서 #5 주문만 ValueError가 나게 바꿔 보세요. rail.join()이 풀릴까요? 영원히 기다리지 않도록 waiter = threading.Thread(target=rail.join, daemon=True)로 띄우고 waiter.join(timeout=1) 뒤 waiter.is_alive()를 찍어 확인한 다음, try/finally로 고쳐 보세요.
  2. 타임라인에서 홀 직원 4명, 요리사 1명, 꽂이 1칸으로 돌려 보세요. 노란 막대가 어느 줄에 생기는지, 병목이 어디인지 설명해 보세요.
  3. 영업 마감 코드를 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(가장 단순).

← 이전

5교시 · 칼과 도마: 데드락

다음 →

7교시 · 출장 요리사 파견: concurrent.futures

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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