이번에 온라인 경매 관련 프로젝트를 맡게 되면서,
자연스럽게 동시성 이슈에 대해 관심을 갖게 되었다.
만약 현재 가격이 10,000원인 상태에서,
A와 B가 동시에 11,000원에 입찰을 걸게 된다면?
이 문제를 해결하기 위해서는 동시성을 얕게나마 공부할 필요성을 가지게 되었다.
(Lock will never die!!!)
자바에는 synchronized
키워드가 있다.
자바가 스레드 동기화를 지원하기 위한 가장 간단한 방법이다.
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
위 메서드처럼, 메서드 반환값 앞에 synchronized
를 달아주면 된다.
그러면 해당 메서드를 품은 클래스의 인스턴스를 기준으로 lock이 발생한다.
public void decrease(Long id, Long quantity) {
synchronized (this) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
이번에는 코드 블록 형태로,
( ) 괄호 안에는 락을 걸고자 하는 객체를 넣어준다.
위 예에서는 this
로 잡았는데,
따라서 메서드 케이스와 동일한 효과를 가지게 된다.
@Transactional
public void decrease(Long id, Long quantity) {
synchronized (this) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
스프링을 자주 사용하는 유저라면
@Transactional
을 익히 알고 있을 것이다.
데이터 소스와의 트랜잭션을 자동으로 처리해주는
아주 고마운 어노테이션이다.
그러나 이놈이 synchronized
를 만나면 아주 사악해질 수 있다.
public synchronized void decrease(Long id, Long quantity) {
getTransaction();
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
doTransaction();
}
위 코드처럼, @Transactional
은 타겟 메서드 전후로
트랜잭션을 처리하는 로직을 삽입한 프록시 객체를 만들어낸다.
따라서 @Transactional
이 생성하는 프록시 객체 내 메서드에는
synchronized
가 붙지 않게 된다...
그러니까 @Transactional
은 사용하지 않고,
Spring Data JPA가 지원하는 메서드들이 가진 트랜잭션을 활용하면 된다.
synchronized
는 키워드 하나로
스레드 동기화를 지원한다는 점에서 매우 간편하다.
그러나 서버가 여러 대인 분산 환경에서는,
그 여러 서버 간의 동기화는 지원할 수 없다!
(그도 그럴 것이, 멀리 떨어진 서버가 무엇이고 어떻게 동기화할 거고
따위의 로직이 하나도 없다)
그러므로 웬만한 실제 서비스 환경에서는
사용하지 않는 것이 좋다.
(비관적 ROCK 좋아하시는 분들은 '앨리스 인 체인스' 꼭 들어보세요)
DB의 타겟 테이블에 직접적으로 lock을 건다.
그래서 다른 스레드는 lock이 풀리기 전까지 이 테이블에 접근할 수 없다.
(위 부분은 쓰기냐 읽기냐에 따라 다르기 때문에
비관적 락 쓰기 읽기는 따로 찾아보시는 걸 추천드립니다)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdWithPessimisticLock(@Param("id") Long id);
(Spring Data JPA를 쓰기 때문에,
@Query
를 통해 키워드를 피해 메서드를 정의했다)
@Lock
어노테이션을 활용해
비관적 락임을 명시할 수 있다.
실제로 DB에 lock을 걸고 풀지는 않고,
배제적인 lock은 JPA 레벨에서 처리한다.
장점
-> 트랜잭션을 늘리지 않기 때문에 충돌이 잦은 환경에서는 유리하다.
단점
-> DB에 대한 접근을 배타적으로 한 스레드가 점유하기 때문에 기본적으로 성능 면에서 불리하고, 데드락의 가능성까지 있다.
(낙관적 ROCK 하면 이 형님들인듯..?)
DB에 lock을 걸지 않고,
대신에 DB에 대한 버전을 관리하여 동기화를 지원한다.
만약 현재 가격이 10,000원인 상태에서,
A와 B가 동시에 11,000원에 입찰을 걸게 된다면?
이 문제로 다시 돌아와보자.
여기에 낙관적 락을 추가하게 되면,
A가 어쨌든 먼저 DB에 요청을 넣었다면
입찰가는 11,000원으로 수정될 뿐만 아니라
version
역시 +1 된다.
그러면 B의 11,000원 수정 요청은
이전 version 값을 가지고 있을 것이므로 취소 되고,
대신에 +1된 version을 가지고 다시 요청하게 될 것이다.
@Version
private Long version;
스프링 환경에서는 version 역할을 맡을 필드를
위와 같이 엔티티에 @Version
으로 명시해줘야 한다.
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdWithOptimisticLock(@Param("id") Long id);
비관적 락과 거의 유사하고,
LockModeType
만 다르게 명시하면 된다.
@RequiredArgsConstructor
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService stockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
stockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
위 예시에서 "+1된 version을 가지고 다시 요청"
한다는 말을 기억하자.
그 요청을 우리가 직접 명시해줘야 한다.
그래서 위와 같은 파사드 클래스가 따로 필요한 것이다.
(테스트 케이스에서도 아래와 같은 예외가 계속 발생하고,
파사드에서는 예외를 잡아서 스레드를 잠깐 재웠다가 다시 시도한다)
장점
-> DB를 배타적으로 막지 않으므로, 충돌이 적은 환경에서 유리하다.
단점
-> update 실패 했을 때의 로직을 직접 작성해줘야 한다.
-> 충돌이 빈번한 경우, 트랜잭션을 추가적으로 실행하므로 성능 면에서 불리하다. (실제 테스트에서도 비관적 락에 비해 3배 느렸다)
(지상 최고의 네임드 ROCK은 이분들이 아닐까...)
mysql의 get_lock
, release_lock
명령어를 통해
DB 내에 lock을 거는 방식이다.
lock의 대상이 테이블이나 row 데이터가 아니라는 점에 주의하자.
일정한 key 값을 우리가 정하는 방식이다.
@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);
리포지토리 계층에 네이티브 쿼리를 품은 메서드를 위와 같이 작성해준다.
(mysql이라는 DB에 의존적이기 때문에)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decreaseWithLock(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
@Transactional
부분을 유의깊게 보면,
Propagation 옵션이 REQUIRES_NEW인 것을 알 수 있다.
lock을 얻고 푸는 로직과,
실제 데이터를 수정하는 트랜잭션이 따로 이뤄져야 하기 때문이다.
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decreaseWithLock(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
그리고 lock을 얻고 푸는 로직은
파사트 클래스에 따로 명시하고,
실제 데이터를 수정하는 비즈니스 로직은
서비스 계층에 의존하여 호출하면 된다.
Redis를 활용하는
다음 시리즈로 이어집니다!
🦾 참고 🦾
위 글은 인프런에서 상용 님의
'재고시스템으로 알아보는 동시성이슈 해결방법' 수업을 참고하여 작성되었습니다.