오늘의 주문서
#07 · 일곱째 밤
위기
회식 단체 주문 30개가 한꺼번에 들어왔다. 요리사를 30명 뽑을 수는 없다. 스레드를 하나하나 만들고 결과를 리스트에 모으는 코드도 이제 지겹다.
목표
주문 30개를 출장 요리사 여섯 명에게 맡겨 1초 안에, 끝나는 대로 내보낸다. 태운 접시가 있어도 놓치지 않는다.
심야 식당 동시식당
일곱째 밤 열 시 반. 전화가 울리고, 셰프가 수화기를 내려놓으며 말합니다. "회식 단체, 30인분."
민호가 앞치마를 고쳐 매며 묻습니다. "스레드를 30개 만들까요?" 셰프가 고개를 젓습니다. "사람을 뽑으면 재워 주고 월급 주고 내보내는 것까지 다 우리 일이야. 오늘은 파견 회사에 전화한다. 사람은 그쪽이 관리하고, 우리는 일만 던져."
concurrent.futures.ThreadPoolExecutor는 스레드를 미리 몇 개 두고, 일이 들어오면 빈 스레드에 맡기는 파견 회사입니다. 2교시에 직접 하던 Thread 만들기, start, join, 결과 담을 리스트 준비를 대신해 줍니다.
import time
from concurrent.futures import ThreadPoolExecutor
def cook(n):
time.sleep(0.2)
return f"#{n} ready"
with ThreadPoolExecutor(max_workers=3) as agency: # 출장 요리사 세 명
fut = agency.submit(cook, 1) # 일을 맡기면 '교환권'(Future)을 준다
print(fut.done()) # 아직 요리 중
print(fut.result()) # 끝날 때까지 기다렸다가 결과를 받는다
print(fut.done())
# False
# #1 ready
# Truesubmit(함수, 인자...)는 일을 맡기고 곧바로 Future 를 돌려줍니다. 음식이 아니라 "음식을 받을 교환권"입니다. print(fut)를 찍어 보면 <Future at 0x... state=running>처럼 상태가 보입니다.fut.result()는 요리가 끝날 때까지 기다렸다가 함수의 반환값을 돌려줍니다. 2교시에서 "스레드는 반환값을 돌려주지 않는다"며 리스트에 담던 문제가 여기서 풀립니다.with 블록을 빠져나올 때 shutdown(wait=True)가 불려, 맡긴 일이 모두 끝날 때까지 기다리고 스레드를 정리합니다.이제 30개를 맡겨 봅시다. 요리사는 다섯 명뿐이니, 한 사람이 여섯 개씩 차례로 맡게 됩니다.
import threading
def cook(n):
time.sleep(0.1)
return threading.current_thread().name
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5, thread_name_prefix="agency") as agency:
futures = [agency.submit(cook, n) for n in range(1, 31)] # 주문 30개
# with 블록을 나오면 모든 작업이 끝나 있다
names = {f.result() for f in futures}
print(len(futures), sorted(names), f"{time.perf_counter() - start:.2f}s")
# 30 ['agency_0', 'agency_1', 'agency_2', 'agency_3', 'agency_4'] 0.60s주문은 30개인데 스레드는 다섯 개만 만들어졌습니다. 0.1초짜리 주문 30개를 다섯 명이 나눠 0.6초. 스레드를 만들고 없애는 비용을 한 번만 치르고, 같은 요리사가 계속 다음 주문을 받습니다. 이것을 스레드 풀(thread pool) 이라고 합니다.
결과를 받는 방법은 두 가지입니다. 이번에는 #1 주문이 가장 오래 걸리게 만들어 봅니다.
from concurrent.futures import as_completed
def cook(n):
time.sleep(0.1 * (5 - n)) # #1이 가장 오래 걸린다 (0.4초 … 0.1초)
return f"#{n}"
with ThreadPoolExecutor(max_workers=4) as agency:
start = time.perf_counter()
futures = [agency.submit(cook, n) for n in range(1, 5)]
for f in as_completed(futures): # 끝난 순서
print(f.result(), f"{time.perf_counter() - start:.1f}s", end=" ")
print()
start = time.perf_counter()
for dish in agency.map(cook, range(1, 5)): # 넣은 순서
print(dish, f"{time.perf_counter() - start:.1f}s", end=" ")
print()
# #4 0.1s #3 0.2s #2 0.3s #1 0.4s
# #1 0.4s #2 0.4s #3 0.4s #4 0.4sas_completed(futures) | executor.map(함수, 값들) | |
|---|---|---|
| 돌려주는 것 | 끝난 Future를 하나씩 | 결과 값을 하나씩 |
| 순서 | 끝난 순서 | 넣은 순서 |
| 첫 결과 | 가장 빨리 끝난 것이 나오자마자 | #1이 끝날 때까지 기다린다 |
| 어울리는 곳 | 끝나는 대로 내보내기, 진행률 표시 | 결과를 입력과 짝지어 그대로 쓸 때 |
map은 #4가 0.1초에 끝났어도 #1이 끝나는 0.4초까지 내주지 않습니다. 넣은 순서를 지켜야 하니까요. 전체 시간은 같지만, 첫 접시가 나가는 시간이 다릅니다.
as_completed는 Future만 돌려주니, 어느 주문의 결과인지 알려면 dict로 짝을 지어 두는 것이 관례입니다.
with ThreadPoolExecutor(max_workers=4) as agency:
table_of = {agency.submit(cook, n): f"T{n}" for n in range(1, 5)} # Future → 테이블
for fut in as_completed(table_of):
print(table_of[fut], fut.result())
# T4 #4
# T3 #3
# T2 #2
# T1 #1아래 타임라인의 "출장 요리사"도 진짜 ThreadPoolExecutor로 돕니다. 이 시나리오는 뒤 번호 주문일수록 짧게 끝나도록 만들어 두었습니다.
주방 타임라인
주문
6
요리사
3
결과 받는 순서
요리
기다림
취소
실패
as_completed에서는 돌아온 순서가 예를 들어 #3 → #2 → #1 → #6 → #5 → #4 → …처럼 뒤섞입니다. 세 명이 동시에 시작한 주문 중 짧은 것이 먼저 끝나기 때문입니다.map에서는 언제나 #1 → #2 → … → #10입니다.as_completed의 돌아온 순서는 #8 → #7 → … → #1처럼 거의 거꾸로 됩니다. 여덟 주문이 한꺼번에 시작해, 짧은 뒤 번호부터 끝나기 때문입니다.맡긴 일 안에서 예외가 나면, 그 예외는 Future 안에 조용히 보관됩니다. result()를 부르는 순간 그 자리에서 다시 터집니다.
def cook(n):
if n == 3:
raise ValueError(f"#{n} burnt") # 3번 주문을 태웠다
return f"#{n}"
with ThreadPoolExecutor(max_workers=2) as agency:
futures = [agency.submit(cook, n) for n in range(1, 5)]
# 여기까지는 아무 일도 일어나지 않은 것처럼 조용하다
for f in futures:
try:
print(f.result())
except ValueError as e: # result()를 부를 때 터진다
print("error:", e)
# #1
# #2
# error: #3 burnt
# #4
print(repr(futures[2].exception()), futures[0].exception())
# ValueError('#3 burnt') Noneresult()를 한 번도 부르지 않으면, 예외는 아무 데도 출력되지 않고 사라집니다. 태운 접시가 손님에게 나가지 않았는데 아무도 모르는 셈입니다.exception()은 예외를 터뜨리지 않고 꺼내 줍니다. 성공했으면 None입니다.map은 결과를 꺼내다가 그 자리에서 예외를 터뜨리고, 그 뒤 결과는 받을 수 없습니다. 하나가 실패해도 나머지를 받아야 한다면 submit과 as_completed를 쓰세요.끝나자마자 무언가를 하고 싶다면 add_done_callback으로 종을 달아 둡니다. 끝난 Future 하나를 인자로 받는 함수를 넘깁니다.
def cook(n):
time.sleep(0.1)
return f"#{n}"
def ring_bell(fut): # 끝나면 불리는 함수(콜백). Future 하나를 받는다
print("bell:", fut.result(), threading.current_thread().name)
with ThreadPoolExecutor(max_workers=2, thread_name_prefix="agency") as agency:
for n in (1, 2):
agency.submit(cook, n).add_done_callback(ring_bell)
# 예:
# bell: #1 agency_0
# bell: #2 agency_1콜백은 일을 끝낸 출장 요리사의 스레드에서 불립니다(이미 끝난 Future에 달면 단 사람의 스레드에서 바로 불립니다). 그래서 콜백 안에서 공유 데이터를 고친다면 3교시의 잠금 규칙이 그대로 적용됩니다. 콜백 안의 예외는 로그로만 남고 무시됩니다.
셰프의 한마디
submit만 하고 result를 안 부르는 코드, 현장에서 제일 많이 본 실수야. 예외가 교환권 안에서 잠들어 버려. 맡긴 일은 반드시 result로 받든지, exception으로 확인하든지 해.
cancel()은 아직 시작하지 않은 일만 취소할 수 있습니다. 이미 불에 올라간 요리는 되돌릴 수 없습니다.
with ThreadPoolExecutor(max_workers=1) as agency: # 요리사 한 명
futures = [agency.submit(time.sleep, 0.2) for _ in range(3)]
time.sleep(0.05) # 첫 주문이 불에 올라갈 틈
print([f.cancel() for f in futures]) # 이미 시작한 것은 못 물린다
print([f.cancelled() for f in futures], [f.done() for f in futures])
# [False, True, True]
# [False, True, True] [False, True, True]cancel()은 취소에 성공하면 True, 이미 돌고 있거나 끝났으면 False를 돌려줍니다.done()은 끝났거나 취소됐으면 True입니다. 첫 주문은 아직 요리 중이라 False입니다.파견 회사와의 계약을 한꺼번에 끝낼 때는 shutdown(cancel_futures=True)를 씁니다. 줄 서 있던 일은 모두 취소하고, 이미 시작한 일만 끝까지 기다립니다.
agency = ThreadPoolExecutor(max_workers=2)
futures = [agency.submit(time.sleep, 0.2) for _ in range(6)]
time.sleep(0.05)
agency.shutdown(cancel_futures=True) # 줄 선 주문은 취소, 불 위의 주문은 끝까지
print(sum(f.cancelled() for f in futures), sum(not f.cancelled() for f in futures))
# 4 2여러 일 중 하나라도 끝나면 움직이고 싶을 때는 wait를 씁니다.
from concurrent.futures import FIRST_COMPLETED, wait
def cook(sec):
time.sleep(sec)
return sec
with ThreadPoolExecutor(max_workers=3) as agency:
futures = [agency.submit(cook, s) for s in (0.3, 0.1, 0.2)]
done, pending = wait(futures, return_when=FIRST_COMPLETED) # 하나라도 끝나면
print([f.result() for f in done], len(pending))
done, pending = wait(futures, timeout=0.15) # 기본은 ALL_COMPLETED
print(len(done), len(pending))
# [0.1] 2
# 2 1wait는 끝난 것과 안 끝난 것, 두 집합을 돌려줍니다. return_when에는 FIRST_COMPLETED, FIRST_EXCEPTION, ALL_COMPLETED(기본값)가 있고, timeout이 지나면 다 끝나지 않았어도 돌아옵니다. 예외를 내지 않으니, pending이 비었는지 직접 확인해야 합니다.
concurrent.futures의 좋은 점은 계약서가 같다는 것입니다. submit, map, Future, as_completed는 그대로 두고 회사 이름만 바꾸면 됩니다.
from concurrent.futures import InterpreterPoolExecutor, ProcessPoolExecutor, ThreadPoolExecutor
def knead(n):
return sum(range(n))
if __name__ == "__main__": # 프로세스 풀에는 꼭 필요하다(8교시)
for Agency in (ThreadPoolExecutor, ProcessPoolExecutor, InterpreterPoolExecutor):
with Agency(max_workers=2) as agency:
print(Agency.__name__, list(agency.map(knead, [10, 100, 1000])))
# ThreadPoolExecutor [45, 4950, 499500]
# ProcessPoolExecutor [45, 4950, 499500]
# InterpreterPoolExecutor [45, 4950, 499500]| 회사 | 요리사 | 어울리는 일 | 교시 |
|---|---|---|---|
ThreadPoolExecutor | 같은 주방의 스레드 | 기다리는 일(I/O) | 7 |
ProcessPoolExecutor | 분점 주방(프로세스) | 손이 바쁜 일(CPU) | 8 |
InterpreterPoolExecutor | 한 건물 안 칸막이 주방(서브인터프리터), 3.14부터 | 손이 바쁜 일, 프로세스보다 가볍게 | 8 |
이 예제는 대화형 셸이 아니라 .py 파일로 저장해 실행하세요. 프로세스 풀이 왜 if __name__ == "__main__":을 요구하는지는 다음 밤에 봅니다.
max_workers를 생략하면 스레드 풀은 min(32, os.process_cpu_count() + 4)명을 부릅니다(3.12까지는 os.cpu_count()). 12코어인 이 PC에서는 16명입니다. 기다리는 일에는 코어 수보다 많은 스레드가 필요하기 때문에 4를 더하고, 너무 많아지지 않게 32에서 자릅니다. 실제 서비스에서는 상대 서버가 감당할 수 있는 수를 생각해 직접 정하는 편이 좋습니다.
오늘 밤의 목표는 주문 30개를 출장 요리사 여섯 명에게 맡겨 1초 안에 끝나는 대로 내보내고, 태운 접시도 놓치지 않는 것이었습니다. 주문마다 0.1~0.2초가 걸리고, 13번 주문은 탑니다.
def cook(n):
time.sleep(0.1 + 0.05 * (n % 3)) # 0.1~0.2초
if n == 13:
raise ValueError(f"#{n} burnt")
return f"#{n}"
served, burnt = [], []
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=6, thread_name_prefix="agency") as agency:
futures = {agency.submit(cook, n): n for n in range(1, 31)}
for fut in as_completed(futures): # 끝나는 대로 내보낸다
try:
served.append(fut.result())
except ValueError:
burnt.append(futures[fut])
print(len(served), burnt, f"{time.perf_counter() - start:.2f}s")
# 29 [13] 0.80s (시간은 대략)29접시를 끝나는 대로 내보냈고, 태운 13번은 다시 만들 목록에 올랐습니다. 0.8초. 스레드를 만드는 코드도, 결과를 담을 리스트를 미리 준비하는 코드도 없습니다.
창가 자리의 손님이 오늘도 혼자 국수 한 그릇을 앞에 두고 있습니다. 단체 접시가 쏟아져 나가는 동안 손목시계를 한 번 보더니, 작은 수첩에 짧게 무언가를 적습니다.
셰프가 출장 요리사들을 돌려보내며 말합니다. "내일은 반죽이 산더미야. 출장 요리사를 넷 불러서 반죽을 맡기면, 네 배 빨라질까?"
영업 일지 — 일곱째 밤. 단체 주문 30건을 출장 요리사 6명(ThreadPoolExecutor)에게 맡겨 0.8초에 처리. 13번을 태웠으나 result()에서 바로 잡아 다시 만듦.
as_completed 대신 agency.map(cook, range(1, 31))로 바꿔 보세요. 13번에서 무슨 일이 일어나고, 몇 접시를 받을 수 있나요?wait(..., return_when=FIRST_COMPLETED)와 shutdown(cancel_futures=True)로 써 보세요. 이미 시작한 요리는 몇 개였나요?셰프의 레시피 카드
with ThreadPoolExecutor(max_workers=n) as ex: fut = ex.submit(f, x); fut.result()
Future는 교환권: done(), result(), exception(), cancel(), add_done_callback(fn).
as_completed(futures)는 끝난 순서, ex.map(f, xs)는 넣은 순서.
작업 안의 예외는 result()를 부를 때 터진다. 부르지 않으면 조용히 사라진다.
cancel()은 시작 전 작업만. 한꺼번에는 ex.shutdown(cancel_futures=True). 하나만 기다리기는 wait(fs, return_when=FIRST_COMPLETED).
같은 계약서: ProcessPoolExecutor(8교시), InterpreterPoolExecutor(3.14). 기본 스레드 수는 min(32, os.process_cpu_count() + 4).
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.