Ch.5 유사 사례와 키워드 정리¶
이번 챕터에서는 Race Condition이 왜 발생하는지, Lock으로 어떻게 해결하는지, Lock을 잘못 쓰면 Deadlock이 어떻게 생기는지 확인했다.
같은 원리가 적용되는 실무 사례를 몇 가지 더 본다.
5-6. 유사 사례¶
DB 레벨의 Race Condition¶
사례 A에서는 Python 변수(dict)로 재고를 관리했다. 실제 서비스에서는 DB에 저장한다. 그런데 DB로 바꿔도 같은 문제가 생긴다.
-- Thread 1
SELECT stock FROM products WHERE id = 1; -- stock = 1
-- Thread 2
SELECT stock FROM products WHERE id = 1; -- stock = 1 (같은 값)
-- Thread 1
UPDATE products SET stock = stock - 1 WHERE id = 1; -- stock = 0
-- Thread 2
UPDATE products SET stock = stock - 1 WHERE id = 1; -- stock = -1
"읽기 → 체크 → 쓰기"가 하나의 트랜잭션 안에서 원자적으로 실행되지 않으면, Python이든 DB든 같은 문제가 발생한다.
DB 레벨의 해결책: SELECT ... FOR UPDATE로 행에 잠금을 건다.
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 행에 쓰기 잠금
-- 다른 트랜잭션의 SELECT ... FOR UPDATE는 기다린다
-- (단, 일반 SELECT는 잠금 없이 읽을 수 있다 - Consistent Read)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
(Ch.15에서 Transaction Isolation Level을 다룰 때 이 문제를 더 깊이 파고든다.)
파일 잠금¶
프로세스가 파일을 열고(open) 작업을 하다가, close를 안 하고 비정상 종료되면? 파일에 걸린 잠금이 해제되지 않는다. 다른 프로세스가 같은 파일을 열려고 하면 접근이 거부된다.
이건 Lock의 acquire/release와 같은 구조다. open()이 acquire, close()가 release. close를 안 하면 Lock이 영원히 풀리지 않는 셈이다.
Python의 with open() 패턴이 이 문제를 해결한다:
# 안전하지 않은 패턴
f = open("data.txt", "w")
f.write("hello")
# 예외 발생 시 close()가 호출되지 않는다!
# 안전한 패턴
with open("data.txt", "w") as f:
f.write("hello")
# with 블록을 나오면 자동으로 close()가 호출된다
with lock:과 with open():이 같은 패턴이라는 걸 눈치챘을 수도 있다. 둘 다 Python의 Context Manager다. "자원을 획득하고, 사용하고, 반드시 해제한다." Ch.2에서 다뤘던 File Descriptor(fd)와도 연결된다. 파일을 open하면 fd가 할당되고, close하면 fd가 반환된다.
Context Manager
Python에서 with 구문을 지원하는 객체를 Context Manager라고 한다. __enter__(진입)과 __exit__(종료)를 구현하면 된다. with 블록을 나올 때 예외가 발생하더라도 __exit__가 반드시 호출되므로, 자원 해제를 자동화하는 표준 패턴이다. threading.Lock(), open(), DB Connection 등이 모두 이 패턴을 쓴다.
Connection Pool 고갈¶
DB Connection Pool의 최대 연결 수가 10이다. 모든 Connection이 사용 중인데 11번째 요청이 오면? 기다린다. 그런데 Connection을 잡고 있는 스레드가 Deadlock에 빠지면? Connection이 영원히 반환되지 않는다. 결국 Pool의 모든 Connection이 고갈되고, 새 요청은 전부 timeout에 걸린다.
Connection Pool의 "최대 동시 접속 수 제한"은 사실상 Semaphore다. Semaphore(10)은 "최대 10개 동시 접근"을 허용한다. 11번째는 기다린다.
(Ch.6에서 네트워크 Connection을, Ch.16에서 DB Connection Pool 사이징을 다룬다. Semaphore 개수를 정하는 게 곧 Pool 크기를 정하는 거다.)
GIL의 한계 재확인¶
Ch.3에서 "GIL이 있으니까 Python 스레드는 한 번에 하나만 실행된다"고 했다. Ch.4에서 "그래도 스레드가 Heap을 공유한다"고 했다. Ch.5에서 확인했다: GIL이 있어도 Race Condition은 발생한다.
정리하면:
- GIL이 보호하는 것: CPython 인터프리터의 내부 자료구조 (Reference Count 등)
- GIL이 보호하지 않는 것: 개발자의 공유 데이터 (재고, 잔액, 카운터 등)
"GIL이 있으니까 Lock 안 걸어도 된다"는 위험한 착각이다.
그래서 실무에서는 어떻게 하는가¶
1. threading.Lock()으로 Critical Section을 보호한다¶
import threading
lock = threading.Lock()
counter = 0
def increment():
global counter
with lock: # acquire + release 자동
counter += 1
with lock: 패턴을 쓰면 예외가 발생해도 Lock이 자동으로 해제된다. lock.acquire() / lock.release()를 직접 호출하는 것보다 안전하다.
2. Lock 순서를 항상 고정한다¶
여러 Lock을 잡아야 할 때, 순서를 고정해서 Deadlock을 방지한다:
def transfer(from_wh, to_wh, quantity):
# 항상 이름순으로 Lock을 잡는다
first, second = sorted([from_wh, to_wh], key=lambda w: w.name)
with first.lock:
with second.lock:
# 이동 처리
pass
Lock에 번호나 이름을 부여하고, 항상 작은 번호부터 잡는다. 이것만 지켜도 Circular Wait가 깨져서 Deadlock이 불가능하다.
3. Lock에 timeout을 건다¶
Deadlock이 발생해도 영원히 멈추지 않게 방어한다:
acquired = lock.acquire(timeout=5)
if not acquired:
# Lock 획득 실패 → Deadlock 가능성
raise TimeoutError("Lock 획득 시간 초과")
try:
# Critical Section
pass
finally:
lock.release()
timeout은 Deadlock을 "방지"하는 게 아니라 "탈출"하는 거다. 근본적인 해결은 Lock Ordering이다.
4. DB에서는 SELECT ... FOR UPDATE를 쓴다¶
Python 레벨이 아니라 DB 레벨에서 동시성을 제어해야 할 때:
# SQLAlchemy 예시
product = (
session.query(Product)
.filter(Product.id == product_id)
.with_for_update() # SELECT ... FOR UPDATE
.first()
)
if product.stock >= quantity:
product.stock -= quantity
session.commit()
with_for_update()가 해당 행에 잠금을 건다. 다른 트랜잭션은 이 행을 수정하려면 현재 트랜잭션이 끝날 때까지 기다려야 한다.
(Ch.15에서 더 자세히 다룬다.)
5. 언제 threading.Lock()을 쓰고, 언제 DB Lock을 쓰는가¶
| 상황 | 도구 | 예시 |
|---|---|---|
| 인메모리 공유 상태 | threading.Lock() |
캐시, 카운터, 전역 설정 |
| DB 행 단위 동시성 | SELECT ... FOR UPDATE |
재고, 잔액, 좌석 예약 |
| 분산 환경 (서버 여러 대) | Redis Lock, DB Lock | 여러 서버가 동일 자원 접근 |
threading.Lock()은 같은 프로세스 안의 스레드 간에만 동작한다. 서버가 여러 대면 각 서버의 Lock은 서로 모른다. 분산 환경에서는 DB Lock이나 Redis 기반 분산 Lock이 필요하다.
그런데 분산 Lock은 이 챕터에서 다룬 OS Lock보다 훨씬 어렵다. threading.Lock()은 같은 메모리 공간 안에서 동작하니까 "잠금 → 해제"가 확실하다. 하지만 서버 A가 Redis에 Lock을 걸고, 서버 A가 갑자기 죽으면? Lock은 누가 풀어주는가? 네트워크가 끊기면? Lock을 잡았는지 안 잡았는지조차 불확실해진다. 이번 챕터에서 배운 Mutual Exclusion, Deadlock, Lock Ordering 같은 원리는 분산 환경에서도 그대로 적용된다. 다만 "네트워크 지연"과 "부분 장애(partial failure)"라는 변수가 추가되면서 난이도가 완전히 달라진다. (Ch.17에서 Redis를 다룰 때 분산 Lock도 함께 본다.)
6. async def에서는 asyncio.Lock()을 쓴다¶
이 챕터의 실습은 전부 def(동기) 엔드포인트로 진행했다. FastAPI에서 def로 정의한 함수는 ThreadPool에서 실행되니까, threading.Lock()이 맞다.
그런데 async def로 정의한 엔드포인트는 이벤트 루프에서 실행된다 (Ch.3에서 다뤘다). 이벤트 루프 안에서 threading.Lock()을 쓰면? Lock이 이벤트 루프 전체를 블로킹한다. 다른 코루틴도 전부 멈춘다.
import asyncio
_async_lock = asyncio.Lock()
@router.post("/purchase-async-safe")
async def purchase_async_safe(quantity: int = 1):
async with _async_lock: # asyncio.Lock은 이벤트 루프를 블로킹하지 않는다
# Critical Section
pass
async def 안에서는 asyncio.Lock(), def 안에서는 threading.Lock(). 이걸 섞으면 문제가 생긴다.
7. 면접 Q&A (이 챕터로 답할 수 있는 단골 질문)¶
Race Condition이 정확히 뭔가요? 어떻게 재현하나요?
두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황이다. "읽기 → 체크 → 쓰기"가 원자적으로 실행되지 않으면 발생한다. 이번 챕터의 재고 차감 사례에서, stock -= 1이 bytecode로 LOAD/SUB/STORE로 쪼개지고 그 사이에 Context Switch가 끼면 두 스레드가 같은 값을 읽어 같은 값으로 덮어쓰는 일이 벌어진다. k6로 동시 요청을 쏴서 재고가 음수로 떨어지는 걸 직접 확인했다.
GIL이 있는데 왜 Python에서도 Lock이 필요한가요?
GIL은 CPython 인터프리터의 내부 자료구조(Reference Count 등)만 보호한다. 개발자가 만든 공유 데이터(재고, 잔액, 카운터)는 보호하지 않는다. 게다가 GIL은 약 5ms마다 스레드를 전환하는데, stock -= 1 같은 복합 연산은 여러 bytecode로 쪼개져 있어서 그 사이에 전환이 끼면 Race Condition이 발생한다. "GIL이 있으니까 Lock 안 걸어도 된다"는 위험한 착각이다.
Mutex와 Semaphore의 차이가 뭔가요?
Mutex는 한 번에 하나의 스레드만 들어갈 수 있게 하는 잠금이고, Semaphore는 동시에 N개까지 접근을 허용하는 카운팅 잠금이다. Binary Semaphore(N=1)는 Mutex처럼 동작하지만, Mutex에는 "잡은 스레드만 풀 수 있다"는 소유권 개념이 있다는 점이 다르다. 실무에서 Connection Pool의 "최대 연결 수 제한"이 사실상 Semaphore다. pool_size = Semaphore 카운트라고 생각하면 된다.
Deadlock이 발생하는 4가지 조건과 깨는 방법을 말해주세요.
Mutual Exclusion(자원 한 번에 하나만), Hold and Wait(잡은 채로 다음 자원 대기), No Preemption(강제로 못 뺏음), Circular Wait(순환 대기). 이 네 가지가 동시에 성립해야 Deadlock이 발생하고, 하나만 깨면 Deadlock이 불가능해진다. 실무에서 가장 흔하게 쓰는 방법은 Lock Ordering으로 Circular Wait를 깨는 거다. 이번 챕터의 창고 이동 사례에서 항상 이름순으로 Lock을 잡는 방식이 그 예다.
파일을 open하고 close 안 했을 때 무슨 일이 일어나나요?
파일에 걸린 잠금이 해제되지 않고, File Descriptor(fd)도 반환되지 않는다. open()이 Lock의 acquire고 close()가 release인데, release를 빠뜨린 셈이다. 누적되면 fd 한계에 걸려서 "Too many open files" 에러가 난다. Python에서는 with open() 패턴(Context Manager)으로 예외가 발생해도 자동으로 close가 호출되게 한다. with lock:도 같은 구조다.
asyncio.Lock과 threading.Lock의 차이가 뭔가요? 언제 무엇을 쓰나요?
threading.Lock()은 OS 레벨 잠금이라 획득하지 못한 스레드를 OS가 블로킹시킨다. 이걸 이벤트 루프 안에서 쓰면 루프 전체가 멈춰서 다른 코루틴도 같이 멈춘다. asyncio.Lock()은 이벤트 루프에 협력해서 잠금을 기다리는 동안 다른 코루틴에게 실행을 양보한다. FastAPI에서 def로 정의한 엔드포인트는 ThreadPool에서 실행되니까 threading.Lock(), async def는 이벤트 루프에서 실행되니까 asyncio.Lock()을 쓴다. 섞으면 문제가 생긴다.
Connection Pool 고갈이 동시성 문제와 어떻게 연결되나요?
Connection Pool의 "최대 연결 수 제한"이 사실상 Semaphore다. pool_size = 10이면 Semaphore(10)이고, 11번째 요청은 기다린다. 그런데 Connection을 잡은 스레드가 Deadlock에 걸리거나 close를 안 하고 끝나면 Connection이 영원히 반환되지 않는다. 결국 Pool 전체가 고갈되고, 새 요청은 timeout으로 떨어진다. with engine.connect() 같은 Context Manager 패턴으로 반환을 보장하는 게 그래서 중요하다. (Ch.6에서 Connection Pool 자체를 다룬다.)
3. 오늘 키워드 정리¶
CS를 키워드로 배운다는 건, 개별 개념을 외우는 게 아니라 개념들 사이의 연결을 이해하는 거다. 이번 챕터에서 나온 키워드들을 모아서 정리한다.
Race Condition (경쟁 조건)
두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황이다. 재현이 어렵고, 테스트에서 잡기 극히 어렵다. "읽기 → 체크 → 쓰기"가 원자적으로 실행되지 않으면 발생한다.
Critical Section (임계 영역)
공유 자원에 접근하는 코드 구간 중, 동시에 두 개 이상의 스레드가 실행하면 안 되는 부분이다. Mutex로 보호해야 한다.
Atomicity (원자성)
연산이 "다 되거나 아예 안 되거나"하는 성질이다. stock -= 1은 bytecode로 여러 줄이라 원자적이지 않다. Lock으로 원자성을 확보해야 한다.
Mutex / Lock (뮤텍스 / 잠금)
Critical Section에 한 번에 하나의 스레드만 들어갈 수 있게 하는 잠금 장치다. "Mutual Exclusion(상호 배제)"의 줄임말이다. Python에서는 threading.Lock()으로 만든다.
Deadlock (교착 상태)
두 개 이상의 스레드가 서로가 가진 자원을 기다리면서 영원히 멈추는 상태다. 에러가 나지 않고, 로그도 안 남는다. 디버깅이 극히 어렵다.
Mutual Exclusion (상호 배제)
자원을 한 번에 하나의 스레드만 사용할 수 있다는 조건. Deadlock의 4가지 필요조건 중 하나이자, Mutex의 핵심 성질이다.
Hold and Wait (점유 대기)
자원을 하나 이상 잡고 있는 상태에서 다른 자원을 추가로 기다리는 것. Deadlock의 4가지 필요조건 중 하나.
No Preemption (비선점)
다른 스레드가 가진 자원을 강제로 빼앗을 수 없다는 조건. Deadlock의 4가지 필요조건 중 하나.
Circular Wait (순환 대기)
A가 B를 기다리고 B가 A를 기다리는 순환 구조. Deadlock의 4가지 필요조건 중 하나. Lock Ordering으로 깰 수 있다.
Lock Ordering (잠금 순서 고정)
여러 Lock을 잡을 때 항상 정해진 순서(이름순, 번호순 등)로 잡는 기법. Circular Wait를 깨서 Deadlock을 방지한다. 실무에서 가장 흔하게 쓰는 Deadlock 방지법이다.
Semaphore (세마포어)
동시에 N개의 스레드까지 접근을 허용하는 카운팅 잠금이다. Binary Semaphore(N=1)는 Mutex처럼 동작하지만, Mutex에는 소유권 개념이 있다는 차이가 있다. Connection Pool의 "최대 연결 수 제한"이 사실상 Semaphore다.
Starvation (기아 상태)
Lock 경쟁에서 특정 스레드가 계속 밀려서 실행 기회를 얻지 못하는 상태. Deadlock은 전부 멈추지만, Starvation은 일부만 굶는다.
재등장 키워드¶
| 키워드 | 최초 등장 | 이번 챕터에서의 역할 |
|---|---|---|
| Thread | Ch.4 | Race Condition의 주체. Heap을 공유하기 때문에 발생 |
| Heap | Ch.4 | 스레드 간 공유 데이터가 사는 곳. Race Condition의 무대 |
| GIL | Ch.3 | switch interval(5ms) 단위로 스레드 전환. 복합 연산의 Race Condition은 못 막음 |
| Context Switch | Ch.3 | Critical Section 중간에 발생하면 Race Condition의 트리거 |
| Thread Pool | Ch.3 | FastAPI 요청 핸들러가 ThreadPool에서 실행됨. 공유 자원 접근의 원인 |
| File Descriptor | Ch.2 | 파일 잠금 사례. open/close = Lock의 acquire/release와 같은 구조 |
키워드 연관 관계¶
graph LR
THREAD["Thread<br/>(Ch.4)"] -->|"Heap 공유"| HEAP["Heap<br/>(Ch.4)"]
HEAP -->|"동시 접근"| RC["Race Condition"]
RC -->|"보호 구간"| CS["Critical Section"]
CS -->|"보호 수단"| MUTEX["Mutex / Lock"]
MUTEX -->|"보장"| ATOM["Atomicity"]
MUTEX -->|"잘못 쓰면"| DL["Deadlock"]
DL --> ME["Mutual Exclusion"]
DL --> HW["Hold and Wait"]
DL --> NP["No Preemption"]
DL --> CW["Circular Wait"]
CW -->|"해결"| LO["Lock Ordering"]
MUTEX -->|"일반화"| SEM["Semaphore"]
SEM -->|"N=1이면"| MUTEX
GIL["GIL<br/>(Ch.3)"] -.->|"인터프리터만 보호"| RC
CTXSW["Context Switch<br/>(Ch.3)"] -.->|"트리거"| RC
FD["File Descriptor<br/>(Ch.2)"] -.->|"파일 잠금"| MUTEX
style DL fill:#f96,stroke:#333
style RC fill:#f96,stroke:#333
여기까지의 지도 (누적)¶
위의 "키워드 연관 관계"가 이번 챕터 안의 연결이라면, 아래는 Ch.1부터 지금까지 도메인별로 쌓인 키워드다. 빈 레인은 앞으로 그 챕터에서 채워진다 — 다 채워지는 과정이 곧 이 강의의 지도다. (이번 챕터에서 추가된 키워드는 굵게.)
| 도메인 | 누적 키워드 (등장 챕터) |
|---|---|
| OS·실행 | System Call·Kernel·Mode Switch·Buffer·File Descriptor(2) → CPU/IO Bound·GIL·Event Loop·async/await·Thread/Process Pool·Context Switch·IPC(3) → Process·Thread·PCB/TCB·Memory Layout·Virtual Memory·Page·TLB·OOM(4) |
| 동시성·트랜잭션 | Race Condition·Critical Section·Mutex/Lock·Deadlock·Semaphore·Starvation(5) |
| 자료구조·알고리즘 | — (Ch.10에서 시작) |
| DB·캐시·튜닝 | — (Ch.13에서 시작) |
| 네트워크·분산 | — (Ch.6에서 시작) |
| 설계·AI·품질·보안 | Computational Thinking·CS 키워드(1) |
다음에 이어지는 이야기¶
이번 챕터에서는 같은 프로세스 안의 스레드가 Heap을 공유하면서 생기는 문제를 다뤘다. Race Condition, Lock, Deadlock. 전부 "하나의 서버 안에서" 벌어지는 일이다.
그런데 현실의 서비스는 서버 한 대로 끝나지 않는다. 서버가 3대인데 동시에 같은 재고를 차감하면? threading.Lock()은 서버 1대 안에서만 동작한다. 서버 3대의 Lock은 서로 모른다. 이 문제를 풀려면 네트워크를 넘나드는 Lock이 필요하고, 그러려면 먼저 네트워크가 어떻게 동작하는지를 알아야 한다.
다음 챕터에서는 네트워크 기초를 다룬다. TCP/IP, Socket, Connection Pool, Keep-Alive까지 파고든다. Connection Refused가 쏟아지는데 서버는 살아 있다? Connection Pool이 Semaphore라는 건 이제 알겠는데, 그 Connection 자체가 어떻게 만들어지고 관리되는가?