스프링에서 동시성 문제를 해결하는 5가지 방법

·2023년 11월 9일
4

문제 상황

매우 간단한 예제가 있습니다.

@Transactional
public void decrease(final Long stockId) {
    final Stock stock = stockRepository.findById(stockId)
            .orElseThrow(IllegalArgumentException::new);

    stock.decreaseOne();
}

위의 메서드는 stockId를 받아서 영속 레이어에서 엔티티를 가져온 뒤, decreaseOne() 메서드를 통해서 재고를 1 감소시키는 상황입니다.

테스트 코드를 살펴보겠습니다.

@Test
@DisplayName("동시에 100개의 요청")
void decrease_concurrency() throws InterruptedException {
    //given
    final int threadSize = 100;
    final var executorService = Executors.newFixedThreadPool(threadSize);
    final var countDownLatch = new CountDownLatch(threadSize);

    //when
    for (int i = 0; i < threadSize; i++) {
        executorService.submit(() -> {
            stockService.decrease(1L);
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();

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

기존에 재고 100개짜리의 Stock 엔티티를 저장해둔 상태입니다.
그리고 동시에 100개의 decreaseOne() 요청이 들어왔을 때 최종적으로 남은 재고가 0이 되는 것을 검증합니다.

결과는 3개 밖에 감소하지 않았습니다.
어떤 방법들로 해결할 수 있을까요??

해결 방안

지금부터 여러 가지 해결 방안을 하나씩 살펴보겠습니다.

  1. Synchronized
  2. Pessimistic Locking
  3. Optimistic Locking
  4. Named Locking
  5. Redis (Lettuce, Redisson)

1. Synchronized

synchronized 키워드를 사용하면 해당 영역을 임계영역으로 만들어서 하나의 스레드만 접근할 수 있게 됩니다.

@Transactional
public synchronized void decrease(final Long stockId) {
    final Stock stock = stockRepository.findById(stockId)
            .orElseThrow(IllegalArgumentException::new);

    stock.decreaseOne();
}

호출하던 서비스의 메서드 decreasesynchronized 키워드를 사용한 모습입니다.
오직 한 스레드씩 이 메서드의 로직을 수행할 수 있게 됩니다.

그럼 테스트를 돌려보면 어떻게 될까요??

결과는 64개가 감소됐습니다.

JPA를 사용하면 실제 DB로 flush 되는 시점은 정확히 알 수 없습니다.
1. decrease 메서드가 종료되고 다음 스레드가 decrease 메서드를 수행하기 시작하는 시점
2. decrease 메서드가 종료되고 변경 감지된 사항을 DB로 flush하는 시점

위의 두 시점이 불일치하기 때문에 아직 DB에 반영되지 않은 값을 읽어오는 상황이 발생합니다.

이 방법의 단점은,
1. 트랜잭션 어노테이션과 JPA Repository를 사용할 때 위의 문제가 여전히 발생할 수 있습니다.
2. 서버가 scale out 됐을 때는 동시에 decrease 메서드 호출이 가능해집니다.

2. Pessimistic Locking

비관적 락을 사용하면 DB의 테이블이나 레코드 레벨(InnoDB)에 배타락을 걸고 사용하게 됩니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("FROM Stock WHERE id=:id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);

JPA를 사용하면 @Lock 어노테이션으로 간편하게 비관적 락을 사용할 수 있습니다.

실제 DB에서 데이터를 가져올 때 for update 구문을 통해서 SELECT 해오게 됩니다.
따라서 트랜잭션이 종료되어 커밋될 때까지 다른 공유락이나 배타락이 접근할 수 없는 상태가 됩니다.

@Transactional
public void decrease(final Long stockId) {
    final Stock stock = stockRepository.findByIdWithPessimisticLock(stockId);
    stock.decreaseOne();
}

위와 같이 배타락을 통해서 가져오도록 합니다.

결과는 100개가 감소됐지만, 수행 시간이 늘어났습니다.
데이터를 저장하는 DB 자체에 락을 걸었기 때문에 scale out된 여러 서버에서 동시에 요청해도 정합성이 유지됩니다.

이 방법의 단점은,
1. 항상 배타락을 걸고 사용하기 때문에 기존보다 수행 시간이 늘어납니다.
2. MVCC를 통해서 데이터를 읽을 때에 비해 데드락 발생 확률이 늘어납니다.
3. 다른 요청으로 들어온 DB 세션들이 기다리는 확률이 늘어났기 때문에 스레드 고갈이 일어날 수도 있습니다.

디비 커넥션 풀의 개수를 늘려두고 사용한다면 3번은 피할 수 있을 것 같네요.

3. Optimistic Locking

낙관적 락을 사용하면 MVCC와 비슷한 원리로 동작합니다.
버전 컬럼을 사용해서 읽어온 시점과 트랜잭션이 커밋되는 시점의 데이터가 같은지 정합성을 비교합니다.

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

    @Version
    private Long version;

사용하고자 하는 엔티티에 version 컬럼을 추가합니다.
버전을 업데이트하고 비교하는 것은 @Version 어노테이션을 통해 편하게 관리할 수 있습니다.

@Lock(LockModeType.OPTIMISTIC)
@Query("FROM Stock WHERE id=:id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);

비관적 락을 걸 때와 마찬가지로 쉽게 JPA에서 사용할 수 있습니다.
OPTIMISTIC 방식의 락을 사용하면 위의 @Version 어노테이션이 달린 컬럼을 관리해야할 버전으로 인식하고 자동으로 비교 및 증가시켜줍니다.

@Component
public class OptimisticLockStockFacade {

    private final StockService stockService;

    public OptimisticLockStockFacade(final StockService stockService) {
        this.stockService = stockService;
    }

    public void decrease(final Long id) {
        try {
            stockService.decrease(id);
        } catch (final OptimisticLockingFailureException e) {
            decrease(id);
        }
    }
}

낙관적 락은 동시성 이슈 감지만 할 수 있으므로, 꼭 성공해야 하는 로직이라면 예외를 감지했을 때 재시도를 해야 합니다.
따라서 위와 같이 StockService를 컴포지션으로 쓰는 퍼사드를 만들어서 재시도 처리를 해줍니다.

@Test
@DisplayName("동시에 100개의 요청")
void decrease_concurrency() throws InterruptedException {
    //given
    final int threadSize = 100;
    final var executorService = Executors.newFixedThreadPool(threadSize);
    final var countDownLatch = new CountDownLatch(threadSize);

    //when
    for (int i = 0; i < threadSize; i++) {
        executorService.submit(() -> {
            optimisticLockStockService.decrease(1L);
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();

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

테스트에서도 퍼사드를 쓰도록 변경합니다.
이제 동시성 이슈가 발생하면 성공할 때까지 재귀적으로 재시도하게 됩니다.

결과는 100개가 감소됐지만 비관적 락보다 수행 시간이 늘어났습니다.
비관적 락은 배타락을 얻을 수 있을 때까지 기다렸다가 수행하지만,
낙관적 락은 커밋 시점까지 항상 진행했다가 실패하면 처음부터 재시도하기 때문입니다.

이 방법의 단점은,
1. 충돌 상황이 자주 발생할 때는 로직을 처음부터 재시도하기 때문에 매우 느립니다.
2. 재귀 방법으로 재시도할 경우 Stack Over Flow가 발생할 수도 있습니다. (이건 낙관적 락의 단점은 아닙니다)

4. Named Locking

MySQL에서 제공하는 기능 중 Named Lock은 특정 문자열에 대해서 락을 획득하는 방법입니다.

여러 분산된 공유 자원들을 하나의 락으로 관리하기 위해서 분산락을 사용합니다.
이 분산락을 구현하는 방법 중 하나가 Named Lock 입니다.

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

락을 얻기 위한 메서드를 만들었습니다.

@Component
public class NamedStockFacade {

    private final LockRepository lockRepository;
    private final StockService stockService;

    public NamedStockFacade(final LockRepository lockRepository, final StockService stockRepository) {
        this.lockRepository = lockRepository;
        this.stockService = stockRepository;
    }

    @Transactional
    public void decrease(final Long id) {
        try {
            lockRepository.getLock(id.toString());
            stockService.decrease(id);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}

락을 얻고 풀어줘야하기 때문에 퍼사드를 만들었습니다.

결과는 100개가 줄었습니다.

이 방법의 단점은,
1. 락을 얻기 위해서 DB 커넥션 스레드가 대기하게 됩니다. 따라서 스레드 고갈이 일어날 수 있습니다.
2. 락을 풀어주지 않으면 더이상 사용할 수 없습니다. 따라서 락을 그 어떤 경우에도 꼭 release 해줘야 합니다.

커넥션 풀을 늘리거나, 다른 DataSource를 사용한다면 1번은 해결할 수 있을 것 같습니다.

5. Redis - Lettuce

Lettuce, Redisson은 대표적인 레디스 클라이언트 라이브러리입니다.

그 중 스핀락 방식으로 락을 획득하는 Lettuce를 통해 분산락을 구현해보겠습니다.
스핀락은 락을 획득할 수 있을 때까지 반복해서 락 획득 요청을 하는 방식입니다.

Lettuce에 있는 setnx 명령어(set value if not exists)를 사용하면 됩니다.

@Component
public class RedisLockRepository {

    private static final String LOCK = "lock";

    private final RedisTemplate<String, String> redisTemplate;

    public RedisLockRepository(final RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Boolean lock(final String key) {
        return redisTemplate.opsForValue()
                .setIfAbsent(key, LOCK, Duration.ofSeconds(3));
    }

    public void release(final String key) {
        redisTemplate.delete(key);
    }
}

RedisTemplate을 주입 받아서 lock, release 메서드를 구현합니다.
키 값에 대한 락을 얻어서 최대 3초간 유지합니다.

@Service
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

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

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

        try {
            stockService.decrease(id);
        } finally {
            redisLockRepository.release(id.toString());
        }
    }
}

락을 사용하는 퍼사드를 만듭니다.
해당 키 값의 락을 얻을 때까지 0.1초마다 재시도합니다.
그리고 락을 얻으면 비즈니스 로직을 수행한 뒤 락을 해제합니다.

결과는 100개가 줄었습니다.
하지만 외부 시스템(6379 포트의 레디스)과 계속 트랜잭션이 일어나기 때문에 훨씬 느려졌습니다.

이 방법의 단점은,
1. 스핀락 기반으로 락을 획득하기 때문에 레디스에 부하가 올 수 있습니다.

Redis - Redisson

Redis를 사용하는 다른 클라이언트 라이브러리로 Redisson이 있습니다.
네임드락을 얻고 비즈니스 로직을 수행하고 락을 해제하는 것은 Lettuce와 동일합니다.

하지만 스핀락 방식이 아닌 Publisher - Subscriber 방식으로 동작하기 때문에 레디스의 부하가 적습니다.
락을 얻고 있던 세션이 락을 해제할 때 해당 Sub 채널에게 알려주면서 다음 세션이 락을 얻게됩니다.

@Service
public class RedissonLockStockFacade {

    private final RedissonClient redissonClient;
    private final StockService stockService;

    public RedissonLockStockFacade(final RedissonClient redissonClient, final StockService stockService) {
        this.redissonClient = redissonClient;
        this.stockService = stockService;
    }

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

        try {
            lock.tryLock(10, 1, TimeUnit.SECONDS);
            stockService.decrease(id);
        } catch (final InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

Redisson은 락을 얻고 해제하는 메서드를 제공해줍니다.
따라서 위와 같이 퍼사드만 생성하면 됩니다.

결과는 100개가 줄었습니다.
Lettuce는 매번 모든 setnx 요청에 대해 응답해야 하기 때문에 3초가 넘게 걸린 반면, Redisson은 큐에서 다음 세션들이 대기하고 있기 때문에 빠르게 처리가 가능합니다.

이 방법의 단점은,
1. 별도의 gradle 의존성이 필요합니다.

결론

지금까지 synchronized, Pessimistic Lock, Optimistic Lock, Named Lock, Redis(Lettuce, Redisson) 방식들을 하나씩 살펴봤습니다.

제 생각으로는,
1. 동시성 이슈가 절대로 발생하면 안되는가?

  • 발생해도 큰 문제가 없다면(좋아요 수 등) 락을 걸지 않는 것이 좋을 수도 있다.
  • 발생한다면 지금까지 살펴봤던 방식들을 고려해본다.
  1. 빈번하게 동시성 이슈가 발생하는가?
    • 빈번하다면 배제락, 분산락을 고려해본다.
    • 빈번하지 않다면 낙관적 락을 고려해본다.
  2. 이슈 발생 시 재시도를 해야 하는가?
    • 성공할 때까지 해야 한다면 Redisson을 고려해본다.
    • 그렇지 않다면 Lettuce를 고려해본다.

필요한 부분에 대해서 고민해보고 적용하면 좋을 것 같습니다!
감사합니다 ☺️

profile
渽晛

0개의 댓글