백엔드/Spring

상품 재고 변경의 동시성 문제를 해결하는 여러가지 방법

n4oah 2024. 1. 21. 18:21

안정적인 서비스를 위해 백엔드 개발자 입장에서 동시성을 고려한 개발을 해야한다.

동시성 이슈를 해결하기 위한 방법은 여러가지가 존재한다.

사용중인 DB에 해당 상품에 대한 record lock을 거는 방법이 있을 것이고, 특정 구간만 lock을 거는 namedLock 혹은 Redis 분산락 등

여러 가지 방법을 사용해보고 또, 동시성 문제를 해결해보며 각각의 장단점을 알아보자.

이 글에서 사용한 RDBMS는 MySQL이다.

동시성 문제 재연해보기

먼저 동시성 문제를 해결하기 위해 어떤 상황에서 동시성 문제가 발생하는지 살펴보자.

우리가 테스트 해볼 경우의 수는 2가지이다.

1. 순차적 재고 차감

2. 동시에 재고 차감

1. 순차적 재고 차감

@Transactional
public void productStockDecrement(long productId, int amount) {
    Product product = this.productRepository.findById(productId)
            .orElseThrow();

    if (product.getStock() - amount < 0) {
        throw new RuntimeException("상품 재고 부족");
    }

    product.changeStock(product.getStock() - amount);

    this.productRepository.save(product);
}

위 코드는 재고 차감에 대한 비즈니스 로직이다.

이 코드로 순차적 재고 차감을 해보자.

@Test
public void 한번에_구매_수량_1개_차감() {
    // given
    final var product =
            productRepository.saveAndFlush(new Product("사과", 5));
    final var productId = product.getId();

    // when
    productService.productStockDecrement(productId, 1);
    productService.productStockDecrement(productId, 1);

    // then
    assertEquals(
            productRepository.findById(productId).orElseThrow().getStock(),
            3
    );
}

테스트 결과

당연히 우린 예상 했듯이, 순차적 재고 차감에는 아무런 문제가 없다.

바로, 동시에 재고 차감 로직이 실행됐을 때의 경우를 살펴보자.

2. 동시에 재고 차감

@Test
@DisplayName("동시에 여러개 차감")
public void 동시에_여러개_차감() throws InterruptedException {
    // given
    final var product =
            productRepository.saveAndFlush(new Product("사과", 100));
    final var productId = product.getId();
    final var threadCount = 50;

    ExecutorService executorService =
            Executors.newFixedThreadPool(20);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    // when
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                productService.productStockDecrement(productId, 1);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    // then
    assertEquals(
            productRepository.findById(productId).orElseThrow().getStock(),
            50
    );
}

 

상품의 재고는 100개이고, 50개의 재고 차감 로직이 동시다발적으로 실행됐다.

테스트 실행 결과

그러나, 테스트 실행 결과에서 알 수 있듯이 실제 차감된 재고는 6개, 즉 동시성 문제가 발생한 것이다.

1. 왜 이런 일이 발생한 것인가?

2. 또, 동시 접근 문제를 어떻게 우리는 해결 할 수 있을까?

이 글에서 설명하는 요점은 이 두 가지이다.

동시접근 문제가 발생한 이유 (write-skew)

위, 테스트 코드에서 50의 재고를 차감 하기위해 50개의 메소드 호출이 발생했고, 총 50개의 트랜잭션이 발생했을 것이다.

트랜잭션이 시작되고 난 후, 조회한 product의 stock과 재고를 차감한 뒤(stock - amount)의 stock의 개수가 차이가 난 것을 우리는 유추할 수 있다.

즉, 50개의 트랜잭션의 서로의 간섭없이, 현재 트랜잭션이 시작된 후 조회한 product의 stock(공유자원)을 어떠한 제한 없이 수정하여 발생한 문제라고 할 수 있다. (race condtion이 발생한 것이다)

 

