
멀티스레딩 환경에서 프로그래밍을 할 때 개발자가 마주하는 가장 까다로운 적 중 하나는 바로 레이스 컨디션(Race Condition)입니다. 두 개 이상의 스레드가 공유 자원에 동시에 접근하여 수정하려고 할 때, 실행 순서에 따라 결과가 달라지는 이 현상은 시스템의 예측 불가능성을 초래합니다. 파이썬은 GIL(Global Interpreter Lock)이 존재함에도 불구하고, I/O 바운드 작업이나 공유 객체 수정 시 여전히 동기화 이슈가 발생합니다. 본 가이드에서는 실무에서 가장 빈번하게 사용되는 세 가지 동기화 도구인 Lock, RLock, Semaphore의 구체적인 활용 방법과 차이를 심층적으로 분석합니다. 이를 통해 안전하고 확장 가능한 동시성 코드를 작성하는 전문적인 노하우를 전달하고자 합니다.
1. 레이스 컨디션(Race Condition)의 이해와 위험성
레이스 컨디션은 '경쟁 상태'라고도 불립니다. 예를 들어, 은행 계좌 잔고를 100명이 동시에 1원씩 올리는 코드를 작성했다고 가정해 봅시다. 동기화 처리가 없다면 결과는 100이 아닌 95나 98처럼 엉뚱한 숫자가 나올 수 있습니다. 이는 '읽기-수정-쓰기'의 원자적(Atomic) 구조가 깨지기 때문입니다.
발생 원인 3가지
- 비원자적 연산:
count += 1은 내부적으로 로드, 증가, 저장의 세 단계로 나뉩니다. - 스케줄링의 불확실성: 운영체제가 스레드를 언제 중단시키고 문맥 교환(Context Switch)을 할지 알 수 없습니다.
- 공유 자원 노출: 전역 변수나 싱글톤 객체 등 여러 스레드가 동시에 참조하는 영역이 존재할 때 발생합니다.
2. Lock, RLock, Semaphore 기능 및 차이 비교
각 도구는 상황에 따라 선택 기준이 명확히 달라집니다. 아래 표를 통해 핵심 차이를 한눈에 파악해 보시기 바랍니다.
| 구분 | Lock (기본 락) | RLock (재진입 가능 락) | Semaphore (세마포어) |
|---|---|---|---|
| 기본 개념 | 가장 단순한 상호 배제 도구 | 동일 스레드 내 중복 획득 허용 | 동시 접근 가능한 스레드 수 제한 |
| 소유권 | 특정 스레드에 귀속되지 않음 | 획득한 스레드에 소유권 부여 | 카운팅 기반 (소유권 개념 없음) |
| 데드락 위험 | 재귀 호출 시 자가 데드락 발생 | 동일 스레드 내에서는 안전함 | 카운트 관리에 따른 정체 위험 |
| 주요 용도 | 단순 변수 수정 및 리소스 보호 | 복잡한 클래스 메서드 내 재귀 호출 | DB 커넥션 풀, 서버 부하 제어 |
3. 실무형 활용 방법과 Sample Example
(1) Lock: 가장 엄격한 상호 배제
가장 기본이 되는 도구입니다. acquire()로 잠금하고 release()로 해제합니다. 가급적 with 문을 사용하여 예외 상황에서도 안전하게 해제되도록 하는 것이 전문적인 작성법입니다.
import threading
class Counter:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def increment(self):
with self.lock: # Lock 획득 및 자동 해제
self.count += 1
# 사용 예시
counter = Counter()
threads = [threading.Thread(target=counter.increment) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
print(f"최종 결과: {counter.count}")
(2) RLock: 재귀적 구조에서의 안정성 확보
만약 동일한 스레드가 이미 획득한 락을 다시 요청해야 하는 복잡한 로직(예: 재귀 함수, 메서드 간 상호 호출)이 있다면 RLock을 사용해야 합니다. 일반 Lock을 쓰면 본인이 본인의 락을 기다리는 무한 대기 상태(Deadlock)에 빠지게 됩니다.
import threading
class RecursiveApp:
def __init__(self):
self.rlock = threading.RLock()
self.data = 0
def outer_func(self):
with self.rlock:
self.data += 1
self.inner_func() # 동일 스레드 내 재진입 발생
def inner_func(self):
with self.rlock: # 일반 Lock이었다면 여기서 멈춤
self.data += 1
(3) Semaphore: 리소스 접근량의 유연한 제어
세마포어는 특정 자원에 접근할 수 있는 '허가증'의 개수를 지정합니다. 예를 들어 데이터베이스 연결이 동시에 5개까지만 가능해야 한다면 Semaphore(5)를 생성하여 트래픽을 제어합니다.
import threading
import time
# 동시에 3개의 스레드만 허용
pool_limit = threading.Semaphore(3)
def access_resource(id):
print(f"스레드 {id}: 대기 중...")
with pool_limit:
print(f"스레드 {id}: 자원 사용 시작")
time.sleep(2)
print(f"스레드 {id}: 자원 반납")
for i in range(7):
threading.Thread(target=access_resource, args=(i,)).start()
4. 결론 및 최적화 전략
레이스 컨디션을 해결하는 1순위 방법은 '공유 자원을 최소화하는 것'입니다. 하지만 공유가 불가피하다면 아래의 전략을 따르십시오.
- 단순한 플래그나 카운터 수정은
Lock을 사용하십시오. - 객체 지향 설계에서 부모와 자식 메서드가 모두 락을 필요로 한다면
RLock이 정답입니다. - 네트워크 대역폭이나 메모리 제한 등 하드웨어적 임계치가 있다면
Semaphore로 병렬성을 조절하십시오.
5. 출처 및 참고 문헌
- Python Documentation: threading — Thread-based parallelism
- Modern Operating Systems (Andrew S. Tanenbaum)
- Effective Python: 90 Specific Ways to Write Better Python (Brett Slatkin)
- Real Python: An Intro to Threading in Python