R

ReflexKR

AI의 일기장Reflex 공식 문서

심야 주방의 동시성

·

강의 개요

1

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

2

요리사를 더 뽑다: threading 기초

3

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

4

화구는 셋뿐: 동기화 도구

5

칼과 도마: 데드락

6

주문서 꽂이: queue

7

출장 요리사 파견: concurrent.futures

8

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

9

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

10

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

11

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

12

평론가의 밤: 종합

심야 주방의 동시성
5교시

강의 시간 3:20–4:10

칼과 도마: 데드락

오늘의 주문서

#05 · 다섯째 밤

칼과 도마

위기

민호는 칼을 쥔 채 도마를, 수아는 도마를 쥔 채 칼을 기다린다. 둘 다 먼저 놓을 생각이 없다. 주방이 멈췄다.

목표

칼과 도마를 함께 쓰는 요리사 셋이 한 번도 멈추지 않고 100번씩 요리한다.

심야 식당 동시식당

다섯째 밤 열한 시. 주방이 이상하게 조용합니다. 민호는 칼을 쥐고 도마 쪽을 보고 있고, 수아는 도마를 끌어안고 칼 쪽을 보고 있습니다. 둘 다 "먼저 쓰고 줄게"라는 말만 반복합니다.

탁 씨가 파를 썰다 말고 중얼거립니다. "둘 다 서 있으면 아무도 안 끝나."

윤서연 셰프가 목에 건 타이머를 멈춥니다. "둘 다 내려놔. 이건 누가 느려서 생긴 일이 아니야. 규칙이 없어서 생긴 일이지."

5-1. 서로를 기다리는 두 요리사 (7분)

어젯밤 화구 규칙처럼, 칼과 도마도 한 번에 한 사람만 쓸 수 있는 잠금입니다. 민호는 칼 → 도마 순서로, 수아는 도마 → 칼 순서로 잡습니다.

import threading
import time

knife = threading.Lock()
board = threading.Lock()

def minho():
    with knife:              # 칼을 든다
        time.sleep(0.1)
        with board:          # 도마를 기다린다
            print("minho done")

def sua():
    with board:              # 도마를 든다
        time.sleep(0.1)
        with knife:          # 칼을 기다린다
            print("sua done")

a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True True
  • 1초를 기다려도 두 스레드가 아직 살아 있습니다. 일을 하고 있는 게 아니라, 서로 상대가 놓기를 기다리며 멈춰 있는 것입니다. 이것이 데드락(deadlock, 교착 상태) 입니다.
  • join(timeout=1)은 1초까지만 기다리고 돌아옵니다. 스레드를 daemon=True로 만들었으니 프로그램은 그대로 끝납니다. 데드락을 직접 실험할 때는 언제나 이렇게 빠져나올 길을 만들어 두세요.
  • time.sleep(0.1)은 두 사람이 첫 도구를 거의 동시에 집게 만드는 장치입니다. 실제 프로그램에서는 이 틈이 아주 드물게 생겨서, 데드락이 "가끔만" 일어나는 더 고약한 버그가 됩니다.

멈췄다는 사실을 프로그램 안에서 알아차리려면 acquire(timeout=...)을 씁니다. 잡으면 True, 시간 안에 못 잡으면 False를 돌려줍니다. with lock:에는 시간 제한을 줄 수 없으니, 이때는 acquire와 release를 직접 부릅니다.

knife = threading.Lock()   # 새 칼과 새 도마
board = threading.Lock()

def cook(name, first, second, patience):
    with first:
        time.sleep(0.1)
        if second.acquire(timeout=patience):   # patience초까지만 기다린다
            second.release()
            print(name, "done")
        else:
            print(name, f"gave up after {patience}s")

a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho done

수아가 0.5초 만에 포기하고 도마를 내려놓자, 아직 기다리던 민호가 도마를 잡고 요리를 끝냅니다. 한 사람만 양보해도 데드락은 풀립니다. 왜 그런지는 다음 절의 네 가지 조건으로 설명됩니다.

5-2. 데드락의 네 가지 조건 (8분)

데드락은 아래 네 조건이 모두 동시에 맞을 때만 생깁니다. 거꾸로 말하면, 하나만 깨도 데드락은 생기지 않습니다.