Thread A(or Transaction A)와 Thread B(or Transaction B)가 동시에 재고 차감 메서드를 호출하면 아래와 같을 것이다.

  Thread A stock A Thread B stock B
1 BEGINE; 100   100
2 SELECT * FROM products WHERE id=1; 99 BEGINE; 100
3 UPDATE products SET stock=100-1 WHERE id=1; 99 SELECT * FROM products WHERE id=1; 100
4 COMMIT; 99 UPDATE products SET stock=100-1 WHERE id=1; 99
      COMMIT; 99

 

기본적으로 MySQL은 read committed이상 수준이 되면 커밋된 데이터만 읽을 수 있게된다. 그러므로 Thread가 stcok을 차감한 update 쿼리가 flush되어도 트랜잭션이 종료되지 않았기 때문에 Thread B는 업데이트 되기 전 stock인 100개의 stock이 조회된다.

 

그럼, 트랜잭션 격리 수준을 read-uncommited로 낮추면 해결될까? 그건 더 큰 문제를 초래할 수 있다.

트랜잭션A가 작업 도중 에러가 발생한 경우 롤백된 데이터도 트랜잭션 B에서 읽게 되므로 데이터베이스 결합성에 어긋날 수 있다. 즉 Dirty read가 발생한다.

또한, read-uncommited수준으로 격리 수준을 낮추고 롤백이 안 생긴다고 가정 하여도 위와 같은 상황은 발생하지 않으리라고는 보장할 수 없다. (동시에 SELECT 하여 stock을 가져온 경우)

 

이 처럼, 공유 자원에 대해 여러 스레드 혹은 프로세스가 접근하게 되면 공유자원의 일관성을 보장받지 못 하게 되는데 이것을 우리는 레이스 컨디션이라고 부른다.

Java synchronized를 활용한 동시성 문제 해결

기본적으로 우리는 동시성 문제 해결을 위해 Java를 쓰고 있다면 syncronized를 생각해 볼 수 있다.

그러나, Java의 syncronized를 사용하게 되면 서버의 인스턴스가 2개 이상이 되었을 때, 동시성 문제 해결을 할 수 없어지므로 syncronized는 사용할 수 없다.

단, 서버의 인스턴스가 하나인 경우 큰 비용 없이 적용해 볼 수 있다.

RDBMS를 활용한 동시성 문제 해결

해당 상품의 레코드에 lock을 거는 방법 (비관적 락)

RDBMS를 사용하면서 적용해 볼 수 있는 가장 간단한 방법이다.

단순하게 변경하려는 상품의 해당 레코드에 대해 X-lock을 걸면된다. (물론 비관적 락 종류에 따라 S-Lock이 걸리는 락 타입도 있다.)

이름에서 알 수 있듯이, 트랜잭션의 충돌이 발생할 경우가 많다고 가정하고, 우선 락 부터 거는 방법이다.

JPA는 비관적 락을 편리하게 제공해주고 있다.

 

@Transactional
public void productStockDecrement(long productId, int amount) {
    Product product = this.productRepository.findByIdWithPessimisticLock(productId)
            .orElseThrow();

    if (product.getStock() - amount < 0) {
        throw new RuntimeException("상품 재고 부족");
    }

    product.changeStock(product.getStock() - amount);

    this.productRepository.save(product);
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(Long id);
}

테스트 코드 실행 결과

실제 product를 조회할 때 실행되는 query는 아래와 같다.

SELECT * FROM products FROM id=:id FOR UPDATE;

 

해당 products의 id에 배타락(x-lock)을 걸게 되면서 다른 트랜잭션에서 조회, 수정 할 수 없게 lock을 걸게되면서 동시성 처리에 대한 동기화 보장을 받을 수 있다.

 

장점

  • 별도의 락을 구현하기 위한 서비스(Redis 등)를 사용하지 않아도 되므로 비용적으로 유리할 수 있다.
  • 재고 감소 로직이 빈번하게 발생하는 경우 성능상 이점이 있다.
  • 구현이 간단하다.

