오늘은 동시성 이슈를 해결하는 과정에 대해서 포스팅하려고 합니다.
재고 시스템을 통해 Application Level에서 문제를 해결하는 방안, Database가 제공하는 Lock을 이용해서 문제를 해결하는 방안, Redis의 Lettuce, Redisson을 활용하는 방안에 대해서 설명드리겠습니다.
domain은 간단하게 재고 갯수, version 정보(Optimistic Lock을 적용하기 위함)가 담겨있고, 재고 개수를 감소시키는 decrease 메서드 Stock 클래스로 구성되어 있습니다.
service는 domain에서 재고 개수를 감소시키는 decrease 메서드를 호출하는 비즈니스 로직으로 구성되어 있습니다.
이제 multi-thread 환경에서 동시에 100개의 요청을 보냈을 때의 발생하는 문제와 해결하는 방법에 대해 코드로 보여드리겠습니다.
- ExecutorService
- 병렬적으로 여러 작업을 효율적으로 처리하기 위해 제공되는 Java Library
- Java에서 제공하는 ThreadPool 관리 interface
- ThreadPool 설정 및 Task를 관리하고 실행할 수 있는 역할 담당
- CountDownLatch
- 동시성 API에 포함된 클래스로 하나 이상의 thread가 다른 thread가 수행 중인 작업이 완료될 때까지 기다리게 하는데 사용하는 클래스
- new CountDownLatch(threadCount) : 초기 카운트 값으로 시작
- countDown(): Latch의 카운트 값 1 감소
- await(): Latch의 카운트 값이 0이 될 때까지 현재 thread를 대기 상태로 만듦
Test 결과
다음과 같이, multi-thread로 100번의 제고 로직을 실행하게 되면 race-condition이 발생하여 기댓값과 전혀 다른 값이 나오게 됩니다. 이유는 공유된 자원을 여러 thread가 관여하려고 하기 때문입니다.
- race condition: 공유된 자원에 여러 thread나 session이 관여할 때 발생하는 문제
해결방안
1. Application Level
- Synchronized
- 메서드 반환 타입 앞에 synchronized 키워드를 명시해주면 multi-thread 환경에서 하나의 thread만 접근 가능
- 하나의 프로세스에만 적용이 가능하기에 한계가 있습니다
- save 대신 saveAndFlush를 사용하는 이유
- @Transactional의 동작 방식에서 DB에 값이 입력되기 전에 다른 thread가 메서드에 접근이 가능하게 됨
- 그래서 메서드 실행 즉시 DB에 Flush 작업을 해주기 위해 saveAndFlush 메서드 적용
2. Database가 제공하는 Lock 이용
1. Pessimistic Lock(비관적 락)
StockRepository.class
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id =: id")
Stock findByWithPessimisticLock(final Long id);
- DB가 제공하는 lock 기능을 이용해 엔티티를 영속 상태로 올릴 때 부터 다른 세션에서 조회하지 못하도록 Lock을 걸어두는 것
- 동시성 문제가 발생될거라는 가정 하에 Lock을 미리 걸어두는 방식
- Lock을 점유해야만 thread에 접근할 수 있고,대기 시간이 길어질 수 있으나 동시성 문제로 인한 문제를 완화시켜 줍니다
2. Optimistic Lock(낙관적 락)
StockRepository.class
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id =: id")
Stock findByWithOptimisticLock(final Long id);
- 자원에 Lock을 걸지 않고 충돌이 발생했을 때, Lock을 걸어두는 방식
- 공통된 자원에 @Version 어노테이션을 추가하여 구현하고, 약 초기에 commit한 version과 커밋할 때의 version이 다르다면 update 쿼리가 실패하게 되고 이에 대한 롤백 처리를 수행
- 동시성 수준이 상대적으로 높지만, 충돌 발생시 재시도 로직을 개발자가 직접 구현해야 합니다
3. Named Lock
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key, key)", nativeQuery = true)
void releaseLock(String key)
- 이름을 가진 metadata Lock
- 이름을 가진 Lock을 획득한 후, 해지될 때 까지 다른 세션이나 프로세스는 락을 획득 X
- Lock이 자동으로 해지되지 않으므로 별도로 해지해주거나 선점시간이 끝나야 해지됩니다.
- Lock 획득: getLock(), Lock 반환: releaseLock()
- StockService는 부모의 트랜잭션과 별도로 실행되어야하기 때문에, Propagation Level을 @Transactional(propagation = Propagation.REQUIRES_NEW)로 설정해야 합니다 -> 이후 finally에서 락 반환
3. Redis Lettuce, Redisson 활용 방안
1. Lettuce
- setnx (key) (value) 명령어를 활용하여 분산 Lock 구현 (key와 value를 set할 때 기존에 값이 없을 때만 set하는 명령어)
- Spin Lock 방식이므로 Lock을 획득할 때 까지 재시도하는 로직을 개발자가 직접 작성
- Spin Lock: Lock을 획득하려는 thread가 Lock을 획득할 수 있는지 반복적으로 시도하는 과정
- 구현이 간단하고, 별도의 Library가 필요하지 않습니다. Spin Lock 방식이기 때문에 동시에 많은 thread가 대기한다면, redis에 부하가 갈 수 있습니다.
2. Redisson
- Pub/Sub 기반 Lock 구현 제공
- Pub/Sub 방식이란, 채널을 하나 만들고 Lock을 가지고 있는 thread가 Lock을 해제한 경우, 대기중인 thread에게 알려주는 방식
- 별도의 Retry 로직을 작성하지 않아도 되지만, 별도의 라이브러리를 사용해야 합니다.
RedisLockRepository
LettuceLockStockFacade
- SpinLock 방식으로 지속적으로 Lock 획득을 시도합니다(while문)
RedissonLockStockFacade
- Lock과 관련된 library 제공(RedissonClient)
- Pub/Sub 방식
오늘은 재고시스템를 예시로 동시성 문제를 해결하는 방안에 대해 상세하게 알아보았습니다. Application Level, Database가 제공하는 Lock, Redis를 활용한 Lock에 대해서 알아보았는데, 앞으로 프로그래밍 할 때, 동시성을 더욱 고려하여 프로그래밍 해야겠다고 다짐하게 되었습니다.
<참고 자료>
https://thalals.tistory.com/370
'Java > Spring' 카테고리의 다른 글
[Spring] Nginx를 이용하여 http(80 Port)로 들어오는 요청을 springboot(8080 Port)로 Redirect 시키기 (0) | 2023.09.27 |
---|---|
[Spring] 계층형 디렉터리, 도메인형 디렉터리 구조 (0) | 2023.09.27 |
[Spring] 동시성 이슈 및 해결 방안 (0) | 2023.09.12 |
[Spring] Spring AOP (0) | 2023.08.15 |
[Spring] Proxy Pattern, Decorator Pattern (0) | 2023.08.15 |