오늘의 주문서
#02 · 둘째 밤
위기
민호, 수아, 탁 씨가 첫 출근했다. 셋 다 열심히 움직이는데, 누가 언제 무엇을 끝냈는지 아무도 모른다.
목표
주문 여섯 개를 세 사람이 나눠 0.6초 안에 끝내고, 누가 몇 초에 무엇을 끝냈는지 기록을 남긴다.
심야 식당 동시식당
둘째 밤 아홉 시 반. 라인 쿡 세 사람이 앞치마를 맵니다. 민호는 벌써 칼을 들었고, 수아는 주문서를 순서대로 정리하고 있고, 탁 씨는 천천히 화구를 닦습니다.
열 시가 되자 주문이 들어오고, 셋은 각자 냄비를 올립니다. 그런데 30분 뒤, 셰프가 "3번 테이블 우동 누가 했어?" 하고 묻자 아무도 대답하지 못합니다.
셰프가 목에 건 타이머를 툭 칩니다. "요리사를 뽑는 것까진 했어. 오늘은 이름표를 달고, 일을 맡기고, 끝날 때까지 기다리는 법을 배운다."
파이썬에서 요리사 한 명은 스레드(thread) 하나입니다. 같은 주방(메모리)을 함께 쓰면서, 각자 자기 일을 따로 진행합니다. 스레드는 threading.Thread로 만듭니다.
import threading
import time
def cook(name, dish):
print(f"{name}: start {dish}")
time.sleep(0.3) # 국이 끓기를 기다린다
print(f"{name}: done {dish}")
minho = threading.Thread(target=cook, args=("minho", "ramen"))
minho.start() # 민호가 일을 시작한다
print("chef: next order") # 셰프는 기다리지 않고 다음 일을
minho.join() # 민호가 끝날 때까지 기다린다
print("chef: served")
# minho: start ramen
# chef: next order
# minho: done ramen
# chef: servedtarget=에는 스레드가 할 일(함수)을, args=에는 그 함수에 넘길 인자를 튜플로 줍니다. 함수를 부르는 게 아니라 함수 자체를 넘기니 cook()이 아니라 cook입니다.Thread(...)를 만든 것만으로는 아무 일도 일어나지 않습니다. 요리사를 뽑기만 한 상태입니다. start()를 불러야 새 스레드에서 cook이 돌기 시작합니다.start()는 곧바로 돌아옵니다. 그래서 민호가 라면을 끓이는 동안 셰프(프로그램을 처음 시작한 메인 스레드)는 다음 줄로 넘어갑니다.join()은 그 스레드가 끝날 때까지 기다립니다. "민호가 접시를 내밀 때까지 패스 앞에서 기다린다"입니다.세 사람에게 하나씩 맡기면 이렇게 됩니다.
def cook(name, dish):
time.sleep(0.3)
print(f"{name}: done {dish}")
crew = [("minho", "ramen"), ("sua", "udon"), ("tak", "soba")]
threads = [threading.Thread(target=cook, args=c) for c in crew]
start = time.perf_counter()
for th in threads:
th.start() # 셋 모두 먼저 출발시키고
for th in threads:
th.join() # 그다음에 모두를 기다린다
print(f"{time.perf_counter() - start:.2f}s")
# 예:
# sua: done udon
# minho: done ramen
# tak: done soba
# 0.30s세 사람이 끝나는 순서는 실행할 때마다 다릅니다. 셋 다 0.3초 뒤에 거의 동시에 끝나서, 운영체제가 누구를 먼저 깨우느냐에 달렸기 때문입니다. 동시성 코드에서 "출력 순서"는 약속이 아닙니다.
start()와 join()을 한 반복문에 같이 쓰면 안 됩니다. th.start(); th.join()을 번갈아 하면 한 사람이 끝나야 다음 사람이 출발하니, 결국 0.9초가 걸립니다.
기다리는 데 한도를 둘 수도 있습니다. join(timeout=초)는 그 시간만 기다리고 돌아오며, 아직 일하는 중인지는 is_alive()로 확인합니다.
slow = threading.Thread(target=time.sleep, args=(1.0,))
slow.start()
slow.join(timeout=0.2)
print(slow.is_alive()) # True: 아직 끓는 중
slow.join()
print(slow.is_alive()) # False주방 타임라인
주문
6
요리사
3
요리
기다림
취소
실패
Thread를 만들고 start(), join()을 부릅니다.Thread에는 run()이라는 메서드도 있습니다. 이름만 보면 "실행"인데, 직접 부르면 함정에 빠집니다.
def cook(name, dish):
time.sleep(0.3)
print(f"{threading.current_thread().name}: done {dish}")
threads = [threading.Thread(target=cook, args=c) for c in crew]
start = time.perf_counter()
for th in threads:
th.run() # start()가 아니라 run()
print(f"{time.perf_counter() - start:.2f}s")
# MainThread: done ramen
# MainThread: done udon
# MainThread: done soba
# 0.90srun()은 target을 부르는 평범한 메서드입니다. 새 스레드를 만들지 않고 지금 스레드에서 순서대로 실행합니다. 그래서 세 번 모두 MainThread가 했고, 시간도 0.3 × 3 = 0.9초입니다.start()입니다. start()가 새 스레드를 만들고, 그 스레드 안에서 run()을 부릅니다.스레드는 한 번만 출발할 수 있습니다. 같은 스레드에 start()를 두 번 부르면 오류가 납니다. 같은 일을 또 하려면 Thread를 새로 만드세요.
th = threading.Thread(target=time.sleep, args=(0.1,))
th.start()
th.start()
# RuntimeError: threads can only be started once셰프의 한마디
args=("ramen")에는 쉼표가 없어서 튜플이 아니라 그냥 문자열이야. 그러면 r, a, m, e, n 다섯 글자가 따로 넘어가서 'cook() takes 1 positional argument but 5 were given'이 터지지. 인자가 하나면 꼭 args=("ramen",)처럼 쉼표를 찍어. 그리고 스레드 안에서 난 예외는 셰프한테 안 올라와. 화면에 'Exception in thread ...'와 traceback만 찍히고 프로그램은 그냥 계속 가. 조용히 넘어가지 않게 로그를 꼭 봐.
오늘 밤의 문제는 "누가 했는지 모른다"였습니다. 스레드에는 이름표를 달 수 있습니다.
def cook(dish):
me = threading.current_thread() # 이 코드를 실행 중인 스레드
print(me.name, dish, threading.get_native_id())
time.sleep(0.2)
a = threading.Thread(target=cook, args=("ramen",), name="minho")
b = threading.Thread(target=cook, args=("udon",)) # 이름을 안 주면?
a.start(); b.start()
print([t.name for t in threading.enumerate()])
print(threading.active_count())
a.join(); b.join()
print(threading.current_thread() is threading.main_thread())
print([t.name for t in threading.enumerate()])
# 예:
# minho ramen 25680
# Thread-1 (cook) udon 38072
# ['MainThread', 'minho', 'Thread-1 (cook)']
# 3
# True
# ['MainThread']| 도구 | 돌려주는 것 |
|---|---|
Thread(name="minho") | 스레드에 이름을 붙인다. 안 주면 Thread-1 (cook)처럼 번호와 함수 이름이 붙는다 |
threading.current_thread() | 지금 이 줄을 실행하고 있는 스레드 객체 |
threading.main_thread() | 프로그램을 시작한 메인 스레드(이름은 MainThread). 오늘 주방에선 셰프 |
threading.enumerate() | 지금 살아 있는 스레드 목록(메인 스레드 포함) |
threading.active_count() | 그 목록의 길이 |
threading.get_native_id() | 운영체제가 붙인 스레드 번호. 리눅스의 top -H 같은 시스템 도구에 보이는 번호와 같다 |
b가 Thread-2가 아니라 Thread-1인 것은, 번호가 이름 없는 스레드에만 매겨지기 때문입니다.get_native_id()의 숫자는 실행할 때마다 달라집니다. 파이썬 안에서 쓰는 번호는 th.ident(또는 threading.get_ident())이고, 둘 다 스레드가 출발한 뒤에야 정해집니다.enumerate()에서 빠집니다. 퇴근한 요리사는 명단에 없습니다.함수는 return으로 결과를 돌려주지만, 스레드는 그 값을 받아 주지 않습니다. target 함수의 반환값은 그냥 버려지고, join()도 아무것도 돌려주지 않습니다.
def make(dish):
time.sleep(0.1)
return f"{dish} ok"
th = threading.Thread(target=make, args=("ramen",))
th.start()
print(th.join()) # join은 아무것도 돌려주지 않는다
# None그래서 결과를 받을 그릇을 미리 만들어 두고, 스레드가 거기에 담게 합니다. 가장 쉬운 방법은 요리사마다 자기 칸을 정해 주는 것입니다.
dishes = ["ramen", "udon", "soba"]
results = [None] * len(dishes) # 자리를 미리 만든다
def cook(i, dish):
results[i] = make(dish) # 자기 칸에만 쓴다
threads = [threading.Thread(target=cook, args=(i, d)) for i, d in enumerate(dishes)]
for th in threads:
th.start()
for th in threads:
th.join()
print(results)
# ['ramen ok', 'udon ok', 'soba ok']이름으로 찾고 싶으면 dict를 씁니다.
done = {}
def cook2(name, dish):
done[name] = make(dish)
ts = [threading.Thread(target=cook2, args=(n, d)) for n, d in [("minho", "ramen"), ("sua", "udon")]]
for t in ts:
t.start()
for t in ts:
t.join()
print(sorted(done.items()))
# [('minho', 'ramen ok'), ('sua', 'udon ok')]join()이 끝난 뒤에 읽습니다. 그전에 읽으면 아직 None인 칸이 있을 수 있습니다.ThreadPoolExecutor가 돌려주는 Future가 이 일을 대신합니다. future.result()로 접시를 직접 받을 수 있습니다.파이썬 프로그램은 메인 스레드가 끝나도, daemon이 아닌 스레드가 모두 끝날 때까지 기다렸다가 종료합니다. 기본으로 만든 스레드는 모두 daemon이 아닙니다.
def clean():
for i in range(1, 6):
print("cleaning", i)
time.sleep(0.1)
th = threading.Thread(target=clean) # daemon=False (기본값)
th.start()
time.sleep(0.25)
print("lights off")
# cleaning 1
# cleaning 2
# cleaning 3
# lights off
# cleaning 4
# cleaning 5셰프가 "불 끈다"고 외쳐도, 청소하던 요리사가 다 끝낼 때까지 식당 문이 닫히지 않습니다. 같은 코드에 daemon=True만 붙여 보겠습니다.
th = threading.Thread(target=clean, daemon=True)
th.start()
time.sleep(0.25)
print("lights off", th.daemon)
# cleaning 1
# cleaning 2
# cleaning 3
# lights off Truejoin()을 부르면 됩니다. join()은 daemon 여부와 상관없이 기다립니다.start() 전에 정합니다. 따로 정하지 않으면 만든 스레드의 값을 물려받는데, 메인 스레드는 daemon이 아니라서 보통은 False입니다.Thread를 상속해 run을 바꾸기. 요리사에게 자기 상태(맡은 요리, 결과)를 붙여 두고 싶을 때 씁니다. 2-2의 이야기대로 start()가 새 스레드에서 run()을 부르니, run()만 다시 쓰면 됩니다.
class Cook(threading.Thread):
def __init__(self, name, dish):
super().__init__(name=name) # 꼭 부모의 __init__부터
self.dish = dish
self.result = None
def run(self): # start()가 새 스레드에서 이걸 부른다
time.sleep(0.1)
self.result = f"{self.dish} ok"
tak = Cook("tak", "soba")
tak.start()
tak.join()
print(tak.name, tak.result)
# tak soba okthreading.Timer: n초 뒤에 한 번. 면 삶는 타이머처럼, 정해진 시간이 지나면 함수를 한 번 부르는 스레드입니다. 울리기 전이라면 cancel()로 끌 수 있습니다.
start = time.perf_counter()
def ring(msg):
print(f"{time.perf_counter() - start:.1f}s", msg)
noodles = threading.Timer(0.5, ring, args=("noodles up",))
noodles.start() # 0.5초 뒤 한 번 울린다
eggs = threading.Timer(1.0, ring, args=("eggs up",))
eggs.start()
eggs.cancel() # 울리기 전에 끄면 아무 일도 없다
noodles.join()
eggs.join()
print("end")
# 0.5s noodles up
# endthreading.local(): 요리사마다 따로 쓰는 앞치마 주머니. 같은 이름의 변수인데, 스레드마다 다른 값을 담습니다. 누가 넣었는지에 따라 주머니가 다릅니다.
pocket = threading.local()
def cook(name, n):
pocket.name = name
pocket.count = 0
for _ in range(n):
pocket.count += 1
time.sleep(0.01)
print(pocket.name, pocket.count)
ts = [threading.Thread(target=cook, args=a) for a in [("minho", 3), ("sua", 5)]]
for t in ts:
t.start()
for t in ts:
t.join()
print(hasattr(pocket, "name")) # 셰프(메인 스레드)의 주머니는 비어 있다
# minho 3
# sua 5
# Falsepocket은 전역 변수 하나인데도, 민호의 count와 수아의 count는 섞이지 않습니다. 스레드마다 데이터베이스 연결을 하나씩 들고 있게 할 때 자주 쓰는 방법입니다.
셰프의 한마디
스레드를 만들 땐 이름부터 붙여. 새벽에 로그를 열었는데 Thread-7, Thread-12만 잔뜩 찍혀 있으면 누가 사고 쳤는지 절대 못 찾아. logging 형식에 %(threadName)s 하나만 넣어 두면 줄마다 이름이 찍혀.
오늘 밤의 목표는 주문 여섯 개를 세 사람이 나눠 0.6초 안에 끝내고, 누가 몇 초에 무엇을 끝냈는지 남기는 것이었습니다. 이름표(name=)를 달고, 각자 끝낼 때마다 공용 일지에 한 줄씩 적게 합니다.
start = time.perf_counter()
log = [] # (끝난 시각, 요리사, 주문)
def cook(dishes):
me = threading.current_thread().name
for dish in dishes:
time.sleep(0.3)
log.append((round(time.perf_counter() - start, 1), me, dish))
plan = {"minho": ["#1", "#4"], "sua": ["#2", "#5"], "tak": ["#3", "#6"]}
threads = [threading.Thread(target=cook, args=(d,), name=n) for n, d in plan.items()]
for th in threads:
th.start()
for th in threads:
th.join()
for row in sorted(log):
print(*row)
print(f"total {time.perf_counter() - start:.2f}s, {len(log)} orders")
# 0.3 minho #1
# 0.3 sua #2
# 0.3 tak #3
# 0.6 minho #4
# 0.6 sua #5
# 0.6 tak #6
# total 0.60s, 6 ordersargs=(d,)에 쉼표가 있습니다.log.append(...)는 한 번에 끝나는 리스트 연산이라 여러 스레드가 함께 불러도 줄이 사라지지 않습니다. 일지는 join() 뒤에 정렬해서 읽습니다.셰프가 일지를 훑어보고 고개를 끄덕입니다. "좋아, 이제 누가 뭘 했는지는 알겠어. 그럼 내일은 하나만 더 해 보자. 넷이 각자 만든 개수를 칠판 숫자 하나에 더해 적으면, 그 숫자는 맞을까?"
영업 일지 — 둘째 밤. 민호, 수아, 탁 씨 첫 출근. 이름표를 달고 주문 6건을 둘씩 나눠 0.6초에 끝냄. 누가 몇 초에 무엇을 냈는지 일지로 남김.
start()와 join()을 한 반복문 안에 함께 쓰도록 바꾸고, 시간이 몇 초가 되는지 예상한 뒤 재 보세요.sleep 시간을 다르게). 전체 시간은 누가 정하나요? 타임라인에서 줄 하나만 길어지는 모습과 비교해 보세요.threading.Timer로 "0.3초마다 한 번씩 세 번" 울리는 주방 타이머를 만들어 보세요. Timer는 한 번만 울리니, 울릴 때 다음 Timer를 새로 시작해야 합니다.셰프의 레시피 카드
th = threading.Thread(target=함수, args=(인자,), name="minho") → th.start() → th.join()
run()을 직접 부르면 새 스레드 없이 지금 스레드에서 순서대로. 스레드는 start()로, 한 번만.
누가 하나: current_thread().name, main_thread(), enumerate(), get_native_id()
스레드는 반환값을 돌려주지 않는다. 미리 만든 리스트 칸이나 dict 키에 담고, join() 뒤에 읽는다.
daemon=True는 프로그램이 끝날 때 기다려 주지 않는다. 끝까지 해야 하는 일엔 쓰지 않는다.
Thread 상속(run 오버라이드), Timer(n초 뒤 한 번, cancel), local()(스레드마다 따로 쓰는 주머니)
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.