ReentrantLock 기반 동시성 제어
목차
멀티스레드 환경에서 여러 스레드가 같은 자원에 동시에 접근하면 의도하지 않은 결과가 발생합니다. 한 스레드가 값을 읽고 갱신하기 전에 다른 스레드가 끼어들면, 두 스레드 모두 같은 초기값을 기준으로 판단하게 됩니다.
이 글에서는 계좌 이체 시나리오를 통해 레이스 컨디션이 어떻게 발생하는지 살펴보고, ReentrantLock과 ConcurrentHashMap을 조합해 계좌별 인메모리 락을 구현하는 방법을 알아봅니다.
1. 레이스 컨디션
두 스레드가 같은 계좌 잔액을 동시에 조회하면 다음과 같은 문제가 생깁니다.
스레드 A: 계좌 1번 잔액 조회 → 100,000원 확인
스레드 B: 계좌 1번 잔액 조회 → 100,000원 확인
스레드 A: 50,000원 출금 → 잔액 50,000원으로 갱신
스레드 B: 80,000원 출금 → 잔액 20,000원으로 갱신 (잔액 부족인데 성공!)
두 스레드가 각자 잔액이 충분하다고 판단해서 출금이 모두 통과됩니다. 이처럼 연산의 결과가 실행 순서에 따라 달라지는 현상을 **레이스 컨디션(Race Condition)**이라 합니다.
해결책은 “조회 → 검증 → 갱신”을 한 스레드만 실행할 수 있는 임계 구역으로 묶는 것입니다.
2. ReentrantLock
java.util.concurrent.locks.ReentrantLock은 임계 구역을 직접 지정할 수 있는 명시적 락입니다.
ReentrantLock lock = new ReentrantLock();
lock.lock(); // 락 획득 — 다른 스레드는 여기서 대기
try {
// 임계 구역: 동시에 하나의 스레드만 실행
} finally {
lock.unlock(); // 락 해제 — 반드시 finally에서 실행
}
finally에서 해제해야 하는 이유는 임계 구역에서 예외가 발생해도 락이 반드시 풀려야 하기 때문입니다. 해제하지 않으면 다른 스레드가 영원히 대기하는 **무한 대기(lock leak)**가 생깁니다.
Reentrant(재진입) 이란 같은 스레드가 이미 획득한 락을 다시 획득할 수 있다는 의미입니다. 덕분에 중첩 메서드 호출에서 같은 락을 요청해도 데드락이 발생하지 않습니다. 단, 서로 다른 스레드 간의 데드락(A가 락1 보유 후 락2 대기, B가 락2 보유 후 락1 대기)은 여전히 주의해야 합니다.
3. ConcurrentHashMap
계좌별로 락을 관리하려면 Map<계좌ID, Lock> 구조가 필요합니다. 일반 HashMap은 멀티스레드 환경에서 내부 상태가 꼬일 수 있습니다.
// ❌ HashMap — 멀티스레드 환경에서 위험
Map<Long, ReentrantLock> locks = new HashMap<>();
// ✅ ConcurrentHashMap — 내부적으로 동기화되어 스레드 안전
ConcurrentHashMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
ConcurrentHashMap은 맵 자체의 읽기/쓰기를 스레드 안전하게 처리합니다. 다만 이는 맵 연산의 안전성이고, 맵에서 꺼낸 ReentrantLock 객체를 통한 임계 구역 보호는 별도로 처리해야 합니다.
4. computeIfAbsent — 원자적 생성
계좌별 락을 처음 사용할 때 생성하는 방식에도 주의가 필요합니다.
// ❌ put — 매번 새 락을 생성해서 기존 락을 덮어씀
locks.put(accountId, new ReentrantLock());
// ✅ computeIfAbsent — 없을 때만 생성
ReentrantLock lock = locks.computeIfAbsent(accountId, id -> new ReentrantLock());
put을 사용하면 다음과 같은 문제가 생깁니다.
스레드 A: 계좌 1번 락 획득 (로컬 변수로 참조 보유)
스레드 B: put으로 새 락을 생성해 맵에 덮어씀
→ A와 B가 서로 다른 락 객체를 사용하게 되어 동기화가 깨짐
computeIfAbsent는 원자적(atomic) 연산이라 여러 스레드가 동시에 호출해도 Value가 한 번만 생성됩니다. 한 번 생성된 계좌 락은 덮어쓰이지 않으므로 락의 동일성이 유지됩니다.
5. InMemoryAccountLock 구현
앞선 세 가지를 조합하면 계좌별 인메모리 락을 구현할 수 있습니다.
@Component
public class InMemoryAccountLock implements AccountLock {
private final ConcurrentHashMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
@Override
public void lockAccount(Long accountId) {
ReentrantLock lock = locks.computeIfAbsent(accountId, id -> new ReentrantLock());
lock.lock();
}
@Override
public void releaseAccount(Long accountId) {
ReentrantLock lock = locks.get(accountId);
// isHeldByCurrentThread() — 현재 스레드가 보유한 락만 해제
// 보유하지 않은 락을 unlock()하면 IllegalMonitorStateException 발생
if (lock != null && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
계좌별로 독립적인 락을 가지므로 서로 다른 계좌는 동시에 이체할 수 있습니다.
계좌 1번 이체 중 → locks = { 1L: locked }
계좌 2번 이체 시도 → locks = { 1L: locked, 2L: locked } ← 동시에 가능
계좌 1번 이체 재시도 → 1L이 이미 locked → 대기
6. 인메모리 락 vs 분산 락
인메모리 락은 단일 서버 내에서만 유효합니다. 여러 서버 인스턴스가 실행되는 환경이라면 각 서버가 독립적인 락 맵을 가지기 때문에 서버 간 동기화가 되지 않습니다.
| 인메모리 락 | 분산 락 (Redis) | |
|---|---|---|
| 범위 | 단일 서버 내에서만 유효 | 여러 서버 간 공유 |
| 속도 | 빠름 (메모리 접근) | 네트워크 오버헤드 있음 |
| 서버 재시작 | 락 초기화됨 | Redis에 유지됨 |
| 구현 복잡도 | 단순 | Redisson 등 라이브러리 필요 |
| 적합한 환경 | 단일 서버 애플리케이션 | MSA, 다중 인스턴스 환경 |
단일 서버 환경이라면 인메모리 락으로 충분합니다. MSA나 수평 확장이 필요한 환경이라면 Redis 기반 분산 락(Redisson)을 고려해야 합니다.
마무리
- 레이스 컨디션은 “조회 → 검증 → 갱신”을 원자적으로 묶지 않을 때 발생한다
ReentrantLock으로 임계 구역을 지정하고,unlock()은 반드시finally에서 실행한다- 락 맵은
ConcurrentHashMap을 사용해야 맵 자체의 스레드 안전성이 보장된다 computeIfAbsent는 원자적 연산이라 동시에 호출해도 락이 한 번만 생성된다isHeldByCurrentThread()로 현재 스레드가 보유한 락만 해제해IllegalMonitorStateException을 방지한다- 인메모리 락은 단일 서버에서만 유효하며, 다중 인스턴스 환경에서는 분산 락으로 전환해야 한다