단점

  • 해당 레코드에 별도의 lock을 거는 것이기 때문에, 데드락이 발생할 수 있다.
  • 빈번하게 일어나지 않는 로직일 경우 성능상 좋지 못 하다.
    • 빈번하지 않게 일어나는 경우엔 비관적 락 보다는 낙관적 락을 사용하여 해결하는 것이 더 합리적일 수 있다.

해당 상품의 레코드에 버전을 관리하는 방법 (낙관적 락)

낙관적 락이란?

낙관적 락은 DB에 락을 거는 형태가 아닌, 실제 데이터가 업데이트 될 때, 트랜잭션이 시작하고 난 후 product의 데이터와 업데이트가 실행됐을 때 시점의 데이터를 비교해서 데이터가 변경되지 않았을 경우에만 업데이트를 해주는 방법이다.

스프링 JPA에서는 version이라는 기능을 사용하여 버전이 다른 경우 다른 트랜잭션에서 데이터가 수정됐다고 판단해 DB가 업데이트하는 것을 막는다.

 

데이터를 업데이트하기 전 별도의 lock을 사용하지 않기 때문에 업데이트가 빈번한 경우 사용하지 않는것을 추천한다.

데이터의 정합성을 보장 받아야 하지만, 업데이트가 빈번하지 않는 경우에 사용할 수 있는 lock이다.

낙관적 락 사용 해보기

먼저 엔티티 클래스에 version 정보를 저장할 컬럼을 추가하고, JPA에서 version컬럼을 식별할 수 있도록 @Version 어노테이션을 붙여준다.

@Getter
@Table(name = "products")
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
	// ...
    @Version
    private Integer version;
    // ...
}

 

그리고, 실제 상품 재고차감 로직에 LockMode를 사용하도록 설정하면 되는데, @Version만 써도 기본 옵션으로 LockMode가 NONE으로 활성화 되기 때문에 상황에 따라 LockMode를 적용해주면 된다.

우린 기본 옵션인 NONE일 때의 상황으로 테스트 해볼 예정이다.

 

LockMode가 NONE이고 낙관적 락이 설정되어 있으면 해당 엔티티가 수정됐을 때 (update 쿼리시) 현재 버전 +1로 업데이트를 하고, 현재 1차 캐시에 있는 version과 비교하여 해당 레코드가 존재하는지 확인한다.

UPDATE ..., version={version + 1} WHERE id={id} AND version={version};

 

update시 레코드가 존재 하지않으면 다른 트랜잭션에서 해당 레코드를 수정했다는 것으로 판단하여 Spring Data JPA에서는 OptimisticLockingFailureException 을 발생시킨다.

그래서 우리는 OptimisticLockingFailureException가 발생되면 재시도 하는 로직까지 구현할 것이고, 해당 로직을 별도의 서비스 클래스에서 구현하기 위해 Facade패턴을 사용할 것이다.

 

Spring AOP의 Dynamic Proxy의 한계로 별도의 @Transactional 주기를 짧게 가져갈 수 있다는 장점도 있다.

@Service
@RequiredArgsConstructor
public class OptimisticLockProductStockFacade {
    private final ProductService productService;

    public void productStockDecrementForLock(long productId, int amount) throws InterruptedException {
        while (true) {
            try {
                this.productService.productStockDecrement(productId, amount);
                break;
            } catch (OptimisticLockingFailureException exception) {
                Thread.sleep(50L);
            }
        }
    }
}

동시에 재고 차감 낙관적 락 실행결과

Redis를 활용하여 동시성 문제 해결하기

Redis Lock 활용하기

Redis를 사용하여 MySQL의 NamedLock과 비슷한 기능을 구현할 수 있다.

