언어/java

동시성 이슈에 따른 해결 방법(네임드락, 낙관적락, 비관적락)

STUFIT 2024. 1. 3. 18:32
반응형

이번에 팀 사수가 나에게 본인의 전 직장 얘기를 하면서 나왔던 용어가 있었다. 

"예전에 데드락이 걸렸는데~ ~를했는데 네임드락이 ~~ 낙관적락을 블라블라~".

잉?? 네임드락은 뭐고 낙관적락은 또 뭔데???

사수가 나에게 아직도 네임드락이 뭔지도 모르냐고 채찍질 2만대를 때려서.. 마음의 짐을 가진 채 찾아보기 시작했다. 그랬더니 해당 개념은 보통 동시성 이슈에서 자주 언급되는 용어들이였다. 그럼 아래에서부터 동시성 이슈 및 락 종류들에 대해 알아보도록 하겠다.

먼저, 동시성 이슈가 무엇인대???

사실, 나도 직장에 다니면서 사내 전사 시스템을 다루는 팀에 소속되어 있었기 때문에 동시성 이슈에 대해 생각을 해본적이 전혀 없었다. 그러나 서비스 플랫폼 팀에서는 동시성 이슈가 발생하는 경우들이 종종 있었다.

동시성 이슈는 쉽게 말하자면 동시에 여러명이 어떠한 업데이트문을 db 쿼리로 날렸을 때, 순차적으로 업데이트가 되어야 하는데 동시에 업데이트가 되어서 데이터가 꼬여버리는 현상이다.

간단한 예시를 들겠다.

id stock
1 5
2 9

이러한 데이터가 있을 때, A,B 두 사람이 id 1의 재고를 1 증가시키려고 한다.

그렇다면 일반적으로 생각하면 A가 먼저 stock을 1 증가시키고, 그 다음 B가 stock을 1 증가시킨다고 한다면, id 1 번의 stock은 7이 될 것이다. 

그런데 이게 웬걸? A와B가 동시에 만약 stock을 증가시킨다고 하면 어떨까?

위처럼 7이 된다고 생각하겠지만, stock은 6이 될 가능성이 매우매우 크다. 

그런데 왜 6이되는데??

위에서 만약 A,B가 동시에 stock을 1씩 증가시킨다고 했을때, A가 id =1, stock=5인 것을 1 증가시키는 update 문을 날리면, B 역시 id=1, stock=5를 바라보고 update를 해버리는 것이다!

우리가 원하는거는 A,B 동시에 작업이 들어왔을 시, 순서대로 재고를 업데이트해서 stock이 7이되는게 목적인데, 이게 무슨 날벼락??!!!

그래서 우리는 이러한 현상을 동시성 이슈라고 부른다.

그렇다면 이러한 동시성 이슈는 어떻게 해결할까???

사실 나는 동시성 이슈를 현업에서 직접 겪어본 적이 없어서 자료를 찾아서 작성하지만 추후에 현업에서 꼭 적용해보고 싶다.

아래는 자바에서 동시성 이슈를 해결하는 방법이다.

1. synchronized 사용

public void 동시요청() 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); //stockService.decrease 에서는 입력값을 줄이는 로직이 포함됨.
            }finally{
            latch.countDown();
            }
        });
    }
    latch.await();
}

// synchronized 적용 전
public class StockService{
	private final StockRepository stockRepository;
    
    public StockService(StockRepository stockRepository){
    	this.stockRepository = stockRepository
    };
    
