병렬 처리를 위한 threading 사용시, DB 접근이 필요한 로직이 있을 경우,
데이터 무결성 등을 위해 DB 접근이 동시에 되는 것을 막도록 Lock을 걸어야 한다.
근데 이때, Lock을 건 동일한 프로세스에서 Lock을 풀기 전에 다시 DB에 접근해야 하는 경우가 있다.
(이러지 않는게 좋겠지만, 비동기 처리를 고려하지 않은 코드를 비동기 코드로 바꾸면서,
한 프로세스에서 여러 차례의 DB 접근이 있을 때
Lock, Release, Lock, Relase를 일일히 하지 않고 처리하고 싶을 때 등)
이를 가능하게 하는 것이 RLock이다, (Reentrant Lock: 재입장 가능한 잠금)
일반 Lock은 Lock을 걸었냐 안 걸었냐를 통해서만 접근을 막는다면
RLock은 누가 Lock을 걸었는지,
그리고 건 프로세스가 다시 접근한다면 허용해주고, 몇번의 DB 접근을 했는지를 count해서,
최종적으로 Lock을 풀 때는 count가 다시 역으로 0이 되었을 때 풀어지는 원리이다.
단, 코드 분석이 복잡해져서 사용하지 않는 것이 권장된다.
아래는 Gemini 설명 추가
파이썬 스레딩: Lock과 RLock의 차이점 🔑
파이썬의 threading 모듈에서 **Lock**과 **RLock**은 모두 여러 스레드가 공유 자원에 동시에 접근하는 것을 막아 **경쟁 상태(Race Condition)**를 방지하는 동기화 도구입니다. 하지만 동작 방식과 사용 사례에 중요한 차이가 있습니다.
결론부터 말하면, RLock은 한 스레드가 동일한 락을 여러 번 획득(acquire)할 수 있도록 허용하는 반면, Lock은 그렇지 않습니다.
## threading.Lock: 가장 기본적인 잠금
Lock은 가장 간단한 형태의 잠금 메커니즘입니다. '잠김(locked)'과 '열림(unlocked)' 두 가지 상태만 존재합니다.
- 동작 방식
- 한 스레드가 lock.acquire()를 호출하여 락을 획득하면, 락은 '잠김' 상태가 됩니다.
- 다른 스레드가 acquire()를 호출하면, 락이 release()되어 '열림' 상태가 될 때까지 **블로킹(대기)**합니다.
- 락을 획득했던 스레드가 lock.release()를 호출하면, 락은 '열림' 상태가 되고 대기하던 다른 스레드 중 하나가 락을 획득할 수 있게 됩니다.
- ⚠️ 주의할 점 이미 락을 획득한 스레드가 또다시 acquire()를 호출하면, 자기 자신을 영원히 기다리는 **교착 상태(Deadlock)**에 빠지게 됩니다. release() 코드가 acquire() 뒤에 있기 때문입니다.
### Lock 예제 코드
import threading
shared_number = 0
lock = threading.Lock()
def worker():
global shared_number
for _ in range(100000):
# with 문을 사용하면 acquire()와 release()가 자동으로 처리됩니다.
with lock:
shared_number += 1
threads = [threading.Thread(target=worker) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"최종 결과: {shared_number}") # lock이 없다면 500000이 보장되지 않음
## threading.RLock: 재진입 가능한 잠금 (Re-entrant Lock)
RLock은 재진입 가능한(Re-entrant) 락입니다. 이름 그대로 한 스레드가 자기 자신이 이미 보유한 락을 중복해서 획득할 수 있습니다.
- 동작 방식
- RLock은 내부적으로 "락을 소유한 스레드"와 "획득 횟수(recursion level)"를 기록합니다.
- 어떤 스레드가 rlock.acquire()를 처음 호출하면, 해당 스레드가 락의 소유자가 되고 획득 횟수는 1이 됩니다.
- 동일한 스레드가 acquire()를 다시 호출하면, 블로킹되지 않고 획득 횟수만 1 증가합니다.
- 다른 스레드가 acquire()를 호출하면, 기존 Lock과 동일하게 블로킹됩니다.
- 락을 획득한 스레드는 release()를 호출할 때마다 획득 횟수가 1 감소하며, 획득 횟수가 0이 되어야만 락이 완전히 해제되어 다른 스레드가 획득할 수 있습니다.
- 💡 주요 사용 사례 주로 재귀 함수나 복잡한 함수 호출 구조에서 여러 단계에 걸쳐 동일한 락을 필요로 할 때 유용합니다.
### RLock 예제 코드 (재귀 함수)
Lock을 사용하면 교착 상태에 빠지는 재귀 함수 예제입니다.
import threading
# rlock = threading.Lock() # -> 이 코드를 사용하면 바로 교착 상태에 빠짐!
rlock = threading.RLock()
def recursive_function(n):
with rlock:
print(f"스레드 {threading.current_thread().name}이(가) 락 획득 (n={n})")
if n > 0:
recursive_function(n - 1)
# with 블록을 벗어나면 release()가 호출됨
# RLock을 사용하면 재귀 호출에서 락을 다시 획득해도 문제가 없습니다.
recursive_thread = threading.Thread(target=recursive_function, args=(3,))
recursive_thread.start()
recursive_thread.join()
print("작업 완료")
위 코드에서 Lock을 사용하면, n=3에서 락을 획득한 후 n=2를 위해 재귀 호출했을 때 자기 자신이 만든 락을 다시 획득하려다 영원히 대기하게 됩니다. 하지만 RLock은 이를 허용하므로 코드가 정상적으로 실행됩니다.
## Lock vs. RLock 핵심 비교
| 기능 | threading.Lock | threading.RLock |
| 재진입 | ❌ 불가능 (교착 상태 발생) | ✅ 가능 |
| 소유권 개념 | 없음 (누구나 release 가능)¹ | 있음 (락을 획득한 스레드만 release 가능) |
| 내부 구조 | 단순한 플래그 (잠김/열림) | 소유 스레드와 획득 횟수 카운터 |
| 사용 사례 | 간단하고 명확한 임계 영역 보호 | 재귀 함수, 복잡한 호출 구조의 임계 영역 보호 |
| 성능 | 약간 더 가볍고 빠름 | 약간 더 복잡하고 느림 |
¹ 이론적으로는 다른 스레드가 release()를 호출할 수 있지만, 이는 매우 위험하며 버그를 유발하는 잘못된 설계입니다.
## 언제 무엇을 써야 할까?
- Lock을 사용하세요 (기본): 대부분의 경우 Lock으로 충분합니다. 코드가 단순하고, 한 스레드가 특정 락을 중복으로 획득할 가능성이 없다면 Lock을 사용하는 것이 좋습니다. 더 간단하고 성능상 이점이 있습니다.
- RLock을 사용하세요 (특수한 경우): 작성하려는 코드가 재귀적으로 락을 필요로 하거나, 하나의 클래스 내에서 여러 메서드가 서로를 호출하며 동일한 락을 공유해야 하는 복잡한 상황에서만 RLock을 고려하세요. 필요하지 않은데 RLock을 남용하면 코드를 이해하기 어렵게 만들 수 있습니다.