Concurrency issues
재고시스템으로 알아보는 동시성이슈 해결방법 강의를 듣고 요약한 내용입니다.
Race Condition
Race condition
경쟁상태는 두 개 이상의 스레드가 공유 데이터에 액세스할 수 있고, 동시에 변경을 하려고 할 때 발생하는 문제입니다.
Copy @ Service
@ RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
public synchronized void decrease ( Long id , Long quantity) {
final Stock stock = stockRepository . findById (id) . orElseThrow ();
stock . decrease (quantity);
stockRepository . saveAndFlush (stock);
}
}
우리는 아래 테스트에서 0의 결과를 예상하지만, 동시에 들어오는 요청들이 갱신 전 값을 읽고 수정
하면서 실제 갱신이 누락되는 현상이 발생하게 됩니다.
Copy @ Test
void decrease_inventory_concurrent_requests() 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()) ;
}
Java Synchronized
synchronized 를 메서드 선언부에 붙여주면 해당 메서드는 한 개의 스레드만 접근이 가능하게 됩니다.
Copy @ Transactional
public synchronized void decreases( Long id , Long quantity) {
final Stock stock = stockRepository . findById (id) . orElseThrow ();
stock . decrease (quantity);
stockRepository . saveAndFlush (stock);
}
.
⚠️ 여기서 문제가 있습니다.
Spring Transactional
의 동작 방식은 아래와 같이 우리가 만든 클래스를 래핑한 클래스를 새로 만들어서 실행하게 됩니다.
Copy public class TransactionService {
private StockService stockService;
...
public void decreases ( Long id , Long quantity) {
startTransaction() ;
stockService . decreases (id , quantity);
endTransaction() ;
}
...
}
우리가 만든 클래스를 필드로 가지고 호출하게 되는데 트랜잭션 종료 전에 다른 스레드가 갱신된 전 값을 읽게 되면
결국 이전과 동일한 문제가 발생하게 됩니다.
@Transactional 과 PROXY
여기서 단순하게 Transactional Annotation 을 제거하여 문제를 해결할 수는 있습니다.
.
⚠️ 하지만, Java Synchronized 의 문제는 또 존재합니다.
Java Synchronized 는 하나의 프로세스 안에서만 보장이 됩니다.
그러다보니 서버가 1대일 경우에는 괜찮겠지만, 서버가 2대 이상일 경우 결국 여러 스레드에서 동시에 데이터에 접근
할 수 있게 되면서 레이스 컨디션이 발생하게 됩니다.
Time
Thread-1
Stock
Thread-2
대부분의 운영 서비스는 2대 이상의 서버로 운영되기 때문에 Java Synchronized 는 거의 사용되지 않는 방법입니다.
DataBase Lock
MySQL
Pessimistic Lock
비관적 락.
실제로 데이터에 Lock
을 걸어서 정합성을 맞추는 방법
Exclusive Lock
을 걸게 되며, 다른 트랜잭션에서는 Lock 해제 전에 데이터를 가져갈 수 없음
데드락
(두 개 이상의 작업이 서로 상대방의 작업이 끝나기 만을 기다는 상태) 주의 필요
충돌이 빈번하게 일어나거나 예상된다면 추천하는 방식
장점.
충돌이 빈번하게 일어난다면 Optimistic Lock
보다 성능이 좋을 수 있다.
락을 통해 업데이터를 제어하므로 데이터 정합성이 보장 된다.
단점.
별도의 락을 잡기 때문에 성능 감소 가 있을 수 있다.
Copy /** Repository **/
@ Lock ( LockModeType . PESSIMISTIC_WRITE )
@ Query ( "select s from Stock s where s.id = :id" )
Stock findByIdWithPessimisticLock(@ Param ( "id" ) Long id) ;
...
/** Service **/
@ Transactional
public void decrease( Long id , Long quantity) {
final Stock stock = stockRepository . findByIdWithPessimisticLock (id);
stock . decrease (quantity);
stockRepository . save (stock);
}
commit
Optimistic Lock
낙관적 락.
실제로 Lock 을 이용하지 않고 버전을 이용
함으로써 정합성을 맞추는 방법
먼저 데이터를 읽은 후 update 수행 시, 현재 내가 읽은 버전이 맞는지 확인하며 업데이트
수행
내가 읽은 버전에서 수정사항이 생겼을 경우 애플리케이션에서 다시 읽은 후에 작업을 수행
충돌이 빈번하게 일어나지 않을 경우 추천하는 방식
장점.
별도의 락을 잡지 않으므로 Pessimistic Lock 보다 성능상 이점이 있다.
단점.
업데이트 실패 시 재시도 로직을 개발자가 직접 작성해야 한다.
Copy /** Entity **/
@ Version
private Long version;
...
/** Repository **/
@ Lock (value = LockModeType . OPTIMISTIC )
@ Query ( "select s from Stock s where s.id = :id" )
Stock findByIdWithOptimisticLock(@ Param ( "id" ) Long id) ;
...
/** Service **/
@ Transactional
public void decrease( Long id , Long quantity) {
final Stock stock = stockRepository . findByIdWithOptimisticLock (id);
stock . decrease (quantity);
stockRepository . save (stock);
}
...
/** Facade **/
@ Component
@ RequiredArgsConstructor
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 );
}
}
}
}
commit
Named Lock
이름을 가진 Lock
획득 후, 해제할 때까지 다른 세션은 이 Lock 을 획득할 수 없도록 함
Transaction 종료 시 Lock 이 자동으로 해제되지 않는 점
주의
별도의 명령어로 해제를 수행해 주거나 선점 시간이 끝나야 해제
비관적 락과 유사하지만 로우나 테이블이 아닌 메타데이터의 락킹
을 하는 방식
커넥션 풀 부족 현상을 막기 위해 데이터 소스를 분리해서 사용할 것을 권장
장점.
단점.
Copy /** Repository **/
@ Query (value = "select get_lock(:key, 3000)" , nativeQuery = true )
void getLock(@ Param ( "key" ) String key) ;
@ Query (value = "select release_lock(:key)" , nativeQuery = true )
void releaseLock(@ Param ( "key" ) String key) ;
...
/** Service **/
@ Transactional (propagation = Propagation . REQUIRES_NEW )
public void decrease( Long id , Long quantity) {
final Stock stock = stockRepository . findById (id) . orElseThrow ();
stock . decrease (quantity);
stockRepository . saveAndFlush (stock);
}
...
/** Facade **/
@ Component
@ RequiredArgsConstructor
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 ());
}
}
}
commit
Reference.
Redis
MySQL의 Named Lock과 유사한 방식
Lettuce
setnx
(set if not exist) 명령어를 활용하여 분산락 구현
Spin Lock
방식
락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식
장점.
별도의 라이브러리가 불필요(spring data redis 사용 시 기본적으로 Lettuce 적용)
단점.
Spin Lock 방식이므로 동시에 많은 스레드가 락 획득 대기 상태라면 레디스에 부하가 갈 수 있음
Copy # key 는 1 이고, value 는 lock 인 데이터 생성
> setnx 1 lock
( integer ) 1
# 이미 1 이라는 키가 있으므로 실패
> setnx 1 lock
( integer ) 0
# 키 삭제
> del 1
( integer ) 1
Copy /* Repository */
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) );
}
...
/* Facade */
public void decrease( Long key , Long quantity) throws InterruptedException {
while ( ! redisLockRepository . lock (key)) {
Thread . sleep ( 100 );
}
try {
stockService . decrease (key , quantity);
} finally {
redisLockRepository . unlock (key);
}
}
commit
Spin Lock
Redisson
Redisson/Spring Boot Starter
pub-sub
기반으로 Lock 구현 제공
채널을 만들고 락을 점유중인 스레드가 락 획득을 대기중인 스레드에게 락 해제라는 메시지를 전송하면, 메시지를 전달받은 스레드가 락 획득을 시도하는 방식
락 획득을 위해 반복적으로 락 획득을 시도하는 Lettuce 대비 부하를 줄일 수 있음
Copy Thread-1 ---------- > Channel ------------------- > Thread-2
I 'm done Try to get a Lock
Copy # ch1 채널 구독(thread-2)
> subscribe ch1
1 ) "subscribe"
2 ) "ch1"
3 ) ( integer ) 1
# 메시지 전송(thread-1)
> publish ch1 hello
( integer ) 1
# thread-2 메시지 수신
...
1 ) "message"
2 ) "ch1"
3 ) "hello"
장점.
pub-sub 기반 구현으로 lettuce 대비 레디스에 부하가 덜 감
단점.
Copy /* Facade */
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를 활용하고,
재시도가 필요한 경우 Redisson을 활용
commit
Finish
Java Synchronized
2대 이상의 서버로 운영될 경우 레이스 컨디션은 또 다시 발생
DataBase Lock
Pessimistic Lock
락 해제 전까지 다른 스레드는 데이터를 가져갈 수 없음(데드락 주의)
Named Lock
Pessimistic Lock 과 유사하지만 이름들 가진 데이터에 락킹
트랜잭션 종료 시 락 해제, 세션은 직접 관리 필요
Redis
Lettuce
Spin Lock 방식(레디스에 부하 가능성 존재)
MySql vs. Redis
Mysql을 사용중이라면 별도 비용없이 사용 가능
활용중인 Redis가 없다면 별도의 구축 비용과 인프라 관리 비용이 발생
Redis 보다 성능이 좋지 않음(어느정도의 트래픽까지는 문제없이 활용 가능)
Reference
Repository
Last updated 7 months ago