🧊 재고 처리에 대한 동시성 문제
동시성 문제는 동일한 데이터에 2개 이상의 스레드, 혹은 세션에서 가변 데이터를 동시에 제어하게 될 때 나타나는 문제이다
만약 하나의 작업이 진행하는 중인데 다른 작업이 끼어들어 데이터를 또 다시 처리하면 데이터가 부숴지게 되는 것이다
이때 나오는 문제가 Concurrency 문제가 발생하는 것이다
간단한 로직
우선 재고가 감소하는 로직을 만든다.
- StockEntity
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고 부족");
}
this.quantity = this.quantity - quantity;
}
}
- StockRepository
public interface StockRepository extends JpaRepository<Stock, Long> {
}
- StockService
@Service
@RequireArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(final Long id, final Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
🧊 동시성 문제 발생
- Spring으로 개발하는 환경이라면 Spring은 멀티스레드의 환경으로 개발이 진행된다.
우리가 상식적으로 여러명이 같은 내용에 접근하여 quantity를 변화시키려고 한다면 한개씩 한개씩 처리하는 것을 생각한다
하지만 실상은 그렇지 않다는 것이다.
1번 쓰레드 | Stock | 2번 쓰레드 |
read: q = 5 | quan = 5 | |
quan = 5 | read: q = 5 | |
write: q = 4 | quan = 4 | |
quan = 4 | write: q = 4 |
- 이게 무슨 일일까 바로 읽는 순간의 문제가 되는 것이다.
- 둘다 메소드를 동시에 들어오고 Transaction이 시작된다. 그러면 해당 로직이 1번 사용자와 2번 사용자가 동시에 진행하게 되는데 이때 둘다 읽으면 5일때 데이터를 읽기 때문에 read한 데이터를 기반으로 값을 변경하고 commit 한다는 것이다.
- 결과적으로 하나의 작업이 누락된것 처럼 보이게 되는 것이다.
🧊 동시성 문제 해결 방법
그렇다면 간단하게 생각해보자 하나씩 해결하면 되는거 아닐까? 그럼 그걸 어떻게 진행하는게 맞을까?
- Java 의 Syncronized 를 통해 동기화하는 방법
- DB 에 Lock을 사용
- Pessimistic Lock (비관적 락)
- Optimistic Lock (낙관적 락)
- Redis Lock
- Lettuce Lock
- Redisson Lock
Java 의 Syncronized
- 스레드의 동기화를 위해 자바에서 지원하는 기술이다
- 하나의 공유자원에 동시에 접근하지 못하도록 막는것이다
- 공유되는 데이터의 Thread-safe를 위해서 syncronized로 스레드 간 동기화를 시켜 thread-safe 하게 만들어준다
Thransactional 의 어노테이션을 같이 사용하면 문제가 발생한다
따라서 Syncronized는 현제 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터 접근을 막아 순차적으로 데이터에 접근할 수 있도록 해준다
- 변경된 StockService 로직
@Service
@RequiredArgsConstructor
public class SynchronizedStockService {
private final StockRepository stockRepository;
public sybchronized void decrease(final Long id, final Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
이렇게 하면 사실상 해결된것으로 볼 수 있다. 하지만 @Transactional이 없어진 것을 확인 할 수 있다.
우리는 Transactional이 왠만하면 필요하다. 한개의 로직에 여러가지의 SQL이 필요하고 Dirty checking을 사용하는 경우도 다반사이기 때문이다.
- 해결하는 방법을 간단하게 말하자면 실제로 실행되는 로직은 Transactional을 처리해주고 해당 메소드를 부르는 또다른 메소드를 불러서 그 메소드를 Syncronized화 해주는 것이다
Syncronized 의 문제점
- Syncronized는 해당 메소드를 동기화 시키는 방법이지 해당 서버 자체를 통합 시켜줄수는 없다 무슨 말이냐면 만약 서버가 여러개라면 각각의 서버가 각각의 작업을 처리하게 된다. 즉, 재고를 담당하는 메소드가 여러개 생기는 것이다. 하지만 그 모든 서버는 하나의 DB를 바라보고 있기 때문에 이때 문제가 발생하는 것이다.
- 그래서 DB 자체에 Lock을 거는 방식을 선택할 수 있다.
DB Lock
1. Pessimistic Lock 사용하기
Pessimistic Lock 즉, 비관적 락이 뭘까?
- 모든 트랜잭션은 충돌이 발생할것이다 라고 가정을 하고 Lock을 거는 것이다.
- exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없는 것이다.
- Repository의 변경
- DB Lock이기 때문에 DB에 직접적인 접근을 다루는 Repository에 적용하면 된다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
Stock findById(Long id);
}
- 장점
- 충돌이 빈번하게 일어난다면 롤백의 횟수를 줄이게 되고 Optimistic Lock보다는 성능이 좋다
- 데이터의 적합성을 보장한다
- 단점
- 데이터 자체에 별도의 락을 잡기 때문에 동시성이 떨어져 성능적인 저하가 발생한다
- 특히 읽기가 많이 이루어지는 DB의 경우 손해가 크다
- 락으로 인해 데드락까지 이어질 수 있다.
- 데드락과 관련된 블로깅이다.
💾 LOCK을 활용한 트랜잭션( 4 )
해당 내용은 쉬운코드님의 영상을 기반으로 제작되었습니다 🔌 LOCK우리는 트랜잭션 (3) 에서 다양한 트랜잭션에 대한 문제를 만나보았다. 거기서 계속해서 진행하던 write는 과연 단순한 값 바
latewalk.tistory.com
2. Optimistic Lock 사용하기
Optimistic Lock 즉, 낙관적 락이란?
- DB Lock을 사용하지 않고, Version 관리를 통해 어플리케이션 레벨에서 처리한다
- 대부분의 트랜잭션이 충돌하지 않는다고 가정하는 방법이다
- JPA에서 버전을 통한 관리기능을 사용할 수 있다
- 커밋전까지는 충돌에 관한 내용을 알 수 없다.
- 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트한다
- 자원에 락을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때가서 처리하는 것이다.
- Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.OPTIMISTIC)
Stock findByWithOptimisticLock(Long id);
}
- Facade
@Service
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
- 장점
- 충돌이 나지 않는다는 가정이기에 별도의 락을 안잡아서 Pessimistic Lock 보다 성능이 좋지 않다
- 단점
- 업데이트를 실패시, 롤백 로직을 개발자가 직접 개발해야한다
- 충돌이 빈번하게 일어나면 롤백처리로 인해 Pessimistic 보다 성능이 좋지 않을 수도 있다.
Redis Lock
1. Lettuce 란?
- Setnx 명령어를 활용하여 분산락을 구현 (key:value를 set 할 때. 기존의 값이 없을때만 Set하는 명령어)
- Setnx 는 SpinLock(분산락) 방식이기 때문에 retry 로직을 개발자가 작성한다.
- SpringLock이란, Lock을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하면서 반복적으로 시도해야 한다.
- RedisLockRepository
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplat<String, String> redisTemplate) {
this.redisTemplate
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
public String generateKey(Long key) {
return key.toString();
}
}
- LettuceLockStockFacade
@Component
public class LettuceLockStockFacade {
private RedisLockRepository redisLockRepository;
private StockService stockService;
public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
this.redisLockRepository = redisLockRepository;
this.stockService = stockService;
}
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);
}
}
}
로직 실행 전 후로 Lock 획득과 해제를 수행해야 하므로 Facade 클래스를 추가한다.
- SpinLock 방식으로 락을 얻기를 시도한다
- 락을 얻은 후, 재고 감소 비지니스 로직을 처리한다
- 그 후 락을 해제해준다.
- 단점
- 구현이 간단하다는 장점이 있지만, Spin Lock 방식이 Lock을 얻을 때까지 Lock을 얻기를 시도하기 때문에 계속해서 Redis에 접근해서 Redis에 부하를 줄 수 있다는 단점이 존재한다.
2. Redisson 란?
- Pub/Sub 기반으로 Lock 구현을 제공한다
- Pub/Sub 방식이란, 채널을 하나 만들고, 락을 점유중인 스레드가 락을 해제했음을 대기중인 스레드에게 알려주면 대기 중인 스레드가 락 점유를 시도하는 방식
- 해당 방식은 Lettuce와는 다르게 대부분 별도의 Retry 방식을 작성하지 않아도 된다
- Redisson은 따로 라이브러리도 설치해주어야 한다
- Redisson은 lock관련 클래스를 제공해주기 떄문에 repository는 필요가 없지만 해제는 직접해주어야 하기 때문에 facade클래스를 생성한다.
- RedissonLockStockFacade
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private RedissonClient redissonClient;
private StockService stockService;
public RedissonLockStockFacade(RedissonClient redissonClient, StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}
public void decrease(Long key, Long quantity) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(20, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패!");
return;
}
stockService.decrease(key, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
- finally를 보면 lock을 직접해제 해주는 모습을 볼 수 있다
Lettuce 와 Redisson의 비교
- Lettuce
- 구현이 간단하고 Spring에서 Redis를 config를 통해 service까지 구현을 하면 별도의 라이브러를 사용하지 않아도 된다
- spin lock방식이디 때문에 동시에 많은 스레드가 lock을 획득하려는 대기 상태라면 redis에 부하가 갈 수 있다
- Redisson
- lock을 획득하려는 재시도를 기본으로 제공한다
- pub/Sub으로 구현되어 있기때문에 lettuce 대비 redis에 부하가 덜 간다.
- 하지만 별도의 라이브러를 사용하기 떄문에 따로 공부가 필요하다.
'CS > 💾 DB' 카테고리의 다른 글
💾 파티셔닝과 샤딩 (0) | 2024.05.31 |
---|---|
💾 JOIN이란?? (0) | 2024.05.31 |
💾 낙관적 락(Optimistic) 과 비관적 락(Pessimisitic) (1) | 2024.04.27 |
💾 MVCC, 트랜잭션 ( 5 ) (feat. MySQL과 PostgreSQL 비교) (1) | 2024.04.27 |
💾 LOCK을 활용한 트랜잭션( 4 ) (1) | 2024.04.26 |