오늘의 주문서
#03 · 셋째 밤
위기
요리사 네 명이 각자 1000개씩 만들 때마다 주방 칠판의 숫자에 1을 더했다. 칠판에는 4000이 아니라 1086이 적혀 있다.
목표
요리사가 몇 명이든 칠판 숫자가 정확히 맞게 한다. 그러면서도 주방이 느려지지 않게 한다.
심야 식당 동시식당
셋째 밤 새벽 두 시, 영업이 끝났습니다. 오늘은 탁 씨의 친구가 하루 도우러 와서 요리사가 넷이었습니다. 셰프는 규칙을 하나 정했습니다. "한 그릇 낼 때마다 칠판 숫자에 1을 더해."
주문서를 세어 보니 넷이 각자 1000그릇씩, 모두 4000그릇입니다. 그런데 칠판에는 1086이 적혀 있습니다.
민호가 머리를 긁습니다. "저 진짜 매번 더했는데요?" 수아도, 탁 씨도 같은 말을 합니다. 셰프가 분필을 집어 듭니다. "다들 거짓말은 안 했어. 넷이 동시에 칠판 앞에 섰을 뿐이지."
칠판에 1을 더하는 일은 사실 세 단계입니다. 칠판을 읽고, 머릿속으로 1을 더하고, 칠판에 쓴다. 이 세 단계를 그대로 코드로 옮겨 보겠습니다.
import threading
import time
board = 0 # 칠판의 숫자
def cook(times):
global board
for _ in range(times):
seen = board # 1. 칠판을 읽는다
time.sleep(0) # 2. 잠깐 한눈을 판다(다른 요리사에게 차례가 넘어갈 틈)
board = seen + 1 # 3. 읽은 값에 1을 더해 적는다
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# 예: 1086 (실행할 때마다 다르다. 4000은 거의 나오지 않는다)time.sleep(0)은 "0초 쉬기"인데, 실제로는 지금 차례를 다른 스레드에게 넘겨 주는 효과가 있습니다. 읽기와 쓰기 사이에 다른 요리사가 끼어들 틈을 일부러 만든 것입니다.sleep이 아니라 로그 찍기, 데이터베이스 조회, 함수 호출 같은 평범한 코드입니다. 틈은 늘 어딘가에 있습니다.이렇게 여러 스레드가 같은 값을 읽고 고쳐 쓰는 순서에 따라 결과가 달라지는 것을 경쟁 상태(race condition)라고 합니다. 아래 칠판 실험에서 직접 확인해 보세요. 요리사들은 위의 코드와 똑같이 "읽기 → time.sleep(0) → 쓰기"를 합니다.
칠판 실험
요리사
4
한 사람당 횟수
1000
Lock 사용
칠판이 41일 때, 민호와 수아가 거의 동시에 한 그릇씩 냈다고 해 봅시다. 시간을 한 칸씩 끊어 보면 이렇습니다.
| 시점 | 민호 | 수아 | 칠판 |
|---|---|---|---|
| 1 | 칠판을 읽는다: 41 | 41 | |
| 2 | (한눈을 판다) | 칠판을 읽는다: 41 | 41 |
| 3 | 41 + 1 = 42를 쓴다 | 42 | |
| 4 | 41 + 1 = 42를 쓴다 | 42 |
두 사람이 한 그릇씩, 모두 두 그릇을 냈으니 칠판은 43이어야 합니다. 그런데 42입니다. 수아는 민호가 고치기 전의 값을 읽었고, 민호가 쓴 42를 자기 42로 덮어썼습니다. 민호의 한 그릇이 사라졌습니다.
해법은 단순합니다. 칠판 앞에는 한 번에 한 사람만 서게 합니다. 분필을 하나만 두고, 분필을 쥔 사람만 칠판에 쓸 수 있게 하는 것입니다. 파이썬의 분필은 threading.Lock입니다.
board = 0
chalk = threading.Lock() # 분필은 하나뿐
def cook(times):
global board
for _ in range(times):
with chalk: # 분필을 쥔 사람만 칠판 앞에 선다
seen = board
time.sleep(0)
board = seen + 1
threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board)
# 4000with chalk: 블록에 들어갈 때 잠금을 잡고(acquire), 나올 때 풉니다(release). 다른 스레드가 이미 잡고 있으면, 풀릴 때까지 그 자리에서 기다립니다.time.sleep(0)으로 차례를 넘겨도 괜찮습니다. 다른 요리사가 차례를 받아도 분필이 없으니 칠판에 손을 못 댑니다.with를 쓰지 않고 직접 잡고 풀 수도 있습니다. 이때는 반드시 try/finally로 감싸서, 중간에 예외가 나도 분필을 내려놓게 합니다.
chalk = threading.Lock()
print(chalk.locked())
chalk.acquire() # 분필을 쥔다
try:
print(chalk.locked())
print(chalk.acquire(blocking=False)) # 이미 누가 쥐고 있으면 바로 False
print(chalk.acquire(timeout=0.1)) # 0.1초 기다려 보고 못 쥐면 False
finally:
chalk.release() # 무슨 일이 있어도 내려놓는다
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock| 메서드 | 하는 일 |
|---|---|
acquire() | 잠금을 잡는다. 누가 잡고 있으면 풀릴 때까지 기다린다. 잡으면 True |
acquire(blocking=False) | 기다리지 않는다. 못 잡으면 바로 False |
acquire(timeout=0.1) | 0.1초까지만 기다린다. 못 잡으면 False |
release() | 잠금을 푼다. 잠겨 있지 않은데 풀면 RuntimeError |
locked() | 지금 잠겨 있는지 |
잠금은 다른 사람을 기다리게 만듭니다. 그러니 꼭 필요한 줄만 감싸야 합니다. 요리까지 분필을 쥔 채로 하면 어떻게 될까요?
board = 0
chalk = threading.Lock()
def cook_wide():
global board
with chalk:
time.sleep(0.1) # 요리까지 분필을 쥔 채로
board += 1
def cook_narrow():
global board
time.sleep(0.1) # 요리는 각자
with chalk:
board += 1 # 칠판 앞에서만 잠근다
for fn in (cook_wide, cook_narrow):
board = 0
threads = [threading.Thread(target=fn) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10s둘 다 숫자는 정확히 4입니다. 하지만 cook_wide는 분필을 쥔 사람이 요리를 끝낼 때까지 나머지가 줄을 서서, 요리사 넷이 있어도 혼자 하는 것과 같은 0.4초가 걸립니다. 공유하는 값을 만지는 줄만 잠그세요.
셰프의 한마디
잠금은 칠판 하나에 분필 하나야. 칠판마다 다른 분필을 쓰거나, 누구는 분필 없이 그냥 쓰면 소용없어. 같은 값을 만지는 코드는 전부 같은 Lock을 거쳐야 해. 하나라도 빠지면 그 구멍으로 숫자가 새.
파이썬(정확히는 CPython)에는 GIL(Global Interpreter Lock)이라는 큰 잠금이 있습니다. 한 순간에 파이썬 코드를 실행하는 스레드는 하나뿐이라는 규칙입니다. 자세한 건 8교시에 다루는데, 여기서 흔한 오해가 하나 생깁니다. "한 번에 하나만 실행되면 경쟁 상태도 없지 않나?"
아닙니다. GIL은 바이트코드 한 줄 단위로 스레드를 바꿔 줄 뿐, 여러분의 "읽기 → 계산 → 쓰기"를 한 덩어리로 지켜 주지 않습니다. board += 1 한 줄도 안을 열어 보면 여러 단계입니다.
import dis
count = 0
def add():
global count
count += 1
dis.dis(add)
# 결과 일부(3.14):
# LOAD_GLOBAL 0 (count) ← 읽기
# LOAD_SMALL_INT 1
# BINARY_OP 13 (+=) ← 계산
# STORE_GLOBAL 0 (count) ← 쓰기읽기와 쓰기 사이에 스레드가 바뀌면, 3-2의 표와 똑같은 일이 일어납니다. 스레드를 언제 바꿀지는 인터프리터가 정하고, 그 간격은 sys.getswitchinterval()로 볼 수 있습니다.
import sys
print(sys.getswitchinterval())
# 0.005기본값은 0.005초입니다. 한 스레드가 계속 돌고 있어도 대략 이 간격마다 다른 스레드에게 차례를 넘기라는 요청이 갑니다. time.sleep이나 I/O처럼 기다리는 일을 만나면 그보다 먼저 넘깁니다.
time.sleep(0) 없이 count += 1만 400만 번 해 보면 어떨까요?
count = 0
def cook(times):
global count
for _ in range(times):
count += 1
threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(count, sys._is_gil_enabled())
# Python 3.14: 4000000 True
# Python 3.14t: 예: 1127695 False (실행할 때마다 다르다)+=를 원자적(쪼개지지 않는 한 덩어리)으로 실행한다고 약속한 적은 없습니다. 버전이 바뀌거나 루프 안에 코드가 한 줄만 늘어도 깨질 수 있습니다.Lock은 누가 잡았는지 신경 쓰지 않습니다. 그래서 이미 분필을 쥔 사람이 분필을 또 집으려 하면, 자기가 내려놓기를 자기가 기다리며 영원히 멈춥니다. 진짜로 멈추면 곤란하니, 0.5초만 기다려 보겠습니다.
def probe(name, lock):
with lock: # 한 번 잠그고
start = time.perf_counter()
ok = lock.acquire(timeout=0.5) # 같은 스레드가 한 번 더
waited = time.perf_counter() - start
print(name, ok, f"{waited:.1f}s")
if ok:
lock.release()
probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0sLock은 0.5초를 꼬박 기다리고 False입니다. timeout이 없었다면 이 스레드는 여기서 영원히 멈췄을 것입니다.RLock(재진입 잠금, reentrant lock)은 잡은 스레드가 누구인지 기억합니다. 같은 스레드는 몇 번이든 다시 잡을 수 있고, 잡은 횟수만큼 풀어야 완전히 풀립니다. 다른 스레드는 평소처럼 기다립니다.이런 일은 잠그는 메서드가 다른 잠그는 메서드를 부를 때 자주 생깁니다.
class Board:
def __init__(self):
self.lock = threading.RLock()
self.count = 0
def add(self):
with self.lock:
self.count += 1
def add_table(self, dishes):
with self.lock: # 테이블 하나는 통째로
for _ in range(dishes):
self.add() # 안에서 같은 잠금을 또 잡는다
board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count)
# 1000self.lock이 Lock이었다면 add_table이 add를 부르는 순간 멈췄을 것입니다. RLock은 조금 더 무겁고, 잠금이 몇 겹인지 헷갈리게 만들기도 합니다. 설계를 바꿔 "잠그지 않는 내부 메서드"(_add)를 따로 두는 것도 좋은 방법입니다.
오늘 밤의 목표는 요리사가 몇 명이든 칠판 숫자가 정확히 맞고, 주방은 느려지지 않는 것이었습니다. 칠판을 클래스로 만들고, 칠판을 고치는 줄만 잠급니다. 요리(time.sleep)는 잠금 밖에서 각자 합니다.
class Chalkboard:
def __init__(self):
self._lock = threading.Lock()
self.count = 0
def add(self, n=1):
with self._lock: # 칠판 앞에서만
self.count += n
board = Chalkboard()
def cook(dishes):
for _ in range(dishes):
time.sleep(0.001) # 요리는 잠금 밖에서
board.add()
threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
th.start()
for th in threads:
th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# 예: 400 0.15s (숫자는 늘 400, 시간은 대략)count)을 고치는 길은 add() 하나뿐입니다. 잠금을 클래스 안에 숨겨 두면, 누가 실수로 분필 없이 쓰는 일이 줄어듭니다.셰프가 칠판을 지우고 400을 다시 적습니다. "칠판은 한 명씩이면 돼. 그런데 내일 화구는 셋이고 요리사는 여섯이야. 한 명씩만 들여보내면 화구 둘이 논다. 세 명까지 들여보내는 분필은 없을까?"
영업 일지 — 셋째 밤. 칠판 4000이 1086으로 줄어든 사고. 원인은 읽기와 쓰기 사이에 끼어든 다른 요리사. 칠판 앞에 분필(Lock)을 하나만 두고, 칠판 고치는 줄만 잠가 해결.
time.sleep(0)을 지우고 다시 돌려 보세요. 숫자가 맞게 나와도 왜 안전하다고 할 수 없는지 3-4를 근거로 설명해 보세요.Board를 Lock으로 바꾸고, 잠그지 않는 내부 메서드 _add()를 두어 add()와 add_table()이 둘 다 멈추지 않게 고쳐 보세요.셰프의 레시피 카드
경쟁 상태: 읽기 → 계산 → 쓰기 사이에 다른 스레드가 끼어들어 값이 사라진다. 결과가 실행마다 다르다.
lock = threading.Lock() / with lock: 공유 값을 고치는 줄만 감싼다. 직접 쓰면 acquire() … finally: release()
acquire(blocking=False), acquire(timeout=초)는 못 잡으면 False. locked()로 상태 확인.
RLock: 같은 스레드가 다시 잠글 수 있다. Lock을 두 번 잡으면 스스로 멈춘다.
GIL이 있어도 count += 1은 원자적이지 않다. 3.14t에서는 더 크게 틀린다. 공유 값엔 언제나 Lock.
sys.getswitchinterval() = 0.005: 대략 이 간격마다 스레드가 바뀔 수 있다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.