동시성 이슈란 무엇인지 알아보자.
여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제
이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 많을 때 자주 발생하게 된다.
특히, 자바의 Spring 프레임워크는 기본적으로 스프링 빈을 싱글톤(Singleton)으로 등록하기 때문에 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 문제가 발생한다.
동시성 이슈가 발생하는 좋아요 기능의 예시를 보자.
두 명의 사용자가 좋아요를 클릭했기 때문에 좋아요는 2가 되어야 하지만, 각 유저가 조회한 0에 +1을 하므로 1만 증가된다.
이때 발생하는게 동시성 문제! 라고 하고 위와 같은 상황을 경쟁 조건(Race Condition) 이라고 한다.
경쟁조건(Race Condition)
여러 프로세스 및 스레드가 동시에 동일한 데이터(공유 데이터)를 조작할 때 타이밍이나 접근 순서에 따라 예상했던 결과와 다른 상황.
동시성 이슈를 해결하는 방안에 대해서 Java와 Spring 기준으로 알아보자.
재고 엔티티와 재고 감소 메소드를 작성하였다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
public Stock(){
}
public Stock(Long productId, Long quantity){
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
public void decrease(Long quantity){
if( this.quantity - quantity < 0){
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
테스트 코드를 작성해서 정상적으로 작동하는지 확인해보자.
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@Test
public void 재고감소(){
stockService.decrease(1L, 1L);
// 100 - 1 = 99개 여야함
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(99, stock.getQuantity());
}
@Test
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();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}
0을 예상했지만, 96의 재고가 남을 것을 확인할 수 있다.
이것을 해결해보자.
자바에서는 메서드에 synchronized 키워드를 추가하거나 코드 내부에 synchronized 어노테이션을 추가하면 된다.
테스트를 실행시키면 정상적으로 통과하는 것을 볼 수 있다.
하지만 해당 방식은 하나의 프로세스(서버) 내에서만 올바르게 작동하기 때문에, 서버가 2개 이상인 경우에는 똑같이 동시성 이슈가 발생한다.
자원 요청시 동시성 이슈가 자주 발생할 것이라고 비관적으로 예상하여 락을 거는 방법.
한 트랜잭션이 데이터에 접근하고있으면 다른 트랜잭션의 조회나 쓰기를 금지한다.
위의 도식도를 보고 비관적락을 이해해보자.
이처럼 Transaction을 이용하여 충돌을 예방하는 것이 비관적 락(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);
}
@Lock 어노테이션으로 비관적락을 적용시킨다.
package com.example.stock.service;
import com.example.stock.domain.Stock;
import com.example.stock.repository.StockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public PessimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity){
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
@Transactional 어노테이션을 붙여 재고 감소 메소드에 트랜잭션을 적용시킨다.
이전의 테스트가 정상적으로 완료되는것을 볼 수 있다.
낙관적 락은 실제 락을 이용하지 않고 version 같은 컬럼을 추가해서 데이터의 정합성을 맞추는 방법이다. 모든 요청을 락 없이 처리하고 데이터 정합성 이슈가 발견되면 그때서야 롤백을 수행해 정합성을 맞춘다.
동시성 이슈가 발생했을 때 기존의 버전 값을 한 트랜잭션에서 이미 업데이트한 경우, 다른 트랜잭션에서는 해당 버전값을 조회할 수 없어 업데이트가 발생하지 않는다. 업데이트가 발생하지 않으면 문제가 있는 것으로 판단해 롤백 처리를 하여 데이터의 정합성을 보장한다.
예시 코드를 보며 확인해보자.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock(){
}
@Version 어노테이션을 사용한 필드 version을 추가해준다.
새롭게 낙관적락을 위한 컴포넌트를 생성한다. 아래의 decrease 메소드는 재고 감소를 실행한다.
동시성 제어에서 충돌이 발생할 것으로 예상하지 않다가, 예외(데이터 무결성 문제)가 발생하면 잠시 대기(50ms) 한 후 재시도 합니다.
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.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);
}
}
}
}
해당 테스트도 문제없이 완료된다.
테이블이나 레코드, 데이터베이스 객체가 아닌 사용자가 지정한 문자열에 대해 락을 획득하고 반납하는 잠금으로, 한 세션이 Lock을 획득한다면, 다른 세션은 해당 세션이 Lock을 해제한 이후 획득할 수 있다.
Named Lock은 우리가 흔히 사용하는 MySQL을 사용해 분산 락을 구현할 수 있다. MySQL 에서는 getLock()을 통해 락을 획득, releaseLock()으로 해지할 수 있다.
단점으로는 Lock이 자동으로 해제되지 않기 때문에, 직접 로직을 구현하여 해제해주어야 한다.
또한, 일시적인 락의 정보가 DB에 저장되고, 락을 획득, 반납하는 과정에서 DB에 불필요한 부하가 있을 수 있으며, 락과 비즈니스 로직의 트랜잭션을 분리할 필요가 있다.
이제 Named Lock을 Spring과 MySQL을 사용하여 구현해보자.
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
Named Lock을 구현하기 위해, Lock을 획득하는 LockRepository를 만들어주자.
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
getLock 메소드는 Lock을 획득하고, releaseLock은 Lock을 해제하는 메소드이다. 둘 다 MySQL에서 네임드락을 위해 해당 함수들을 제공해준다. JPA에서 제공하지않는 데이터베이스의 고유한 함수이기 때문에 nativeQuery를 사용해주자.
다음으로 NamedLockStockFacade이다.
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
this.lockRepository = lockRepository;
this.stockService = stockService;
}
@Transactional
public void decrease(Long id, Long quantity){
try{
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
decrease 메소드를 보면 lockRepository에서 id(스트링)에 대한 Lock을 획득하고, 재고 감소로직을 수행한 뒤, 그 id에 대한 Lock을 해제한다.
테스트를 진행해보자.
@SpringBootTest
class NamedLockStockFacadeTest {
@Autowired
private NamedLockStockFacade namedLockStockFacade;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
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{
namedLockStockFacade.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}