Post

재고 시스템으로 배우는 동시성 이슈 해결 전략: Synchronized, DB Lock, Redis 비교 분석

최상용님의 재고시스템으로 알아보는 동시성이슈 해결 방법에서 학습한 내용을 정리한 글입니다.

1. 쓰레드를 사용하여 재고 감소 테스트 하기

동시성을 고려하지 않고 재고 감소 기능을 구현할 할 경우, 경쟁상태 문제가 발생한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    @DisplayName("동시에 재고 감소를 요청한다.")
    @Test
    void decreaseWithThread() throws InterruptedException {
    	// given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for (int i = 0 ; i < threadCount ; i++) {
            executorService.submit(() -> {
                try {
                    stockService.decrease(1L, 1L);
                } finally {
                    latch.countDown();

                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isZero();
  }

CountDownLatch latch = new CountDownLatch(threadCount);

  • CountDownLatch는 스레드들이 일정한 시점에 도달할 때까지 대기하도록 하기 위해 사용한다. 여기서는 100개의 작업이 모두 완료될 때까지 대기하는 데 사용된다.

executorService.submit(() ->

  • 각 반복에서 executorService에 작업을 제출한다. 작업은 람다식으로 정의된 코드 블록으로, 스레드 풀에서 실행된다.

latch.await();

  • latch의 카운트가 0이 될 때까지 현재 스레드를 대기시킨다.

결과

1
2
3
4
   //org.opentest4j.AssertionFailedError: 
	 //expected: 0L
	 //but was: 94L

2. Synchronized로 동시성 문제 해결하기

자바에서 지원하는 Synchronized를 사용하면 1개의 쓰레드만 공유 자원에 접근이 가능 제한할 수 있다.

1
2
3
4
5
6
7
8
9
    //@Transactional
    public synchronized void decrease(Long id, Long quantity){
        // stock 조회
        Stock stock = stockRepository.findById(id).orElseThrow();
        //재고 감소
        stock.decrease(quantity);
        //갱신 값 저장
        stockRepository.saveAndFlush(stock);
    }

주의해야 할 점은 synchronizedTransactional을 같이 사용 하면 안된다.

Transactional 어노테이션을 사용하면 트랜잭션 안에서 재고를 감소한 뒤 데이터 베이스에 커밋하기 전에 다른 쓰레드에서 재고 감소를 요청할 수 있게 된다.

추가로, 자바의 synchronized는 각 프로세스 안에서만 보장이 되기 때문에 만약 서버가 2개 이상이라면 경쟁 상태 문제가 다시 발생한다.

3. Database를 이용해 동시성 문제 해결하기

Pessimistic Lock(비관적 잠금)

  • 실제 데이터에 Lock을 걸어 데이터 정합성을 맞춘다.
  • exclusive lock을 걸면 다른 트랜잭션은 lock이 해제 될 때까지 기다리게 된다.
  • 데드락이 걸릴 수 있다.

Optimistic Lock(낙관적 잠금)

  • 실제 데이터에 Lock을 걸지 않고, 버전 관리를 하여 데이터 정합성을 맞춘다.
  • 데이터를 조회 후 업데이트를 하는 시점에 읽은 버전이 맞는지 확인한 뒤 업데이트를 한다.
  • 만약 읽고 난 후 데이터에 수정사항이 있어 버전이 높아졌다면 데이터를 다시 읽고 작업을 수행하는 로직을 넣어줘야한다.

Named Lock

  • 이름을 가진 Lock을 획득한 후 해제 할 때까지 다른 세션은 해당 Lock을 사용하지 못한다.
  • 트랜잭션이 종료될 때 Lock을 자동으로 해제하지 않기 때문에 주의해야한다.
  • 해제 명령어를 실행하거나 선점 시간이 끝나야 Lock을 해제한다.
  • Pessimistic Lock과 다른점은 데이터베이스의 row나 table에 직접 Lock을 걸지 않는다는 것이다.

3.1 Pessimistic Lock

Spring date JPA를 사용하면 @Lock 어노테이션을 사용하여 Pessimistic Lock을 사용할 수 있다.

1
2
3
4
5
6
7
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);
}

Pessimistic Lock을 사용하면 아래와 같이 select for update 라는 쿼리가 나간다.

1
2
3
 select s1_0.id,s1_0.product_id,s1_0.quantity from stock s1_0 where s1_0.id=? for update
Hibernate: select s1_0.id,s1_0.product_id,s1_0.quantity from stock s1_0 where s1_0.id=? 
**for update**

Pessimistic Lock 장점은 충돌이 빈번하다면 Optimistic Lock보다 성능이 좋을 수 있다.

또한 데이터베이스의 락을 사용하기 때문에 데이터 정합성이 정확하다.

하지만 별도의 락을 사용하기 때문에 일반적인 경우 성능 저하가 있을 수 있다.

3.2 Optimistic Lock

버전관리를 위해 Stock 엔티티에 version 필드를 추가한다

1
2
    @Version
    private Long version;

Spring date JPA를 사용하면 @Lock 어노테이션을 사용하여 Optimistic Lock을 사용할 수 있다.

1
2
3
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where  s.id = :id")
    Stock findByIdWithOptimisticLock(Long id)

업데이트 시점에 조회 버전을 확인한다. 만약 버전이 달라졌다면 재 시도를 해야한다. 재시도를 위해 facade 서비스를 만들고 예외가 발생하면 다시 요청 할 수 있도록 개발자가 직접 로직을 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RequiredArgsConstructor
@Component
public class OptimisticLockStockFacade {
    private final 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);

            }
        }
    }
}

