문서 검색

재고 시스템으로 알아보는 동시성 이슈

인상 깊었던 내용

“동시성이 왜 발생했을까?”

  • Race Condition 이 발생했기 때문
  • 둘 이상의 스레드가 공유 자원에 접근할 수 있고 동시에 변경을 하려 할 때 발생하는 문제
📝

레이스 컨디션(Race Condition) 이란 ?

  • 둘 이상의 스레드가 공유 데이터에 액세스할 수 있고 동시에 변경하려고 할 때 발생하는 문제

    • 둘 이상의 스레드 : 요청
    • 공유 데이터 : 재고 데이터
    • 동시에 변경하려고 할 때 : 업데이트 할때
    • 발생하는 문제 : 값이 정상적으로 바뀌지 않는 문제
  • 해결방법

    • 하나의 스레드만 데이터에 액세스 할 수 있도록 한다.

동시성 해결 방법

  1. Java 언어 자체적으로 지원하는 Synchronized 이용하기

자바 자체적으로 지원하는 Synchronized 를 이용해서 문제를 해결하기 위해서는 Transactional 어노테이션을 사용할 수 없다.

문제점

여러 서버가 한 번에 접근한다면 어떻게 될까? syncrhonized 예약어는 각 프로세스 내부에서만 보장이 되기 때문에 다른 프로세스에서 접근하는 행위는 막을 수 없게된다. -> 결국 여러 스레드에서 동시에 접근하게 되어 Race Condition 이 발생한다.

실제 운영 환경에서는 서버를 여러대 두고 사용하는 것이 범용적이기 때문에 synchronized 예약어로 동시성 문제를 해결하진 않는다.

만약 Transactional 을 사용하면 어떻게 될까?

  • Spring은 Transaction을 AOP 방식으로 처리하기 위해 Transactional 을 사용하는 클래스를 래핑한 새로운 클래스를 만들어, startTransaction -> Transactional 을 명시한 메서드 실행 -> endTransaction 순서로 실행하게 된다.
  • 여기서 startTransaction -> stockService.decrease 까지 실행 되었고 다른 스레드에서 다시 startTransaction, stockService.decrease 를 실행 한다면 아직 endTransaction 이 안된 데이터로 인해 2번의 재고 감소를 했지만 실질적으로는 재고 감소가 1번 밖에 발생하지 않게된다.
  1. MySQL 자체 Lock 사용하기
  • Pessimistic Lock (비관적 락)

    • 실제로 데이터에 Lock 을 걸어서 정합성을 맞추는 방법입니다. exclusive lock 을 걸게되며 다른 트랜잭션에서는 lock 이 해제되기전에 데이터를 가져갈 수 없게됩니다.

    데드락이 걸릴 수 있기때문에 주의하여 사용하여야 합니다.

  • Optimistic Lock (낙관적 락)

    • 실제로 Lock 을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다. 먼저 데이터를 읽은 후에 update 를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트 합니다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은후에 작업을 수행해야 합니다.
  • Named Lock

    • 이름을 가진 metadata locking 입니다. 이름을 가진 lock 을 획득한 후 해제할때까지 다른 세션은 이 lock 을 획득할 수 없도록 합니다. 주의할점으로는 transaction 이 종료될 때 lock 이 자동으로 해제되지 않습니다. 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다

낙관적락과 비관적락을 사용하는 기준은 “동시에 수정을 하는일이 빈번하게 일어나는가?” 이다.

  • 동시에 수정을 하는일이 적다면 낙관적락

    • 충돌이 빈번하게 일어나는 상황에서 낙관적락을 사용하게 된다면 모든 요청이 완료될때까지 재시도를 수행한다.
    • 이는 성공할때까지 시도한다고 가정했을 때 데이터베이스에 굉장히 많은 요청을 보내게 되기 때문에 비효율적이다.
  • 동시에 수정하는 상황이 빈번하게 일어난다면 비관적락

    • 비관적락을 사용한다면 lock 을 걸고 수행하기때문에 이후의 요청들은 업데이트가 완료될때까지 기다렸다가 순차적으로 진행된다.
    • 단점으로 데이터 로우와 테이블에 직접적인 물리적 락을 사용하기 때문에 빈번하지 않은 충돌 상황에서는 성능 감소가 일어날 수 있다.

