동시성 문제

김파란·2024년 6월 24일

Spring-Library

목록 보기
4/7

1. 재고시스템

1). 문제점

  • 레이스 컨디션: 둘 이상의 쓰레드가 공유 데이터에 액세스할 수 있고 동시에 변경을 하려고 할때 발생하는 문제
  • 하나의 쓰레드가 작업이 끝나고 DB에 반영하기 전에 다른 쓰레드가 작업을 해서 생기는 문제
@Test
    void  동시에_100개의_요청() throws Exception {
        // given
        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();

        // when
        Stock stock = stockRepository.findById(1L).orElseThrow();

        // then
        Assertions.assertThat(stock.getQuantity()).isEqualTo(0L);
    }

2. 자바 해결법

1). Synchronized

  • @Transactional이 있으면 동작방식때문에 실패하게 된다
  • @Transactional은 우리가 만든 클래스를 매핑한 클래스로 새로 만들어서 실행 (콜백패턴 이용)
  • 서비스계층의 @Transactional 없애면 동시성 해결가능하다
	startTransaction();
    stockService.decrease(id, quantity);
    endTransaction(); // 트랜잭션 종료시점에 데이터베이스에 업데이트를 하게되는데
    // -> 종료되기 전에 다른 쓰레드가 와서 decrease 메소드를 사용할 수 있다

(1). 문제점

  • 프로세스 안에서만 보장이 되기때문에 서버가 한 대일경우에는 문제가 없지만
  • 서버가 두 대이상이면 데이터 접근이 여러곳에서 가능하기 때문에 다시 문제가 생긴다

3. MySql 해결법

  • Pessimistic Lock: 실제로 락을 걸어서 정합성을 맞추는 방법, 데드락 주의
  • Optimistic Lock: 실제 락은 아니고 버전을 확인해서 정확성을 맞추는 방법
  • Named Lock: 이름을 가진 락을 획득, 별도의 명령어로 해제를 하거나 선점시간이 끝나야 해제
    -> Perssimistic은 row나 테이블단위로 걸지만 Named는 메타데이터 락킹이다

1). Pessimistic Lock

  • 충돌이 빈번하다면 Optimistic보다 성능면에서 낫다
// Repository 계층
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);
}

// Service 계층
@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);
    }
}

2). Optimistic Lock

  • 보통은 Pessimistic Lock보다 성능면에서 낫다
  • 재시도 로직을 개발자가 직접 짜야됨
@Version
private Long version; // 엔티티에 추가만 해주면 된다 따로 코드작성할 필요 없음
// repository 계층
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
    
// service 계층
@Service
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

    public OptimisticLockStockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithOptimisticLock(id);
        stock.decrease(quantity);
        stockRepository.save(stock);
    }
}
// 재시도 해야하니까 Facade도 만든다
@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);
            }
        }
    }
}
		// 테스트코드
		// given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    try {
                        optimisticLockStockFacade.decrease(1L, 1L);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // when
        Stock stock = stockRepository.findById(1L).orElseThrow();

        // then
        Assertions.assertThat(stock.getQuantity()).isEqualTo(0L);

3). NamedLock

  • 락이 자동으로 해제되지 않기때문에 해제를 명시적으로 해줘야한다
  • get-lock과 release-lock으로 획득, 해제가 가능하다
  • pessimistck이 stock에 직접적으로 락을 걸었다면 named는 별도의 공간에 락을 건다
  • 주로 분산락을 구현할때 사용
// repository 계층
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);
}
// facade
@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()); // 끝나면 락을 해제한다
        }
    }
}
// service 계층
@Transactional(propagation = Propagation.REQUIRES_NEW) // 별도의 트랜잭션이 필요하다
    public void decrease(Long id, Long quantity) {
        // Stock 조회
        // 재고를 감소시킨뒤
        // 갱신된 값을 저장
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);



        stockRepository.saveAndFlush(stock);
    }

4. Redis 해결법

  • 분산락을 구현하는 대표적인 2가지
  • Lettuce: setnx 명령어를 활용한 분산락 방식, spin lock 방식
  • Redisson: pub-sub기반으로 lock 구현 제공

1). Lettuce

  • spin lock 방식이기때문에 레디스에 부하를 줄수 있다. Thread.sleep으로 텀을 줘야한다
// redis SetNx방식
@Component
public class RedisLockRepository {

    private RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = 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));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}
// 재시도 로직
@Component
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository repository, StockService stockService, RedisLockRepository redisLockRepository) {
        this.stockService = stockService;
        this.redisLockRepository = redisLockRepository;
    }

    public void decrease(Long id, Long quantity) throws InterruptedException {
        while (!redisLockRepository.lock(id)) {
            Thread.sleep(100);
        }

        try{
            stockService.decrease(id, quantity);
        }finally {
            redisLockRepository.unlock(id);
        }
    }
}

2). Redisson

  • 락획득을 한번 또는 적게만 시도하므로 레디스의 부하를 줄여주게 된다
  • 락 획득 재시도가 기본 제공된다.
  • 의존성 추가: implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.31.0'
@Component
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 id, Long quantity) {
        RLock lock = redissonClient.getLock(id.toString());

        try{
            boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
            if(!available){
                System.out.println("Lock 획득 실패");
                return;
            }
            stockService.decrease(id,quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            lock.unlock();
        }
    }
}

0개의 댓글