Ch.5 사례 B: Lock을 걸었더니 서버가 먹통이다¶
< Critical Section과 Lock | 왜 이렇게 되는가 - Deadlock의 조건과 Semaphore >
앞에서 Lock으로 Race Condition을 해결했다. 재고가 더 이상 마이너스가 되지 않는다. 그런데 Lock을 잘못 쓰면 어떻게 되는가? 서버가 에러도 안 내고 먹통이 된다.
5-1. 사례 설명¶
사례 A에서 재고 보호 문제를 Lock으로 해결한 개발자가 기능을 확장한다. 이번에는 창고 간 재고 이동이다.
창고가 두 개 있다. 창고 A와 창고 B. "창고 A에서 10개를 빼서 창고 B에 넣는" API가 필요하다.
재고 이동은 두 창고를 동시에 건드려야 한다. A에서 빼고 B에 넣는 건 하나의 묶음이다. 중간에 다른 요청이 끼어들면 데이터가 꼬인다. 그래서 두 창고의 Lock을 모두 잡고 이동을 처리한다.
개발 환경에서 A→B 이동 테스트. 잘 된다. B→A 이동 테스트. 잘 된다.
부하 테스트: A→B와 B→A를 동시에 요청했다.
서버가 응답을 안 한다. 에러 로그도 없다. CPU도 안 쓴다. 프로세스는 살아 있다. 그냥 멈춰 있다.
"서버가 살아 있는데 응답을 안 한다. 에러도 없다. 뭐가 문제인 거지?"
5-2. 결과 예측¶
- "A→B 15건, B→A 15건을 동시에 보내면 어떻게 되는가?"
- "에러가 나는가? 느려지는가? 아예 안 되는가?"
- "Lock 순서를 고정하면 해결되는가?"
5-3. 결과 분석¶
k6로 10 VUs(짝수 VU는 A→B, 홀수 VU는 B→A)가 각 3번씩 이동 요청을 보냈다. 총 30건.
5-2에서 예측했으면, 아래를 펼쳐 실제 결과와 비교하자.
해답 펼쳐보기
| 시나리오 | 총 요청 | 성공 | Deadlock Timeout | 비고 |
|---|---|---|---|---|
| unsafe (Lock 순서 미고정) | 30 | 3 | 27 | 90%가 Deadlock |
| safe (Lock 순서 고정) | 30 | 30 | 0 | 정상 |
측정 환경: M1 Mac, Python 3.12, FastAPI 0.111, uvicorn, k6 10 VUs
unsafe에서 30건 중 27건이 5초 Timeout에 걸렸다. 평균 응답 시간 4.71초. 대부분의 요청이 Lock을 기다리다가 포기한 거다.
(실제 운영 환경이라면 timeout 없이 무한 대기했을 거다. 이 실습에서는 서버가 완전히 멈추는 걸 방지하기 위해 lock.acquire(timeout=5)로 5초 제한을 뒀다.)
safe에서는 30건 전부 정상 처리. Deadlock 0건. Lock 순서를 고정한 것뿐인데.
5-4. 코드 설명¶
먼저 창고 상태를 선언한다. 각 창고가 자기만의 Lock을 가진다:
_warehouses = {
"A": {"stock": 100, "lock": threading.Lock()},
"B": {"stock": 100, "lock": threading.Lock()},
}
문제의 코드 - Lock 순서를 호출 방향에 따라 잡는다:
@router.post("/warehouse/transfer-unsafe")
def transfer_unsafe(from_wh: str = "A", to_wh: str = "B", quantity: int = 10):
from_lock = _warehouses[from_wh]["lock"]
to_lock = _warehouses[to_wh]["lock"]
from_lock.acquire(timeout=5) # 출발지 Lock 먼저
try:
time.sleep(0.05) # 인위적 지연
to_lock.acquire(timeout=5) # 도착지 Lock 다음
try:
# 이동 처리
_warehouses[from_wh]["stock"] -= quantity
_warehouses[to_wh]["stock"] += quantity
finally:
to_lock.release()
finally:
from_lock.release()
A→B 요청이 오면: lock_A를 먼저 잡고, lock_B를 다음에 잡는다. B→A 요청이 오면: lock_B를 먼저 잡고, lock_A를 다음에 잡는다.
(여기서 lock_A는 _warehouses["A"]["lock"], lock_B는 _warehouses["B"]["lock"]이다. 코드에서는 from_lock, to_lock이라는 변수명을 쓰지만, 호출 방향에 따라 lock_A가 될 수도, lock_B가 될 수도 있다.)
두 요청이 동시에 오면:
Thread 1 (A→B): from_lock(=lock_A) 획득 → sleep → to_lock(=lock_B) 획득 시도
Thread 2 (B→A): from_lock(=lock_B) 획득 → sleep → to_lock(=lock_A) 획득 시도
Thread 1은 lock_B를 기다린다. 그런데 lock_B는 Thread 2가 잡고 있다. Thread 2는 lock_A를 기다린다. 그런데 lock_A는 Thread 1이 잡고 있다.
둘 다 상대방이 들고 있는 Lock을 기다린다. 영원히.
이게 Deadlock이다.
시간 순서로 그리면 이렇다. 각자 출발지 Lock은 잡는 데 성공하지만, 두 번째 Lock에서 서로를 붙든다:
sequenceDiagram
participant T1 as Thread 1 (A→B)
participant LA as lock_A
participant LB as lock_B
participant T2 as Thread 2 (B→A)
T1->>LA: acquire() → 성공
T2->>LB: acquire() → 성공
Note over T1,T2: 각자 출발지 Lock은 잡았다
T1->>LB: acquire() 시도 → 대기 (T2가 보유)
T2->>LA: acquire() 시도 → 대기 (T1이 보유)
Note over T1,T2: 서로가 상대의 Lock을 기다린다 — 영원히 (Deadlock)
핵심은 "이미 하나를 든 채로 다른 하나를 기다린다"는 데 있다. 두 스레드가 각각 반대 방향으로 Lock을 잡았기 때문에, 두 번째 요청이 서로를 향한다.
해결 코드 - Lock 순서를 항상 알파벳순으로 고정:
@router.post("/warehouse/transfer-safe")
def transfer_safe(from_wh: str = "A", to_wh: str = "B", quantity: int = 10):
# 항상 알파벳순으로 Lock을 잡는다
first_wh, second_wh = sorted([from_wh, to_wh])
first_lock = _warehouses[first_wh]["lock"]
second_lock = _warehouses[second_wh]["lock"]
with first_lock:
time.sleep(0.05)
with second_lock:
_warehouses[from_wh]["stock"] -= quantity
_warehouses[to_wh]["stock"] += quantity
A→B든 B→A든, Lock은 항상 A → B 순서로 잡는다. Thread 1과 Thread 2 모두 lock_A를 먼저 잡으려고 한다. 둘 중 하나만 lock_A를 잡고, 그 스레드가 lock_B도 잡는다. 나머지 스레드는 lock_A부터 기다린다.
서로 다른 Lock을 들고 상대방을 기다리는 상황이 생기지 않는다. Deadlock이 원천 봉쇄된다.
같은 상황을 시간 순서로 그리면, 순환이 아예 만들어지지 않는다. 두 스레드가 모두 lock_A를 먼저 노리기 때문에, 진 쪽은 lock_A 하나만 기다린다 — 무언가를 든 채로 기다리는 게 아니다:
sequenceDiagram
participant T1 as Thread 1 (A→B)
participant LA as lock_A
participant LB as lock_B
participant T2 as Thread 2 (B→A)
Note over T1,T2: 방향과 무관하게 둘 다 lock_A부터 잡는다
T1->>LA: acquire() → 성공
T2->>LA: acquire() 시도 → 대기 (T1이 보유)
T1->>LB: acquire() → 성공
T1->>T1: 재고 이동 처리
T1->>LB: release()
T1->>LA: release()
LA->>T2: acquire() → 성공
T2->>LB: acquire() → 성공
T2->>T2: 재고 이동 처리
Note over T1,T2: T2는 lock_A만 기다렸다 — 순환이 생기지 않는다
왜 Lock 순서를 고정하면 Deadlock이 안 생기는가? Deadlock이 발생하려면 4가지 조건이 동시에 성립해야 하는데, 그 중 하나가 깨지기 때문이다. 다음에서 본다.
< Critical Section과 Lock | 왜 이렇게 되는가 - Deadlock의 조건과 Semaphore >