R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

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

2

요리사를 더 뽑다: threading 기초

3

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

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

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

9

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

10

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

11

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

12

평론가의 밤: 종합

심야 주방의 동시성
2교시

강의 시간 0:50–1:40

요리사를 더 뽑다: threading 기초

오늘의 주문서

#02 · 둘째 밤

요리사를 더 뽑다

위기

민호, 수아, 탁 씨가 첫 출근했다. 셋 다 열심히 움직이는데, 누가 언제 무엇을 끝냈는지 아무도 모른다.

목표

주문 여섯 개를 세 사람이 나눠 0.6초 안에 끝내고, 누가 몇 초에 무엇을 끝냈는지 기록을 남긴다.

심야 식당 동시식당

둘째 밤 아홉 시 반. 라인 쿡 세 사람이 앞치마를 맵니다. 민호는 벌써 칼을 들었고, 수아는 주문서를 순서대로 정리하고 있고, 탁 씨는 천천히 화구를 닦습니다.

열 시가 되자 주문이 들어오고, 셋은 각자 냄비를 올립니다. 그런데 30분 뒤, 셰프가 "3번 테이블 우동 누가 했어?" 하고 묻자 아무도 대답하지 못합니다.

셰프가 목에 건 타이머를 툭 칩니다. "요리사를 뽑는 것까진 했어. 오늘은 이름표를 달고, 일을 맡기고, 끝날 때까지 기다리는 법을 배운다."

2-1. 요리사 한 명 들이기: Thread, start, join (10분)

파이썬에서 요리사 한 명은 스레드(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: served
  • target=에는 스레드가 할 일(함수)을, 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

요리

기다림

취소

실패

주문 6개, 각 0.3초. '요리사 여럿'에서 요리사를 1명부터 6명까지 바꿔 보세요. 줄이 하나씩 늘고 전체 시간이 1.8초 → 0.9초 → 0.6초 → 0.3초로 줄어듭니다.
  • 요리사가 1명이면 "혼자 순서대로"와 똑같이 약 1.8초입니다. 스레드를 하나만 쓰면 빨라질 것이 없습니다.
  • 요리사 2명은 약 0.9초, 3명은 약 0.6초, 6명은 약 0.3초입니다.
  • 4명과 5명은 둘 다 약 0.6초입니다. 주문 6개를 네 사람이 나누면 누군가는 두 개를 해야 하기 때문입니다. 걸리는 시간은 가장 많이 맡은 사람이 정합니다.
  • 줄 하나가 스레드 하나입니다. 이 타임라인의 요리사들도 위의 코드처럼 Thread를 만들고 start(), join()을 부릅니다.

2-2. run()을 부르면 그냥 함수 호출이다 (5분)

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.90s
  • run()은 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만 찍히고 프로그램은 그냥 계속 가. 조용히 넘어가지 않게 로그를 꼭 봐.

2-3. 이름표 달기: name, current_thread, enumerate (7분)

오늘 밤의 문제는 "누가 했는지 모른다"였습니다. 스레드에는 이름표를 달 수 있습니다.

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()에서 빠집니다. 퇴근한 요리사는 명단에 없습니다.

2-4. 스레드는 접시를 돌려주지 않는다 (6분)

함수는 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인 칸이 있을 수 있습니다.
  • 매번 그릇을 만드는 게 번거롭다면, 7교시의 ThreadPoolExecutor가 돌려주는 Future가 이 일을 대신합니다. future.result()로 접시를 직접 받을 수 있습니다.

2-5. daemon=True: 불을 끌 때 기다려 주지 않는 스레드 (6분)

파이썬 프로그램은 메인 스레드가 끝나도, 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 True
  • daemon 스레드는 프로그램이 끝날 때 기다려 주지 않습니다. 청소 도중에 불이 꺼지고, 4번과 5번은 영영 하지 못합니다.
  • 기다리고 싶다면 daemon이어도 join()을 부르면 됩니다. join()은 daemon 여부와 상관없이 기다립니다.
  • daemon은 "중간에 끊겨도 괜찮은 일"에만 씁니다. 상태를 주기적으로 찍는 감시 스레드 같은 것입니다. 파일 쓰기나 주문 기록처럼 끝까지 해야 하는 일을 daemon에 맡기면, 반쯤 쓴 파일이 남습니다.
  • daemon 여부는 start() 전에 정합니다. 따로 정하지 않으면 만든 스레드의 값을 물려받는데, 메인 스레드는 daemon이 아니라서 보통은 False입니다.

2-6. 그 밖의 도구: Thread 상속, Timer, local (6분)

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 ok

threading.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
# end

threading.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
# False

pocket은 전역 변수 하나인데도, 민호의 count와 수아의 count는 섞이지 않습니다. 스레드마다 데이터베이스 연결을 하나씩 들고 있게 할 때 자주 쓰는 방법입니다.

셰프의 한마디

스레드를 만들 땐 이름부터 붙여. 새벽에 로그를 열었는데 Thread-7, Thread-12만 잔뜩 찍혀 있으면 누가 사고 쳤는지 절대 못 찾아. logging 형식에 %(threadName)s 하나만 넣어 두면 줄마다 이름이 찍혀.

영업 마감 (10분)

오늘 밤의 목표는 주문 여섯 개를 세 사람이 나눠 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 orders
  • 인자가 리스트 하나라서 args=(d,)에 쉼표가 있습니다.
  • log.append(...)는 한 번에 끝나는 리스트 연산이라 여러 스레드가 함께 불러도 줄이 사라지지 않습니다. 일지는 join() 뒤에 정렬해서 읽습니다.

셰프가 일지를 훑어보고 고개를 끄덕입니다. "좋아, 이제 누가 뭘 했는지는 알겠어. 그럼 내일은 하나만 더 해 보자. 넷이 각자 만든 개수를 칠판 숫자 하나에 더해 적으면, 그 숫자는 맞을까?"

영업 일지 — 둘째 밤. 민호, 수아, 탁 씨 첫 출근. 이름표를 달고 주문 6건을 둘씩 나눠 0.6초에 끝냄. 누가 몇 초에 무엇을 냈는지 일지로 남김.

실습 과제

  1. 2-1의 세 요리사 예제에서 start()와 join()을 한 반복문 안에 함께 쓰도록 바꾸고, 시간이 몇 초가 되는지 예상한 뒤 재 보세요.
  2. 영업 마감 코드에서 탁 씨만 주문 하나에 0.5초가 걸리도록 바꿔 보세요(힌트: 요리사 이름에 따라 sleep 시간을 다르게). 전체 시간은 누가 정하나요? 타임라인에서 줄 하나만 길어지는 모습과 비교해 보세요.
  3. 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()(스레드마다 따로 쓰는 주머니)

← 이전

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

다음 →

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

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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