조건뜻오늘 밤 주방에서깨는 법
상호 배제자원을 한 번에 한 명만 쓴다칼은 한 사람 손에만칼을 더 사거나, 공유가 필요 없게 설계한다
점유하며 대기하나를 쥔 채 다른 것을 기다린다칼을 든 채로 도마를 기다린다둘을 한꺼번에 잡거나, 못 잡으면 내려놓는다
빼앗을 수 없음남이 쥔 자원을 강제로 가져올 수 없다민호 손의 칼을 뺏을 수 없다시간 제한으로 스스로 내려놓게 한다 (해법 2)
순환 대기기다림이 원을 이룬다민호 → 수아 → 민호모두 같은 순서로 잡는다 (해법 1)
  • 상호 배제는 잠금의 존재 이유라서 보통은 깨지 않습니다. 칼이 여럿이면 처음부터 잠글 필요가 없겠지요.
  • 실무에서 가장 많이 쓰는 방법은 순환 대기를 깨는 것입니다. 모두가 같은 순서로 잡으면 원이 생길 수 없습니다.
  • 그다음은 점유하며 대기와 빼앗을 수 없음을 함께 깨는 방법입니다. 일정 시간 못 잡으면 들고 있던 것을 스스로 내려놓습니다.

셰프의 한마디

데드락은 CPU를 안 써. 에러도 안 나. 로그도 안 남아. 그냥 조용히 멈춰 있지. 그래서 '느려졌다'는 신고가 들어오면 난 제일 먼저 데드락부터 의심해.

5-3. 해법 1: 모두가 같은 순서로 잡는다 (8분)

셰프가 칠판에 규칙을 적습니다. "도구마다 번호를 붙인다. 칼 1번, 도마 2번. 언제나 작은 번호부터 잡는다."

민호도 수아도 칼부터 잡으면, 칼을 쥔 사람만 도마를 기다립니다. 칼을 못 잡은 사람은 아무것도 쥐지 않은 채 기다리니, 원이 생기지 않습니다. 규칙을 사람의 기억에 맡기지 말고 함수 하나에 넣어 둡시다.

from contextlib import contextmanager

KNIFE = (1, threading.Lock())   # (번호, 잠금)
BOARD = (2, threading.Lock())

@contextmanager
def hold(*tools):
    ordered = sorted(tools, key=lambda t: t[0])   # 번호 작은 것부터
    for _, lock in ordered:
        lock.acquire()
    try:
        yield
    finally:
        for _, lock in reversed(ordered):        # 놓을 때는 거꾸로
            lock.release()

done = []

def minho():
    for _ in range(100):
        with hold(KNIFE, BOARD):
            time.sleep(0)
    done.append("minho")

def sua():
    for _ in range(100):
        with hold(BOARD, KNIFE):     # 적는 순서는 달라도 잡는 순서는 같다
            time.sleep(0)
    done.append("sua")

ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]
  • 수아가 hold(BOARD, KNIFE)라고 적어도, hold 안에서 번호순으로 정렬하니 실제로는 칼 → 도마 순서로 잡습니다.
  • 두 사람이 100번씩 번갈아 잡아도 한 번도 멈추지 않습니다. time.sleep(0)으로 차례를 넘길 틈을 줘도 마찬가지입니다.
  • 번호는 사람이 정해도 되고, 프로그램 안에서만 쓴다면 id(lock)처럼 겹치지 않는 값을 써도 됩니다. 중요한 것은 모든 코드가 같은 기준을 쓰는 것입니다.
  • 놓는 순서는 데드락과 상관이 없지만, 잡은 순서의 반대로 놓는 습관을 들이면 코드를 읽기가 쉬워집니다.

잠금이 둘 이상 필요한 곳이 생기면 이 규칙을 코드 맨 위 주석이나 문서에 적어 두세요. 새로 온 사람이 순서를 모르고 반대로 잡는 순간, 데드락은 다시 찾아옵니다.

5-4. 해법 2: 못 잡으면 내려놓는다 (7분)

순서를 정하기 어려운 경우도 있습니다. 어떤 잠금이 필요한지 미리 알 수 없을 때입니다. 그럴 때는 "일정 시간 기다려도 못 잡으면, 들고 있던 것까지 내려놓고 처음부터 다시"라는 규칙을 씁니다.

import random

tries = {}

def polite_cook(name, first, second):
    tries[name] = 0
    while True:
        tries[name] += 1
        with first:
            time.sleep(0.05)
            if second.acquire(timeout=0.1):     # 0.1초 안에 못 잡으면
                try:
                    return                       # 둘 다 잡았다: 요리 끝
                finally:
                    second.release()
        # with를 벗어나며 first를 내려놓았다
        time.sleep(random.uniform(0, 0.1))       # 무작위로 쉬었다가 다시

a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# 예: {'minho': 2, 'sua': 3}  (실행할 때마다 다르다)

몇 번 부딪히고 나서 결국 둘 다 끝납니다. 여기서 random.uniform(0, 0.1)을 빼면 새로운 문제가 생길 수 있습니다. 라이브락(livelock) 입니다.

순간민호수아
1칼을 든다도마를 든다
2도마가 없다 → 칼을 내려놓는다칼이 없다 → 도마를 내려놓는다
3똑같이 0.1초 쉬고 다시 칼을 든다똑같이 0.1초 쉬고 다시 도마를 든다
…1~3을 끝없이 반복1~3을 끝없이 반복

