경쟁상태는 두 개 이상의 스레드가 공유 데이터에 액세스할 수 있고, 동시에 변경을 하려고 할 때 발생하는 문제입니다.
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
public synchronized void decrease(Long id, Long quantity) {
final Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
우리는 아래 테스트에서 0의 결과를 예상하지만, 동시에 들어오는 요청들이 갱신 전 값을 읽고 수정하면서 실제 갱신이 누락되는 현상이 발생하게 됩니다.
synchronized 를 메서드 선언부에 붙여주면 해당 메서드는 한 개의 스레드만 접근이 가능하게 됩니다.
@Transactional
public synchronized void decreases(Long id, Long quantity) {
final Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
.
⚠️ 여기서 문제가 있습니다.
Spring Transactional 의 동작 방식은 아래와 같이 우리가 만든 클래스를 래핑한 클래스를 새로 만들어서 실행하게 됩니다.
public class TransactionService {
private StockService stockService;
...
public void decreases(Long id, Long quantity) {
startTransaction();
stockService.decreases(id, quantity);
endTransaction();
}
...
}
우리가 만든 클래스를 필드로 가지고 호출하게 되는데 트랜잭션 종료 전에 다른 스레드가 갱신된 전 값을 읽게 되면 결국 이전과 동일한 문제가 발생하게 됩니다.
여기서 단순하게 Transactional Annotation 을 제거하여 문제를 해결할 수는 있습니다.
.
⚠️ 하지만, Java Synchronized 의 문제는 또 존재합니다.
Java Synchronized 는 하나의 프로세스 안에서만 보장이 됩니다.
그러다보니 서버가 1대일 경우에는 괜찮겠지만, 서버가 2대 이상일 경우 결국 여러 스레드에서 동시에 데이터에 접근할 수 있게 되면서 레이스 컨디션이 발생하게 됩니다.
Time
Thread-1
Stock
Thread-2
10:00
select
quantity:5
quantity:5
select
10:05
update
quantity:4
quantity:4
update
대부분의 운영 서비스는 2대 이상의 서버로 운영되기 때문에 Java Synchronized 는 거의 사용되지 않는 방법입니다.
DataBase Lock
MySQL
Pessimistic Lock
비관적 락.
실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
Row or Table 단위로 Locking
Exclusive Lock을 걸게 되며, 다른 트랜잭션에서는 Lock 해제 전에 데이터를 가져갈 수 없음
데드락(두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다는 상태) 주의 필요
충돌이 빈번하게 일어나거나 예상된다면 추천하는 방식
장점.
충돌이 빈번하게 일어난다면 Optimistic Lock 보다 성능이 좋을 수 있다.
락을 통해 업데이터를 제어하므로 데이터 정합성이 보장된다.
단점.
별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.
/** Repository **/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
...
/** Service **/
@Transactional
public void decrease(Long id, Long quantity) {
final Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
Optimistic Lock
낙관적 락.
실제로 Lock 을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
먼저 데이터를 읽은 후 update 수행 시, 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 수행
내가 읽은 버전에서 수정사항이 생겼을 경우 애플리케이션에서 다시 읽은 후에 작업을 수행
충돌이 빈번하게 일어나지 않을 경우 추천하는 방식
장점.
별도의 락을 잡지 않으므로 Pessimistic Lock 보다 성능상 이점이 있다.
단점.
업데이트 실패 시 재시도 로직을 개발자가 직접 작성해야 한다.
/** Entity **/
@Version
private Long version;
...
/** Repository **/
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
...
/** Service **/
@Transactional
public void decrease(Long id, Long quantity) {
final Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
...
/** Facade **/
@Component
@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);
}
}
}
}
Named Lock
이름을 가진 Lock 획득 후, 해제할 때까지 다른 세션은 이 Lock 을 획득할 수 없도록 함
Transaction 종료 시 Lock 이 자동으로 해제되지 않는 점 주의
별도의 명령어로 해제를 수행해 주거나 선점 시간이 끝나야 해제
비관적 락과 유사하지만 로우나 테이블이 아닌 메타데이터의 락킹을 하는 방식
커넥션 풀 부족 현상을 막기 위해 데이터 소스를 분리해서 사용할 것을 권장
주로 분산락 구현 시 사용
장점.
Timeout을 쉽게 구현 가능
단점.
트랜잭션 종료 시 락 해제, 세션 관리 필요
구현 방법이 복잡해질 수 있음
/** Repository **/
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
...
/** Service **/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
final Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
...
/** Facade **/
@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
Reference.
Redis
MySQL의 Named Lock과 유사한 방식
Lettuce
setnx(set if not exist) 명령어를 활용하여 분산락 구현
Spin Lock 방식
락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식
재시도 로직 개발 필요
장점.
구현이 단순
별도의 라이브러리가 불필요(spring data redis 사용 시 기본적으로 Lettuce 적용)
단점.
Spin Lock 방식이므로 동시에 많은 스레드가 락 획득 대기 상태라면 레디스에 부하가 갈 수 있음
락 획득 재시도 간 대기 시간도 필요
# key 는 1 이고, value 는 lock 인 데이터 생성
> setnx 1 lock
(integer) 1
# 이미 1 이라는 키가 있으므로 실패
> setnx 1 lock
(integer) 0
# 키 삭제
> del 1
(integer) 1