재고시스템으로 알아보는 동시성이슈 해결방법

이재혁·2024년 5월 21일
0

재고 시스템

한번에 한개의 요청만 들어오는 테스트 케이스

@Test
    public void 재고감소() {
        stockService.decrease(1L, 1L);

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

        assertEquals(99, stock.getQuantity());
    }

동시에 여러 요청이 들어오는 테스트 케이스

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

        assertEquals(0, stock.getQuantity());
    }

위의 경우 race condition이 발생한다.

(race condition: 두개 이상의 스레드가 공유 데이터에 access 할 수 있고 동시에 변경하려고 할 때 발생하는 문제)

Untitled

💡 mutex lock등으로 race condition 문제를 해결해야한다!

Synchronized 이용해보기

Synchronized란?
메소드나 블록에 동기화를 적용하여, 동시에 하나의 스레드만 해당 메소드나 블록에 접근할 수 있게 한다. 이를통해 동시성 문제를 방지할 수 있다.

자바에서는 Synchronized를 사용하면 하나의 스레드만 접근 가능하게 할 수 있다.(메소드에 붙인다)

@Transactional
    public synchronized void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
    }

그러나 정상적으로 실행되지 않는다.

Untitled

왜?

스프링의 Transactional 애노테이션의 동작방식 때문이다.

@Transactional애노테이션의 동작 방식

  • @Transactional 애노테이션은 스프링의 AOP(Aspect-Oriented Programming) 기능을 사용합니다. 이 애노테이션을 사용하면 스프링은 해당 메소드를 호출할 때 프록시 객체를 생성하여, 이 프록시를 통해 트랜잭션을 관리합니다. 중요한 점은 이 프록시 객체가 원래 객체와는 다른 객체라는 것입니다.

Synchronized와 @Transactional의 충돌

Synchronized를 통해 동기화를 적용하지만, 적용되는 것은 프록시 객체이기 때문에 내가 예상한 값과 다른 결과 값이 나온다.

→ @Transactional 을 주석처리하고 실행하면 테스트 케이스가 통과한다.

Synchronized를 사용할 때의 문제점

Untitled

서버를 여러개 돌리는 운영 상황에서는 synchronized의 경우 로직 종료 시간에 따라 갱신되지 않은 값을 가져가서 race condition 문제가 발생한다.

mysql이 지원해주는 방법으로 race condition을 해결!

Database 이용해보기

Pessimistic Lock 활용해보기

실제로 데이터에 lock을 걸어서 정합성을 맞추는 방법.

@Lock() 애노테이션으로 쉽게 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);
}

장점: 충돌이 빈번한 경우 Optimistic Lock보다 성능이 좋을 수 있다.

Lock을 통해 업데이트를 제어하므로 데이터 정합성이 보장된다.

단점: Lock을 활용하므로 성능 감소가 있을 수 있다.

Optimistic Lock 활용해보기

실제로 락을 걸지 않고, 버전으로 관리하는 방식

@Entity
@Getter
@RequiredArgsConstructor
public class Stock {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long productId;

    private Long quantity;

    @Version
    private Long version;
}

OptimisticLock은 실패 시 재시도를 해야한다.

장점: 별도의 락을 잡지 않으므로 성능 상 이점이 있다.

단점: 업데이트 실패 시 재시도 로직을 개발자가 직접 작성해줘야 한다.

충돌이 빈번히 일어날 것이라 예상 → Pessimistic Lock

충돌이 빈번하지 않다면 → Optimistic Lock 추천

Named Lock 활용해보기

트랜잭션 종료 시 락이 자동으로 해제되지 않음

Stock에 직접 락을 걸지 않고 별도의 공간에 걸게된다

장점: Timeout 구현이 쉬움

단점: 트랜젝션 종료 시 락 해제, 세션관리를 잘 해야하기 때문에 주의해서 써야하고

구현방법이 복잡할 수 있다.

Redis 이용해보기

분산락을 구현할 때 사용하는 라이브러리

  • Lettuce
    • setnx 명령어로 분산락 구현
    • spin lock 방식
    • Thread1이 락을 획득해서 쓰고 있으면 Thread2가 획득할때 까지 재시도를 하는 로직을 구현해야 한다.
  • Redisson
    • pub-sub 기반으로 Lock 구현 제공(쓰레드 간 소통 채널을 하나를 만들어야한다)
    • lettuce랑 다르게 별도의 retry방식을 구현하지 않아도 된다.
    • 쓰레드가 채널을 통해 락 사용이 끝났다는 신호를 주면 다음 쓰레드가 락 획득을 시도한다.

Lettuce

@RequiredArgsConstructor
@Repository
public class RedisLockRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Object key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(key.toString(), "lock", Duration.ofMillis(3000));
    }

    public Boolean unlock(Object key) {
        return redisTemplate.delete(key.toString());
    }
}

Lettuce는 Netty기반의 Redis Client이며, 요청을 논블로킹으로 처리하여 높은 성능을 가집니다.spring-data-redis 의존성을 추가했다면, 기본적으로Lettuce 기반의 Redis Client가 제공됩니다.

스핀락 방식으로 facade 구현

@RequiredArgsConstructor
@Service
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public void decrease(Long id) {
        while (!redisLockRepository.lock(id)) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        try {
            stockService.decrease(id);
        } finally {
            redisLockRepository.unlock(id);
        }
    }
}

🐳 Lettuce 사용시 장단점

Lettuce는 redis 의존성을 추가하는 경우 기본 Redis Client로 제공되므로, 별도의 설정 없이 간단히 구현할 수 있다는 장점이 있습니다. 그러나 구현 방식에서 스핀락을 사용하기 때문에 레디스에 부하를 줄 수 있다는 것이 단점입니다.

Redisson

직접 구현할 수도 있지만 Redisson에서는 Pub/Sub 기반의 분산락을 이미 구현하여 제공해주고 있으므로, 이를 사용하도록한다

implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'
@RequiredArgsConstructor
@Component
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public void decrease(Long id) {
        RLock lock = redissonClient.getLock(id.toString());

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

🐳 Redisson 사용시 장단점Redisson에서는 분산락을 이미 구현하여 제공하고 있으므로, 별도의 구현 로직이 필요하지 않다는 장점이 있습니다.또한 Pub/Sub 기반으로 동작하여 기존 Lettuce를 통한 스핀락 기반의 분산락에 비해 Redis에 부하를 덜 준다는 장점이 있습니다.그러나 별도의 의존성을 추가해야 한다는 것이 단점이라고 볼 수 있을 것 같다.

🤔 참고 - Redis Vs MySQLRedis는 기본적으로 In-Memory DB이므로 Disk 기반으로 동작하는 MySQL에 비해 성능이 뛰어납니다.그러나 사용자들은 이를 크게 체감하지 못할 가능성이 크므로, 성능보다는 현재 인프라 상황을 고려하여 선택하는 것이 좋아보입니다.Redis를 사용하지 않는 프로젝트에서 분산락 구현을 위해 Redis를 사용하려 한다면, 별도의 Redis 구축에 대한 비용과, 이를 학습하는 비용이 발생합니다.그에 비해 이미 프로젝트에서 사용하고 있는 MySQL 등의 DB를 사용한다면 별도의 인프라 구축 비용과 학습 비용 없이 적용이 가능합니다.Redis와 MySQL 사이의 성능 차이가 조금 있다고는 하지만, 일반적으로는 크게 체감되지 않을 가능성이 크므로, 여러 조건과 상황을 고려하여 적절한 기술을 도입하는 것이 좋아보입니다.
profile
서비스기업 가고 싶은 대학생

0개의 댓글

관련 채용 정보