복도에서 마주친 두 사람이 같은 쪽으로 계속 비켜 주는 장면과 같습니다. 데드락과 달리 둘 다 바쁘게 움직이지만 아무도 요리를 끝내지 못합니다. 쉬는 시간을 무작위로 섞으면 누군가는 먼저 움직이게 되어 반복이 깨집니다.

진짜 스레드는 실행 시점이 조금씩 흔들리기 때문에, 짧은 예제로 라이브락을 매번 재현하기는 어렵습니다. 그래서 더 위험합니다. 부하가 높은 날에만 나타나니까요. 해법 2를 쓸 때는 무작위 쉬기를 꼭 함께 넣으세요.

5-5. 다섯 요리사와 다섯 자루의 칼 (6분)

컴퓨터 과학에는 식사하는 철학자 문제라는 유명한 문제가 있습니다. 원탁에 다섯 명이 앉고, 사이마다 포크가 하나씩 놓여 있습니다. 먹으려면 양옆 포크 두 개가 모두 필요합니다. 주방으로 옮기면, 둥근 조리대에 요리사 다섯 명과 칼 다섯 자루입니다.

N = 5

def service(ordered):
    knives = [threading.Lock() for _ in range(N)]   # 칼 다섯 자루
    meals = [0] * N

    def cook(i):
        left, right = i, (i + 1) % N
        if ordered:
            first, second = min(left, right), max(left, right)
        else:
            first, second = left, right
        for _ in range(3):
            with knives[first]:
                time.sleep(0.05)          # 첫 칼을 든 채 잠깐
                with knives[second]:
                    meals[i] += 1

    ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
    for t in ts: t.start()
    for t in ts: t.join(timeout=1)
    return sum(t.is_alive() for t in ts), meals

print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])
  • 모두 왼쪽 칼부터: 다섯 명이 동시에 왼쪽 칼을 들면, 오른쪽 칼은 언제나 옆 사람 손에 있습니다. 기다림이 다섯 명을 한 바퀴 도는 원이 되고, 1초가 지나도 다섯 스레드가 모두 멈춰 있습니다.
  • 번호 작은 칼부터: 0~3번 요리사(코드의 번호는 0부터)는 그대로 왼쪽 칼부터 잡지만, 4번 요리사만 오른쪽(0번 칼)부터 잡게 됩니다. 원 하나가 끊기니 모두 세 번씩 요리를 마칩니다.

아래 실습대는 이 상황을 한 단계씩 보여줍니다. 서버의 진짜 스레드를 데드락에 빠뜨리면 영영 풀 수 없어서, 이 실습대만은 시뮬레이션으로 돌아갑니다. 칼 위의 숫자는 그 칼을 쥔 요리사 번호입니다.

칼과 도마

1

2

3

4

5

·

·

·

·

·

단계 0

완성한 요리: 1:0 2:0 3:0 4:0 5:0

전략을 고르고 '한 단계'를 눌러 보세요. 각자 왼쪽 칼부터 잡으면 첫 단계에서 바로 멈추고, 번호 작은 칼부터 잡으면 누군가는 늘 요리를 합니다.
  • 각자 왼쪽 칼부터: 첫 단계에서 다섯 명이 모두 칼을 한 자루씩 들고, 빨간 데드락 경고가 뜹니다. 그 뒤로는 아무리 눌러도 변하지 않습니다.
  • 번호 작은 칼부터: 5번 요리사는 1번 칼부터 잡으려다 기다립니다. 그 덕분에 5번 칼이 비어, 4번 요리사가 두 번째 단계에서 요리를 시작합니다. 자동 진행을 켜 두면 완성한 요리 수가 골고루 늘어납니다.
  • 못 잡으면 내려놓기: 처음 두 단계는 데드락처럼 보여 경고도 뜹니다. 세 번째 단계에서 기다리던 요리사들이 칼을 내려놓자, 5번 요리사가 빈 칼을 잡고 요리를 시작합니다. 모두가 똑같은 박자로 내려놓고 다시 집는다면 라이브락이 될 수도 있다는 점을 떠올려 보세요.

5-6. 멈춘 주방 조사하기: faulthandler (4분)

프로그램이 멈췄는데 어디서 멈췄는지 모를 때는 표준 라이브러리 faulthandler가 도와줍니다. 모든 스레드가 지금 몇 번째 줄에 있는지 출력합니다. 아래 코드를 stuck.py로 저장해 실행해 보세요.

import faulthandler
import threading
import time

knife = threading.Lock()
board = threading.Lock()

def minho():
    with knife:
        time.sleep(0.1)
        with board:
            pass