장점은 별도의 데이터베이스 락을 설정하지 않기 때문에 Pessimistic Lock보다 조금 더 빠르다.

하지만 별도의 재시도 로직을 직접 작성해야 하는 불편함이 있다.

충돌이 빈번하지 않을 때 사용하면 좋다.

3.3 Named Lock

Native 쿼리로 Lock을 획득하고 해제하는 쿼리를 작성한다.

1
2
3
4
5
6
7
8
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);
}

get_lock(:key, 3000)

  • 3000초 동안 잠금을 얻으려고 시도한다.
  • 만약 3000초 내 Lock을 얻지 못하면 0 또는 NULL 을 반환하고 Lock을 획득하면 1을 반환한다.

Lock을 획득하고 해제하는 과정이 필요하기 때문에 Facade 서비스를 활용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RequiredArgsConstructor
@Component
public class NamedLockStockFacade {
    private final LockRepository lockRepository;
    private final 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 로직은 부모 트랜잭션과 분리되어야 하기 때문에 @Transactional(propagation = Propagation.REQUIRES_NEW) 로 선언한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RequiredArgsConstructor
@Service
public class StockService {
    private final StockRepository stockRepository;

    **@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를 활용해 경쟁상태 해결하기

4.1 Lettuce

setnx 명령어는 는 값이 존재하지 않을 때 만 값을 set 하는 명령어이다.

Redis는 싱글 쓰레드로 동작하기 때문에 쓰레드들이 동시에 공유 자원에 접근하여도 이전 쓰레드 작업이 끝날 때까지 대기한다. 따라서 setnx 명령어를 활용해서도 동시성 문제를 해결 할 수 있다.

1
2
3
4
5
6
7
8
9
//docker exec -it 레디스 컨테이너 아이디 redis-cli
127.0.0.1:6379> setnx 1 lock
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 0
127.0.0.1:6379> del 1
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 1

Redis의 setnx 명령어를 lock과 unlock 메소드로 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequiredArgsConstructor
@Component
public class RedisLockRepository {
    private RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public void unlock(Long key) {
        redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }

}

opsForValue()

  • opsForValue()는 Redis에서 String 타입 값을 처리하는 작업을 제공하는 메서드이다.
  • Redis의 String 타입은 가장 기본적인 데이터 타입으로, 간단한 키-값 저장에 사용된다.

Lettuce는 스핀 락 방식이다. 아래와 같이 잠금을 획득 하지 못하면 일정 텀을 두고 재시도하는 과정이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequiredArgsConstructor
@Component
public class LettuceLockStockFacade {
    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

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

    }
}

Mysql의 Named Lock과 유사하게 동작하다. 장점은 구현이 간단하고 세션 관리에 신경을 쓰지 않아도 된다.

하지만 스핀 락 방식임으로 Redis에 부하를 줄 수 있다.

실무에서는 재시도가 필요하지 않은 lock이 필요할 때 Lettuce로 사용한다.

4.2 Redisson

별도의 라이브러리가 필요하다.

build.gradle

1
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.34.1'

Redis의 구독과 발행을 기능을 활용하여 Lock을 구현하는 방식이다.

1
2
3
4
5
//sub
127.0.0.1:6379> subscribe ch1
1) "subscribe"
2) "ch1"
3) (integer) 1
1
2
3
4
//pub
127.0.0.1:6379> publish ch1 hello
(integer) 1
127.0.0.1:6379>
1
2
3
4
5
6
7
8
//sub
127.0.0.1:6379> subscribe ch1
1) "subscribe"
2) "ch1"
3) (integer) 1
1) "message"
2) "ch1"
3) "hello"

Lock을 획득하는 대기시간과 Lock을 얻고 자동으로 해지하는 lease 시간을 설정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@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 key, Long quantity) {
        RLock lock = redissonClient.getLock(key.toString());

        try {
            **boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);**

            if (!available) {
                System.out.println("lock 획득 실패");
                return;
            }

            stockService.decrease(key, quantity);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

장점은 Lettuce와 비교하였을 때 Redis 부하를 줄일 수 있다. 하지만 구현이 복잡하고 별도의 라이브러리를 사용해야한다는 단점이있다.

실무에서는 재시도가 필요한 경우 redisson을 활용한다.

5. 동시성 해결을 위한 Redis vs Mysql

Mysql

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

Redis

  • Redis를 사용하고 있지 않다면 별도의 구축 비용, 관리비용이 필요하다.
  • 단 Mysql보다는 성능이 좋다
This post is licensed under CC BY 4.0 by the author.