(물론 MySQL namedlock은 합의알고리즘 기반의 분산락이 아닌, 단일 물리적 mysql 서버에만 관리되는 락이기 때문에, DB서버가 분산되어 있을 때에는 사용할 수 없는 lockd이긴 하다.)

Redis에 특정 key를 만들고, 해당 키가 존재하면 lock대기상태이고, key가 존재하지않으면 해당 key로 잠금을 획득하는 방식이다.

 

Redis의 SET 옵션 중 NX 옵션을 사용하면 특정 키가 존재 하지 않을 경우에만 값을 쓰게하는 옵션이 있는데, NX옵션을 사용하여 값을 쓴다면 'OK' 가 response되고, 값을 쓰지 못 한다면(키가 이미 존재해서) nil을 response해주어서 락을 획득했는지 구분할 수 있다.

('OK'인 경우 획득, nil인 경우 락 키 획득 못 함.)

 

Redis의 모든 명령어는 atomic하게 동작하기 때문에 동시성 문제에 대해 조금 더 자유롭다고 할 수 있다.

 

Redis는 싱글 인스턴스 mutex락과, 분산락을 지원한다.(클러스터 환경인 경우)

https://redis.io/docs/manual/patterns/distributed-locks/

 

Distributed Locks with Redis

A distributed lock pattern with Redis

redis.io

 

 

JAVA Spring 환경에서는 Redis client 구현체인 Redisson과 Lettuce를 사용하여 분산락을 사용할 수 있다.

 

Redisson는 락을 획득하기 위해 pub/sub 방식으로 구현되며, Lettuce는 spin lock방식을 사용하여 락을 획득한다.

그러므로, Redisson가 더 효율적인 로직으로 구현되어 있다고 할 수 있다.

그래서 우리는 Redisson를 사용하여 Redis lock을 사용해볼 예정이다.

여기서 spin lock이란 lock을 획득하기 위해 무한 루프를 돌며 lock을 획득 하는 방식이다.

직접 사용해보기

@Service
@RequiredArgsConstructor
public class RedisLockProductStockFacade {
    private final ProductService productService;
    private final RedissonClient redissonClient;

    private final String STOCK_LOCK_KEY = "STOCK_LOCK_KEY";

    public void productStockDecrement(long productId, int amount) {
        final var rLock = this.redissonClient.getLock(STOCK_LOCK_KEY);

        try {
            // waitTime: 5초간 lock획득 시도
            // leaseTime: lock을 획득 한 후 3초간 반납하지 않으면 자동 반납
            final var available =
                    rLock.tryLock(5, 3, TimeUnit.SECONDS);

            if (!available) {
                throw new RuntimeException("Lock acquisition failed");
            }

            this.productService.productStockDecrement(productId, amount);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            rLock.unlock();
        }
    }
}

동시에 재고 차감 Redis 락 실행결과

결론

  • RDBMS의 낙관적락을 사용하는 경우엔 별도의 lock을 사용하는 것이 아니라 동시 요청 빈도가 낮은 경우 성능상 더 유리함.
  • 비관적락을 사용하는 경우엔 간단하게 lock을 구현할 수 있는 장점이 있지만, 별도의 락을 사용하고 해당 레코드에 X-lock을 거는 형태이므로 재고가 수정되는 로직이 실행될 때, 해당 레코드에 대해 다른 로직(업데이트/삭제 관련)도 수행 불가능하며, 데드락이 발생할 가능성이 있음.
  • Redis lock을 사용하면, 분산 환경에서도 큰 어려움 없이 구현가능 단, Redis라는 서비스를 별도로 운영해야 하므로, 분산락을 사용하기 위해서 Redis를 사용한다는 것 자체도 비용이고, Trade-off 해볼만한 지점
  • Reids lock을 사용하면, 데드락이 발생하여도 일정 기간이후 알아서 lock이 풀리기 때문에 db 데드락 보단 비용이 낮음.
    • 물론 데드락이 안 생기게 개발을 하는게 더 중요.