    @Transactional
    public void decrease(Long id, Long quantity){
    	Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

// synchronized 적용 후
public class StockService{
	private final StockRepository stockRepository;
    
    public StockService(StockRepository stockRepository){
    	this.stockRepository = stockRepository
    };
    
    @Transactional
    public synchronized void decrease(Long id, Long quantity){
    	Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}

먼저, 위 코드에서는 100개의 재고 감소 작업이 들어간다고 했을 때, for문을 돌리면 결과값은 0이 되어야 할 것이다.

하지만 코드를 돌려보면 결과가 0이 아니라 96이 될것이다.

이 현상은 위에서 설명한 것처럼 재고 감소 업데이트 시, 한개의 id 값이 완전히 작업이 끝나기 전에 다른 작업이 들어가서 업데이트가 제대로 이뤄지지 않은 것이다. 이를 표로 설명한다면

시간 서버1 재고 서버2
10:00 select * from 재고 where id=1 {id:1, stock:5}  작업 x
    {id:1, stock:5} select * from 재고 where id=1
10:03 update set stock=4 from 재고 where id=1  {id:1, stock:4} 작업x
    {id:1, stock:4} update set stock=4 from 재고 where id=1

여기서 보면 10시에 서버1에서 재고 id가 1인 것을 셀렉트로 잡은 후에, 10시 3분에 재고를 4로 바꾸는 업데이트를 하였다. 그런데 10시~10시3분 사이에 서버2에서 마찬가지로 셀렉트문으로 재고 id=1을 조회하였고 이때는 아직 서버1에서 업데이트를 하지 않은 상태여서 stock 값이 5인 상태이다. 그 후 10시 3분에 서버1에서 update를 통해 재고를 4로 바꿨지만 서버 2에서는 여전히 재고 id=1은 5라고 판단하여 10시 3분 이후에 서버2에서 재고 감소 업데이트를 할 때 업데이트 stock을 3이 아닌 4로 하는 것이다.

 

그래서 위의 코드에서 synchronized를 붙이게 되면 성공할 것이라고 생각하지만...!!! 그래도 아마 결과값이 0이 아닌 50이 나오면서 실패할 것이다.

아니 synchronized 붙이면 해결되는거 아니였냐고~~~!!!

근데 그 이유는 바로 @Transactional 어노테이션 때문에 실패하는 것이다.

@Transactional 어노테이션을 이용하면 해당 어노테이션을 붙인 클래스를 새로 만들어서 실행하게 되는데, 새로 만들어진 클래스를 실행할 때, startTransaction(); 을 먼저 한 후에, 우리가 만든 stockService.decrease(); 를 실행하고, 그 후에 endTransaction(); 으로 트랜잭션을 종료한다. 그런데 이 과정에서 트랜잭션 종료 시점에서 update를 하게 되는데 decrease() 메소드가 종료되고 endTransaction(); 이 호출되기 전에 다른 쓰레드에서 작업이 들어가게 되는 경우가 있어서 결과값이 0이 아니라 반띵인 50이 나오는 것이다.

그래서 synchronized를 사용할 때 @Transactional 어노테이션을 지워버리고 사용하면 정상적으로 결과값이 0인 것을 확인할 수 있다.

2. Pessimistic Lock

pessimistic Lock은 비관적 락으로서, 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. Exclusive Lock을 걸게 되면 다른 트랜잭션에서는 Lock이 해제되기 전까지는 데이터를 가져갈 수 없게 된다. 다만, 데드락이 걸릴 수 있기 때문에 주의하여 사용해야 한다.

원리는 다음과 같다.

시간 서버1 재고 서버2
10:00 select * from 재고 where id=1  => Lock을 걸어버림 {id:1, stock:5}  여기서 select 시도하지만 이미 서버1 이 Lock을 걸어서 접근이 안되기 때문에 대기함.
    {id:1, stock:5} 대기중
10:03 update set stock=4 from 재고 where id=1  => Lock 풀림 {id:1, stock:4}  
    {id:1, stock:3} update set stock=4 from 재고 where id=1 => 10시 3분에 서버1에서 Lock을 풀어서 이제 작업을 진행할 수 있음.

아래는 스프링 코드예시이다.

// 1. 레포지토리에서 pessimistic lock을 걸어준다.

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

// 2. 서비스 작성

public class PessimisticLockStockService{
	private final StockRepository stockRepository;
    
    public PessimisticLockStockService(StockRepository stockRepository){
    	this.stockRepository = stockRepository
    };
    
    @Transactional
    public void decrease(Long id, Long quantity){
    	Stock stock = stockRepository.findByIdWithPessimisticLock(id);
        stock.decrease(quantity);
        stockRepository.save(stock);
    }
}

perssimistic lock의 장점은 만약 충돌이 빈번하게 일어난다면 optimistic lock 보다 성능이 좋을 수 있다. 또한, lock을 사용하기 때문에 데이터 정합성이 보장된다.

3. Optimistic Lock 

optimistic lock은 lock을 이용하지 않고 version을 이용해서 정합성을 맞춘다. 잉? version이 뭔데??? 아래의 테이블을 보자.

서버1 DB데이터 서버2
select * from stock where id =1  { read {id : 1, stock: 100, version: 1}
     
     

4. Named Lock

 

반응형

'언어 > java' 카테고리의 다른 글

지정자(public, static, void 등)  (0) 2023.10.04
HashSet & HashMap  (0) 2023.09.08
ArrayList & LinkedList  (0) 2023.09.05
인터페이스(Interface)  (0) 2023.08.28
언어특징 및 개발환경  (0) 2023.08.11