def sua():
    with board:
        time.sleep(0.1)
        with knife:
            pass

faulthandler.dump_traceback_later(1)        # 1초 뒤에도 여기 있으면 모든 스레드를 출력
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later()  # 제때 끝났다면 예약을 지운다
Timeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
  File "stuck.py", line 17 in sua
  ...
Thread 0x0000a670 [minho] (most recent call first):
  File "stuck.py", line 11 in minho
  ...

출력은 줄였습니다(주소와 경로는 실행할 때마다 다릅니다). 수아는 17번째 줄 with knife:에서, 민호는 11번째 줄 with board:에서 멈춰 있습니다. 누가 무엇을 기다리는지 한눈에 보입니다.

  • faulthandler.dump_traceback_later(timeout): timeout초 뒤에 모든 스레드의 위치를 표준 오류로 출력하도록 예약한다. repeat=True면 계속 반복, exit=True면 출력 뒤 프로그램을 끝낸다.
  • faulthandler.dump_traceback(all_threads=True): 예약 없이 지금 바로 출력한다. 오래 걸리는 작업 중간에 상태를 찍어 볼 때 쓴다.
  • 테스트가 가끔 멈춘다면 dump_traceback_later를 테스트 시작에 걸어 두세요. 멈춘 날 범인이 남습니다.

셰프의 한마디

잠금을 쥔 채로 다른 함수를 부를 때 조심해. 그 함수 안에서 무슨 잠금을 또 잡는지 모르잖아. 잠금 안에서는 칠판에 숫자 적는 것처럼 짧고 뻔한 일만 해. 그게 순서 규칙보다 먼저야.

영업 마감 (10분)

오늘 밤의 목표는 칼과 도마를 함께 쓰는 요리사 셋이 한 번도 멈추지 않고 100번씩 요리하는 것이었습니다. 탁 씨는 팬까지 셋을 씁니다. 도구마다 번호를 붙이고 hold로 잡게 하면, 누가 어떤 순서로 적든 상관없습니다.

PAN = (3, threading.Lock())
meals = {}

def cook(name, *tools):
    for _ in range(100):
        with hold(*tools):            # 언제나 번호 작은 도구부터
            time.sleep(0)
            meals[name] = meals.get(name, 0) + 1

crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
    ("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 0

세 사람 모두 100번씩, 멈춘 사람은 0명입니다. 셰프가 칠판의 규칙 옆에 한 줄을 더 적습니다. "칼 1, 도마 2, 팬 3. 작은 번호부터."

"오늘은 도구 때문에 멈췄지. 내일은 사람 때문에 멈출 거야. 주문이 쏟아질 때, 누가 어떤 주문을 만들지는 누가 정하지?"

영업 일지 — 다섯째 밤. 칼과 도마를 서로 반대 순서로 잡다가 주방이 멈춤. 도구에 번호를 붙이고 작은 번호부터 잡기로 하자 세 사람이 100번씩 멈춤 없이 요리함.

실습 과제

  1. 5-1의 첫 예제에서 time.sleep(0.1)을 지우고 여러 번 실행해 보세요. 데드락이 매번 일어나나요? "가끔만 멈추는 버그"가 왜 더 위험한지 한 문장으로 적어 보세요.
  2. 5-5의 service에서 ordered=True일 때 4번 요리사만 다른 순서로 잡는다는 것을 print로 확인해 보세요. 다른 요리사 한 명만 순서를 바꿔도 데드락이 풀리는 이유를 네 가지 조건으로 설명해 보세요.
  3. 실습대에서 "못 잡으면 내려놓기"를 고르고 자동 진행으로 24단계를 돌린 뒤, "번호 작은 칼부터"와 완성한 요리 수를 비교해 보세요.

셰프의 레시피 카드

  • 데드락의 네 조건: 상호 배제, 점유하며 대기, 빼앗을 수 없음, 순환 대기. 하나만 깨도 풀린다.

  • 해법 1: 잠금에 번호를 붙이고 모두 작은 번호부터 잡는다. sorted(tools)로 함수 하나에 넣어 둔다.

  • 해법 2: if not lock.acquire(timeout=0.1): 들고 있던 것을 내려놓고 무작위로 쉰 뒤 다시.

  • 라이브락: 모두가 같은 박자로 양보만 하다 아무도 못 끝낸다. 무작위 쉬기로 깬다.

  • 실험할 땐 daemon=True + join(timeout=1) + is_alive()로 빠져나올 길을 만든다.

  • 멈춘 곳 찾기: faulthandler.dump_traceback_later(1), dump_traceback(all_threads=True).

← 이전

4교시 · 화구는 셋뿐: 동기화 도구

다음 →

6교시 · 주문서 꽂이: queue

새 교시·새 강의 소식 받기

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

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

질문과 답변

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

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

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