개발 후 운영을 하다 보면 동시성 이슈에 대해 맞닥뜨리게 된다. 분명 요청은 한번만 했다고 생각했지만, 동일한 요청이 중복되어 서버로 전달되는 경우도 있으며 동시에 많은 유저들이 이벤트를 발생시켜 동시성 이슈를 발생시키기도 한다.
이번 작업에서는 DB Lock 과 Redis를 활용한 Server-Side에서의 중복방지 및 동시성을 해결하는 방법에 대해 소개를 하고 적용해 보려고 한다.
@Test
void test_concurrency()throws InterruptedException {
log.info("동시성 테스트 시작");
// Given
log.info("동시성 테스트 준비");
stockRepository.saveAndFlush(new Stock(1L,10L)); //1번 상품 재고 10개 세팅
int threadCount = 10;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// When
log.info("동시성 테스트 진행");
for (int i=0; i < threadCount;i ++){
executorService.submit(() -> {
try{
stockService.decrease(1L,1L); //1번 상품 1개씩 재고 감소
}finally {
latch.countDown();
}
});
}
latch.await();
// Then
log.info("동시성 테스트 결과 검증");
Stock stock = stockRepository.findById(1L).orElseThrow();
// 예상결과 10 - (10 * 1) = 0
assertEquals(0, stock.getQuantity());
}
예상 결과는 0으로 예상했지만 테스트 결과 0이 아닌 다른 값이 나오게 되었다.
이는 한 트랜잭션이 커밋 되기 전에 다른 트랜잭션이 변경하려는 값을 읽어버려서 생기는 문제이다. 현재 문제를 DB Lock 과 Redis를 이용해서 해결해 보려고 한다.
💡 Lock이란 ? 데이터베이스는 여러 사용자들이 같은 데이터를 동시에 접근하는 상황에서, 데이터의 무결성과 일관성을 지키기 위해 락을 사용한다.
비관적 락은 SELECT ~ FOR UPDATE문을 통해 DB가 제공하는 Lock을 해당 데이터에 거는 방식이다.
FOR UPDATE가 있으면 다른 트랙잭션에서 수정, 삭제, FOR UPDATE문을 통해 조회를 할 순 없다.
JPA에서는 @Lock 어노테이션을 이용해 비관적 락을 쉽게 구현할 수 있다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
Stock findByIdWithPessimisticLock(Long id);
위와 같이 LockModeType 을 PESSIMISTIC_WRITE 로 설정하게 되면
데이터베이스에 SELECT FOR UPDATE 쿼리가 나가게 되고 조회한 레코드 자체에 락을 걸게 된다.
낙관적 락은 DB가 제공하는 Lock이 아니다. Application 수준에서 확보하는 Lock으로 대표적으로 Version을 사용하는 방식이 있다.
즉 UPDATE ~ WHERE version = :version 쿼리를 보내고 affected row가 0이면 낙관적 에러 예외를 발생시킨다.
JPA에서는 엔티티에 @Version이 적용된 필드가 있다면 낙관적 락이 적용된다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
}
💡 분산 락이란 서로 다른 프로세스에서 동일한 공유자원에 접근할 때, 데이터의 원자성을 보장하기 위해 활용되는 방법이다.
Redis를 활용해 분산 락을 적용 할 때 Redis에서 제공하는 SETNX 연산을 사용하여 스핀 락(spin lock) 방식으로 분산 락을 구현할수있다.
위는 SETNX 명령을 사용한 모습이다.
이 방식을 통해 애플리케이션에서 스핀 락(spin lock)을 구현할 수 있다.
Spring Boot에서 Redis를 사용하기 위해서 pom.xml에 아래의 dependency를 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(final RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(final Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(String.valueOf(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(final Long key) {
return redisTemplate.delete(String.valueOf(key));
}
}
RedisTemplate 을 주입 받아서 락을 관리하는 RedisLockRepository 를 구현한다.
lock() 메소드는 setIfAbsent() 를 사용하여 SETNX 를 실행한다. 이때, Key는 상품 엔티티의 ID로, Value는 lock 으로 설정한다. 세번째 파라미터는 Timeout 설정이다.
unlock() 메소드는 Key에 대해 DEL 명령을 실행한다. 이를 통해 락을 release 할 수 있다.
@Transactional
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)){
Thread.sleep(100);
}// 락을 획득하기 위해 대기
try{
//비지니스 로직
stockService.decrease(key, quantity);
}finally {
redisLockRepository.unlock(key);
}// 락을 해제
}
decrease 메소드에 스핀 락을 적용한 소스코드이다. while 문을 활용해 락을 획득할 때 까지 무한 반복을 돈다. 레디스 서버에 부하를 덜기 위해 반복마다 100ms 쉬어준다. 임계 영역에 진입한 후 비즈니스 로직을 처리하고 나서는 finally 블럭을 사용해 락을 해제해준다.
일명 따닥 이슈라 불리는 서버 측으로 중복 요청 전달되는 현상과 동시에 많은 유저들이 이벤트를 발생시켜 동시성 이슈를 발생시키는 현상은 개발자가 흔하디흔하게 맞닥뜨리는 이슈라고 생각한다.
이번 작업을 통해 배운것으로 해당 문제가 생길 시 DB Lock / Redis 사용하여 조금 더 효율적으로 핸들링해 사용 하길 바란다. 👏