Post

동시성 이슈에 대응하기

동시성 이슈에 대응하기

여러가지 동시성 문제 상황

개발을 진행하다보면 많은 동시성 문제를 마주하게 된다.
예약 기능을 구현할 때, 조회수 기능을 구현할 때 등등…
실제로 졸업 프로젝트로 진행했던 오늘만 사장에서 예약 기능을 구현하는 도중 동시성 문제를 마주하였다.
해당 기능을 구현하면서 들었던 강의와 찾아본 내용을 간단하게 정리해본다.

  • 여러 실무 환경에서 동시성 문제 발생
    • 아래와 같은 재고 감소 코드에서
    1
    2
    3
    4
    5
    6
    7
    
      @Transactional
      public void decrease(Long id, Long quantity) {
      		Stock stock = stockRepository.findById(id).orElseThrow();
      		stock.decrease(quantity);
        
      		stockRepository.saveAndFlush(stock);
      }
    
    • 아래와 같이 동시에 100개의 요청을 보낼 때에 문제 발생!
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
      @Test
      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());  // 남은 재고가 0이 아님
      }
    
  • 문제 상황
    • 여러 스레드가 공유 데이터에 접근해서 갱신 시도 → race condition 발생
    • 하나의 스레드가 갱신 후에 다음 스레드가 접근하도록 해야!!
  • 문제 해결 방법
    • Application 레벨에서 해결
    • Database Lock 활용
    • Redis Distributed Lock 활용

Application Level

synchronized

  • synchronized 사용
1
2
3
4
5
6
7
@Transactional
public synchronized void decrease(Long id, Long quantity) {
		Stock stock = stockRepository.findById(id).orElseThrow();
		stock.decrease(quantity);

		stockRepository.saveAndFlush(stock);
}
  • 여전히 문제 발생 → transactional 어노테이션의 동작 방식 때문!
    • 트랜잭션이 종료되면서 재고가 갱신되기 전에 다음 스레드가 재고에 접근 가능 → 문제 발생!!
  • transactional 어노테이션을 주석처리하면?
1
2
3
4
5
6
7
//@Transactional
public synchronized void decrease(Long id, Long quantity) {
		Stock stock = stockRepository.findById(id).orElseThrow();
		stock.decrease(quantity);

		stockRepository.saveAndFlush(stock);
}
  • 원하는 방식으로 동작

문제점

  • 자바의 synchronized는 하나의 프로세스 안에서만 보장
  • 결국 서버의 개수가 늘어나면 여러 스레드에서 데이터에 접근 가능 → race condition 발생

Database Lock

  • MySQL이 제공해주는 Lock 사용

Pessimistic Lock

  • 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
  • 데이터에 Exclusive Lock을 걸게되면 다른 트랜잭션에서는 Lock이 해제되기 전에 데이터를 가져갈 수 없음
  • 데드락 발생 가능

예시 코드

  • Repository에 추가
1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);

특징

  • 충돌이 빈번하게 발생한다면 Optimistic Lock보다 성능이 좋음
  • 별도의 Lock때문에 성능 감소 가능

Optimistic Lock

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

예시 코드

  • Entity에 추가
1
2
@Version
private Long version;
  • Repository에 추가
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
18
19
20
21
@Component
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService optimisticLockStockService;

    public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
        this.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);  // 50ms후에 재시도
            }
        }
    }
}

특징

  • 별도의 Lock을 잡지 않으므로 Pessimistic Lock보다 성능에 이점
  • Update 실패시에 재시도 로직을 직접 구현해줘야 함
  • 충돌이 빈번하게 일어나지 않는다면 추천

Named Lock

  • 이름을 가진 Metadata Locking
  • 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 Lock을 획득할 수 없음
  • 트랜잭션이 종료될 때 Lock이 자동으로 해제되지 않으므로 별도로 해제해 주어야 함
  • Pessimistic Lock과 유사하지만 Metadata에 Lock을 한다는 점이 다름

예시 코드

  • 편의를 위해 동일한 데이터 소스 사용 → connection pool이 부족해질 수 있음
  • 실제로 사용할 때는 데이터 소스를 분리해야!
  • Repository 추가
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);
}
  • Facade 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class NamedLockStockFacade {

    private final LockRepository lockRepository;

    private final StockService stockService;

    public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
        this.lockRepository = lockRepository;
        this.stockService = stockService;
    }

    @Transactional
    public void decrease(Long id, Long quantity) {
        try {
            lockRepository.getLock(id.toString());
            stockService.decrease(id, quantity);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}
  • Service 변경
1
2
3
4
5
6
7
@Transactional(propagation = Propagation.REQUIRES_NEW)  //기존 트랜잭션은 반영되지 않고 일시적으로 중단되며, 새로운 트랜잭션이 시작
public void decrease(Long id, Long quantity) {
    Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);

    stockRepository.saveAndFlush(stock);
}
  • connection pool 사이즈 변경
1
2
3
4
spring:
  jpa:
    hikari:
      maximum-pool-size: 40

특징

  • 주로 분산락을 구현할 때 사용
  • 타임아웃을 손쉽게 구현 가능
  • 트랜잭션 종료시에 Lock 해제, 세션 관리를 잘 해주어야!
  • 실제 구현시에 복잡해질 수도 있음

Redis Distributed Lock

  • 아래 두가지 라이브러리를 통해 분산락 구현
  • 일반적으로 MySQL보다 성능이 좋음
  • 실무에서 비용적 여유가 있다면 Redis를 고려 그렇지 않고 트래픽이 적다면 MySQL을 고려

Lettuce

  • setnx 명령어를 활용하여 분산락 구현
    • Key와 Value를 set할 때 기존의 값이 없을 때에만 set

    image

  • Spin Lock 방식
    • 반복적으로 확인하면서 Lock 획득
    • retry 로직 직접 구현해줘야!

예시코드

  • Repository 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RedisLockRepository {

    private 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();
    }
}
  • Facade 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class LettuceLockStockFacade {

    private final RedisLockRepository redisLockRepository;
    private final StockService stockService;

    public LettuceLockStockFacade(RedisLockRepository redisLockRepository, StockService stockService) {
        this.redisLockRepository = redisLockRepository;
        this.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);
        }
    }
}

특징

  • 구현이 간단
  • Spin Lock 방식이므로 Redis에 부하가 갈수도! → Lock 획득 재시도 간에 텀을 주어야!!

Redisson

  • pub - sub 기반으로 Lock 구현 제공
    • Lock이 해제되면 Lock 해제를 기다리던 스레드에게 알려줌
    • retry 로직 작성 필요 X

예시코드

  • Redisson 라이브러리 추가
1
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.34.0'
  • Facade 추가
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
@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);  // 문제 발생시에 Lock을 기다리는 시간을 조금 늘려줄 것

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

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

특징

  • pub - sub 기반으 Redis의 부하를 줄여줌
  • 구현이 조금 어렵고 별도의 라이브러리를 사용해야!
This post is licensed under CC BY 4.0 by the author.