결국 같은 자원에 여러 스레드(메서드)가 접근해서 데이터를 수정하려고 해서 생기는 일
이 동시성 문제를 해결하기 위해:
핵심 해결 과제: 데이터가 수정된 것이 확정되기 전에 또 다른 스레드가 데이터를 수정하지 못하도록 해야한다
@Test
void should_fail_when_1000_threads_decrease() {
// Lock 없이 1000개 스레드가 동시에 재고 차감
// 결과: 동시성 문제로 재고 불일치 발생
}
@Test
void should_success_when_1000_threads_decrease_with_lock() {
// Lock 적용하여 1000개 스레드가 순차적으로 재고 차감
// 결과: 재고가 정확히 0개
}
┌─────────────────┐
│ Lock 요청 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ setIfAbsent() │ ← Redis SETNX와 유사
└────────┬────────┘
│
┌────┴────┐
│ 성공? │
└────┬────┘
│
Yes │ No
┌────┴────┐
│ │
▼ ▼
┌───────┐ ┌────────┐
│ 작업 │ │재시도 │
│ 수행 │ │(50ms) │
└───┬───┘ └────────┘
│
▼
┌───────┐
│ 락해제 │
└───────┘
redisTemplate.opsForValue().setIfAbsent(lockKey, value, duration)
매개변수:
lockKey: 락 키 (예: "stock:lock:1")value: 락의 식별자 (UUID-랜덤값)duration: 락이 자동으로 해제될 시간 (100ms)while (attempts < MAX_RETRY_COUNT && System.currentTimeMillis() < deadline) {
if (tryLock()) return true;
Thread.sleep(RETRY_INTERVAL_MS);
}
스핀락: 락을 얻을 때까지 CPU를 점유하며 계속 시도하는 방식
재고 차감에 있어서 락 충돌이 자주 일어날 것에 대비해 락을 최대한 얻기 위해선 spin lock 방식이 적합하다고 판단
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
Lua Script 사용 이유:
사용 흐름:
1. Java 코드에서 unlock() 호출
2. Lua 스크립트를 포함한 Redis 명령 생성
3. RedisTemplate이 Redis 서버에 Lua 코드 실행 요청
4. Redis 서버가 스크립트를 원자적으로 처리
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2
);
이유:
DB_Processing_Time (10ms) +
Network_Latency (3ms) +
Buffer_Time (87ms) = 100ms
Active_Threads (24) ×
Avg_Processing_Time (15ms) ×
Safety_Factor (2.0) = 1440ms ≈ 4초
왜 Active Threads를 곱하나?
MAX_RETRY_DURATION = "락을 못 잡고 최대 기다리는 시간"
이걸 계산할 때 스레드 개수를 곱하는 이유는, 경합 상황에서 내가 언제 락을 잡을 차례가 올지를 보수적으로 예측하기 위해서
DEFAULT_LOCK_TIMEOUT / 2 = 100ms / 2 = 50ms
stock:lock:{stockId} - 재고별로 독립적인 락┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│StockService │ --> │ LockService │ --> │LockRedis │
│ │ │ │ │Repository │
└─────────────┘ └──────────────┘ └─────────────┘
// Lock 외부에서 트랜잭션 시작 X
public void decreaseWithLock(Long stockId, int quantity) {
lockService.executeWithLock(key, () -> {
// Lock 내부에서 새로운 트랜잭션 시작
stockTransactionService.decreaseInNewTransaction(stockId, quantity);
return null;
});
}
┌─────────┐ 락 요청 ┌─────────┐
│Thread 1 │ ────────> │ Redis │
└─────────┘ └─────────┘
│ │
│ 실패 시 Sleep │
│<─────────────────── │
│ │
│ 재시도 (50ms 후) │
│────────────────────>│
특징:
┌─────────┐ 락 요청 ┌─────────┐
│Thread 1 │ ────────> │ Redis │
└─────────┘ └─────────┘
│ │
│ 구독 & 대기 │
│<─────────────────── │
│ │
│ 락 해제 알림 │
│<═══════════════════ │
│ │
│ 즉시 재시도 │
│────────────────────>│
특징:
명확히 하자면:
Redisson의 RLock은 이렇게 동작:
락을 점유 중이면 Pub/Sub 채널에 기다리는 클라이언트를 등록
락 해제 이벤트가 발생하면 알림(메시지) 브로드캐스트
클라이언트가 이 알림을 받고 즉시 락 시도
실패하면 Polling으로 재시도
그래서 Redisson이 Polling만 쓰는 Lettuce, Jedis 기반 Lock보다 락 획득 성능이 좋고 네트워크 효율적.
RFuture<Boolean> future = lock.tryLockAsync(waitTime, leaseTime, unit);
// leaseTime을 지정하지 않으면 Watch-dog 활성화
lock.lock(); // 기본 30초마다 자동 연장
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdWithPessimisticLock(@Param("id") Long id);
장점:
단점:
장점:
단점:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Domain A │────>│ RestTemplate │────>│ Domain B │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
└──────────── Event System ───────────────┘
MSA 전환 고려
확장성
성능
Pub/Sub 기반 효율성
안정성
확장성
모니터링 용이
유지보수성
stock:lock:{stockId} 형식프로젝트의 MSA 전환 가능성과 분산 환경 확장성을 고려하여 Redis Lock을 선택했으며, 그 중에서도 Redisson은:
이러한 이유로 최종적으로 Redisson을 채택하여 동시성 문제를 해결했습니다.
백오프 전략이란 재시도 간격을 점점 늘려가는 방법
락을 점유 중인 프로세스에 기회를 주어 락을 해제하게 할 시간을 벌어줌
짧은 간격으로 무한 시도를 하면 redis에 과부하가 걸림
redis나 네트워크에 부하를 줄이면 장애 회복에 도움이 되기 때문에
첫 번째 실패 후: 100ms 대기
두 번째 실패 후: 150ms 대기
세 번째 실패 후: 225ms 대기
...
점점 간격이 커짐 (최대 1,000ms까지만)
이걸 Exponential Backoff (지수 백오프) 라고 부릅니다.