한번에 한개의 요청만 들어오는 테스트 케이스
@Test
public void 재고감소() {
stockService.decrease(1L, 1L);
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
동시에 여러 요청이 들어오는 테스트 케이스
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0, stock.getQuantity());
}
위의 경우 race condition이 발생한다.
(race condition: 두개 이상의 스레드가 공유 데이터에 access 할 수 있고 동시에 변경하려고 할 때 발생하는 문제)
Synchronized란?
메소드나 블록에 동기화를 적용하여, 동시에 하나의 스레드만 해당 메소드나 블록에 접근할 수 있게 한다. 이를통해 동시성 문제를 방지할 수 있다.
자바에서는 Synchronized를 사용하면 하나의 스레드만 접근 가능하게 할 수 있다.(메소드에 붙인다)
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
}
그러나 정상적으로 실행되지 않는다.
왜?
스프링의 Transactional 애노테이션의 동작방식 때문이다.
Synchronized를 통해 동기화를 적용하지만, 적용되는 것은 프록시 객체이기 때문에 내가 예상한 값과 다른 결과 값이 나온다.
→ @Transactional 을 주석처리하고 실행하면 테스트 케이스가 통과한다.
서버를 여러개 돌리는 운영 상황에서는 synchronized의 경우 로직 종료 시간에 따라 갱신되지 않은 값을 가져가서 race condition 문제가 발생한다.
mysql이 지원해주는 방법으로 race condition을 해결!
실제로 데이터에 lock을 걸어서 정합성을 맞추는 방법.
@Lock() 애노테이션으로 쉽게 Pessimistic Lock을 걸 수 있다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
장점: 충돌이 빈번한 경우 Optimistic Lock보다 성능이 좋을 수 있다.
Lock을 통해 업데이트를 제어하므로 데이터 정합성이 보장된다.
단점: Lock을 활용하므로 성능 감소가 있을 수 있다.
실제로 락을 걸지 않고, 버전으로 관리하는 방식
@Entity
@Getter
@RequiredArgsConstructor
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
}
OptimisticLock은 실패 시 재시도를 해야한다.
장점: 별도의 락을 잡지 않으므로 성능 상 이점이 있다.
단점: 업데이트 실패 시 재시도 로직을 개발자가 직접 작성해줘야 한다.
충돌이 빈번히 일어날 것이라 예상 → Pessimistic Lock
충돌이 빈번하지 않다면 → Optimistic Lock 추천
트랜잭션 종료 시 락이 자동으로 해제되지 않음
Stock에 직접 락을 걸지 않고 별도의 공간에 걸게된다
장점: Timeout 구현이 쉬움
단점: 트랜젝션 종료 시 락 해제, 세션관리를 잘 해야하기 때문에 주의해서 써야하고
구현방법이 복잡할 수 있다.
분산락을 구현할 때 사용하는 라이브러리
@RequiredArgsConstructor
@Repository
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Object key) {
return redisTemplate
.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
}
public Boolean unlock(Object key) {
return redisTemplate.delete(key.toString());
}
}
Lettuce는 Netty기반의 Redis Client이며, 요청을 논블로킹으로 처리하여 높은 성능을 가집니다.spring-data-redis 의존성을 추가했다면, 기본적으로Lettuce 기반의 Redis Client가 제공됩니다.
스핀락 방식으로 facade 구현
@RequiredArgsConstructor
@Service
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
public void decrease(Long id) {
while (!redisLockRepository.lock(id)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
try {
stockService.decrease(id);
} finally {
redisLockRepository.unlock(id);
}
}
}
🐳 Lettuce 사용시 장단점
Lettuce는 redis 의존성을 추가하는 경우 기본 Redis Client로 제공되므로, 별도의 설정 없이 간단히 구현할 수 있다는 장점이 있습니다. 그러나 구현 방식에서 스핀락을 사용하기 때문에 레디스에 부하를 줄 수 있다는 것이 단점입니다.
직접 구현할 수도 있지만 Redisson에서는 Pub/Sub 기반의 분산락을 이미 구현하여 제공해주고 있으므로, 이를 사용하도록한다
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
@RequiredArgsConstructor
@Component
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final StockService stockService;
public void decrease(Long id) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean acquireLock = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!acquireLock) {
System.out.println("Lock 획득 실패");
return;
}
stockService.decrease(id);
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
}
🐳 Redisson 사용시 장단점Redisson에서는 분산락을 이미 구현하여 제공하고 있으므로, 별도의 구현 로직이 필요하지 않다는 장점이 있습니다.또한 Pub/Sub 기반으로 동작하여 기존 Lettuce를 통한 스핀락 기반의 분산락에 비해 Redis에 부하를 덜 준다는 장점이 있습니다.그러나 별도의 의존성을 추가해야 한다는 것이 단점이라고 볼 수 있을 것 같다.
🤔 참고 - Redis Vs MySQLRedis는 기본적으로 In-Memory DB이므로 Disk 기반으로 동작하는 MySQL에 비해 성능이 뛰어납니다.그러나 사용자들은 이를 크게 체감하지 못할 가능성이 크므로, 성능보다는 현재 인프라 상황을 고려하여 선택하는 것이 좋아보입니다.Redis를 사용하지 않는 프로젝트에서 분산락 구현을 위해 Redis를 사용하려 한다면, 별도의 Redis 구축에 대한 비용과, 이를 학습하는 비용이 발생합니다.그에 비해 이미 프로젝트에서 사용하고 있는 MySQL 등의 DB를 사용한다면 별도의 인프라 구축 비용과 학습 비용 없이 적용이 가능합니다.Redis와 MySQL 사이의 성능 차이가 조금 있다고는 하지만, 일반적으로는 크게 체감되지 않을 가능성이 크므로, 여러 조건과 상황을 고려하여 적절한 기술을 도입하는 것이 좋아보입니다.