이러한 관점에서 봤을 때 충돌가능성이 많다면 비관적락이 더 좋은 선택이 될 수 있다.

🧪

1,000명의 사용자가 동시에 A 라는 상품을 구매할 때 “충돌이 빈번하게 일어난다” 라고 생각할 수 있다.

반대로 1,000명의 사용자가 A 라는 상품을 구매하지만 구매시간이 각자 다를 때 (예를들어 사용자 1은 1시에, 사용자 2는 1시 1분에 사용자 3은 1시 2분에…) “충돌이 비교적 적게 일어난다” 라고 생각할 수 있다.

낙관적락의 구현은 JPA를 사용한다면 크게 어렵지 않게 구현할 수 있기때문에 실무에서도 사용된다.

Redis 활용하기

  • Lettuce 활용 Lettuce 를 활용하면 NamedLock 과 비슷하게 동작하지만 별도의 세션을 관리할 필요가 없는 장점이 있다. 다만, Lettuce는 스핀 락 방식으로 Lock 을 획득하지 못했을 때 획득 재시도 하는 로직을 작성하는데, 이 때 일정 시간의 텀을 두지 않으면 레디스에 부하를 줄 수 있다.

Lettuce 는 setnx 명령어를 통해 데이터를 저장하는데, set 하는 key 가 존재하지 않을 때만 데이터를 저장한다. 이 원리로 Lock 을 제어한다.

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);  
    }}

@Component  
public class RedisLockRepository {  
  
    private final 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();  
    }  
  
  
}
  • Redisson 활용 Redisson 을 사용하기 위해 별도의 의존성을 추가 해야한다.

Redisson 은 Pub/Sub 구조로 Lettuce 가 무한으로 락을 획득하려고 재시도하는 단점을 개선할 수 있는 방식이다. 이는, 채널을 하나 두고 스레드 1이 락을 획득한 뒤 값을 저장하고 락을 풀게되면 채널에게 락을 다 썼다는 내용을 보내게 되고, 다른 스레드는 이 채널을 구독하며 기다리고 있다가 락을 다 썼다는 메시지를 본 후에 락 획득을 시도한다.

Redisson 은 비즈니스 로직에 직접적으로 락 제어 코드를 작성해야하는 복잡성이 있지만, 의존성 하나만 설치하면 별도의 작업 없이 다중 서버에서의 동시성을 해결할 수 있게된다.

@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();  
        }    }  
}

Lettuce vs Redisson

  • Lettuce

    • 구현이 간단하다
    • spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
    • spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
  • Redisson

    • 락 획득 재시도를 기본으로 제공한다.
    • pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 덜 간다.
    • 별도의 라이브러리를 사용해야한다.
    • lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다.

분산 락 선택 가이드

  • 재시도가 필요하지 않은 lock 은 lettuce 활용
  • 재시도가 필요한 경우에는 redisson 를 활용

Mysql vs Redis

  • Mysql

    • 이미 Mysql 을 사용하고 있다면 별도의 비용없이 사용가능하다.
    • 어느정도의 트래픽까지는 문제없이 활용이 가능하다.
    • Redis 보다는 성능이 좋지않다.
  • Redis

    • 활용중인 Redis 가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
    • Mysql 보다 성능이 좋다.

새롭게 배운 내용

자바에서 멀티스레드 환경으로 테스트 하는 방법

Executors 를 활용하여 newFixedThreadPool(n) -> 강의에선 n = 32 로 사용함 이 반환 값은 ExecutorService 가 됨

ExecutorService executorService = Executors.newFixedThreadPool(32);