본문으로 바로가기

동시성 해결방법

category SPRING/스프링부트 2024. 3. 26. 17:42
728x90
반응형
SMALL

보통 우리는 Update 같은 상황에는 @Transactional 어노테이션을 사용하여 Spring 단에서 데이터의 정합성을 보장한다.

그러나 이것은 단지 싱글스레드의 환경에서만 보장되는 것이다.
만약 멀티스레드 환경에서 동시에 update를 한다면 데이터의 정합성을 보장할 수 없다.

내가 예상했던 결과 값과는 달리 다른 값들이 만들어진다.

이는 멀티스레드 환경에서의 동시성 이슈이며 이를 해결할 수 있는 5가지 방법을 알아보자

개요

synchronized
낙관적 락
비관적 락
네임드 락
redis
@Entity  
@Getter  
@Setter  
public class Book {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  

    private int stockQuantity;  

    /**  
     * 낙관적 락  
     * */  
    @Version  
    private Long version;  

    /**  
     * stock 증가  
     */  
    public void addStock(int quantity) {  
        this.stockQuantity += quantity;  
    }  

    /**  
     * stock 감소  
     */  
    public void removeStock(int quantity) {  
        int restStock = this.stockQuantity - quantity;  
        if (restStock < 0) {  
            throw new NoSuchElementException("need more stock");  
        }  
        this.stockQuantity = restStock;  
    }  

}

위의 BOOK 엔티티를 준비해서 QUANTITY 를 감소시키는 메서드를 여러 쓰레드가 접근해서 감소하는 동시성 테스트를 진행할 것이다.

1. synchronized

동시성을 해결할 수 있는 방법을 찾아보면 가장 친근히 나오는 synchronized 키워드 이다.

아래 처럼 @Transactional 과 synchronized 같이 적용 하였다.
그리고 테스트는 실패하였다. 왜일까?

@Slf4j  
@RequiredArgsConstructor  
@Service  
public class BookService {  

    private final BookRepository bookRepository;  

    public Book getById(Long idx){  
        return bookRepository.findById(idx).get();  
    }  

    @Transactional  
    public synchronized void decreaseV1(Long idx){  
        Book book = bookRepository.findById(idx).orElseThrow();  

        book.removeStock(1);  
    }



@Test  
@DisplayName("동시에 100개의 요청으로 재고를 감소시킨다. && @Transactional과 synchronized의 보장 범위가 틀려 동시성 보장이 안된다." +  
        "또한 synchronized는 하나의 스레드 하나의 프로세스에서만 보장되기 때문에 서버 scale-out시 동시성 보장이 안된다.")  
void decreaseV1() throws InterruptedException {  
    //given  
    final int threadCnt = 100;  
    final ExecutorService executorService = Executors.newFixedThreadPool(32);  
    final CountDownLatch countDownLatch = new CountDownLatch(threadCnt);  

    //then  
    for (int i=0; i<threadCnt; i++){  
        executorService.submit(() -> {  
           try {  
               bookService.decreaseV1(1L);  
           } finally {  
               countDownLatch.countDown();  
           }  
        });  
    }  

    countDownLatch.await();  
    final Book book = bookService.getById(1L);  

    //when  
    assertThat(book.getStockQuantity()).isEqualTo(0);  

    System.out.println(book.getStockQuantity());  
}

synchronized와 @Transactional를 같이 사용하는 경우 Spring Aop로 인해 만들어지는 Proxy 내부에서는 synchronized 가 생성이 안된다. Proxy에서는 메서드 시그니처와 연관되어 메서드가 생성된다. 메서드 시그니처메서드의 이름, 매개변수 타입 및 개수 로 구성되는 synchronized 동기화 키워드는 메서드 시그니처에 포함되지 않으므로 프록시 메서드 생성과는 무관하다.

직접 디버깅을 통해 확인해봐도 synchronized에 대한 어떠한 정보도 확인할 수 없다.

이런 문제 때문에 여러 쓰레드들이 같이 접근이 가능하기 때문에 멀티쓰레드에서는 위험할 수 있다.

현재 book을 조회하여 Quantity를 감소시키는 로직에서
스레드들은 Quantity 감소가 끝난 book을 조회하여 다시 감소시켜야 하지만 보장범위 불일치 문제로 트랜잭션에서 commit 중인 시점에도 Quantity 감소 이전의 book 값을 스레드가 접근하고 있는 상태이기 때문에 예상하는 결과 값과 달라진다.

그래서 synchronized와 @Transactional 을 같을 레벨에서 사용하면 무척 위험할 수 있다. 그래서 이것은 분리를 통해 해결할 수 있다.

중간에 Facade 계층을 둬서 호출 부분을 분리하여 각각 보장할 수 있도록 하였다.

@Slf4j  
@RequiredArgsConstructor  
@Service  
public class BookService {  

    private final BookRepository bookRepository;  

    public Book getById(Long idx){  
        return bookRepository.findById(idx).get();  
    }  

    @Transactional  
    public void decreaseV1(Long idx){  
        Book book = bookRepository.findById(idx).orElseThrow();  

        book.removeStock(1);  
    }
 }

@RequiredArgsConstructor  
@Service  
public class SyncBookFacade {  

    private final BookService bookService;  

    public synchronized void decrease(Long id){  
        bookService.decreaseV1(id);  
    }  
}

@Test  
void decreaseV1() throws InterruptedException {  
    //given  
    final int threadCnt = 100;  
    final ExecutorService executorService = Executors.newFixedThreadPool(32);  
    final CountDownLatch countDownLatch = new CountDownLatch(threadCnt);  

    //then  
    for (int i=0; i<threadCnt; i++){  
        executorService.submit(() -> {  
           try {  
               bookFacade.decrease(1L);  
           } finally {  
               countDownLatch.countDown();  
           }  
        });  
    }  

    countDownLatch.await();  
    final Book book = bookService.getById(1L);  

    //when  
    assertThat(book.getStockQuantity()).isEqualTo(0);  

    System.out.println(book.getStockQuantity());  
}

장점

쉽게 적용 가능하다.

단점

하나의 프로세스 안에서만 보장이 된다.
만약 SyncBookFacade 가 한개의 서버라고 가정하고 여러개의 서버로 실행된다면
동시성을 보장할 수 없다.

 

 

2. 낙관적 락

별도의 Lock을 이용하지 않고
데이터의 Version 확인을 통해서 동시성을 관리하는 방법이다.

@Entity  
@Getter  
@Setter  
public class Book {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  

    private int stockQuantity;  

    /**  
     * 낙관적 락  
     * */  
    @Version  
    private Long version;  

    /**  
     * stock 증가  
     */  
    public void addStock(int quantity) {  
        this.stockQuantity += quantity;  
    }  

    /**  
     * stock 감소  
     */  
    public void removeStock(int quantity) {  
        int restStock = this.stockQuantity - quantity;  
        if (restStock < 0) {  
            throw new NoSuchElementException("need more stock");  
        }  
        this.stockQuantity = restStock;  
    }  

}

위 엔티티의 @Version 이 그것을 위한 것이다.

위의 서로 다른 Server가 동시에 DB에 접근할 때 version 관리를 하게된다.

만약 이 상황에서 Server1이 먼저 DB에 update를 한다고 가정한다.

Server1의 update로 인해서 DB는 version 2로 변경되었으며
동시에 접근했던 Server 2는 DB와의 version 불일치 문제로 실패하게 되며 다시 접근해야한다.

다시 접근해야 되기 때문에 낙관적 락은 무조건 재시도 로직이 있어야 한다.

public interface BookRepository extends JpaRepository<Book, Long> { 
    /**  
     * 낙관적 락  
     */  
    @Lock(LockModeType.OPTIMISTIC)  
    @Query("select b from Book b where b.id = :id")  
    Book findByWithOptimisticLock(@Param("id") final Long id);  

}

@Transactional  
public void decreaseV3(Long idx) {  
    Book book = bookRepository.findByWithOptimisticLock(idx);  
    book.removeStock(1);  
}

@RequiredArgsConstructor  
@Service  
public class OptimisticBookFacade {  

    private final BookService bookService;  

    /**
    * 낙관적 락 재시도 로직 적용
    */
    public void decrease(Long id){  
        while (true){  
            try {  
                bookService.decreaseV3(id);  
                break;            
                } 
            catch (Exception e){  
                try {  
                    Thread.sleep(50);  
                } catch (InterruptedException ex) {  
                    throw new RuntimeException(ex);  
                }  
            }  
        }  
    }  
}

@Test  
@DisplayName("낙관적 락을 사용하여 동시성을 보장한다.")  
void decreaseV3() throws InterruptedException {  
    //given  
    final int threadCnt = 100;  
    final ExecutorService executorService = Executors.newFixedThreadPool(32);  
    final CountDownLatch countDownLatch = new CountDownLatch(threadCnt);  

    //then  
    for (int i=0; i<threadCnt; i++){  
        executorService.submit(() -> {  
            try {  
                bookFacade.decrease(1L);  
            } finally {  
                countDownLatch.countDown();  
            }  
        });  
    }  

    countDownLatch.await();  
    final Book book = bookService.getById(1L);  

    //when  
    assertThat(book.getStockQuantity()).isEqualTo(0);  

    System.out.println(book.getStockQuantity());  
}

장점

실제 LOCK을 사용하지 않는다.

단점

실패시 재시도 로직을 작성해야한다.
version 충돌이 빈번하면 비관적 락에 비해 성능이 떨어질 수 있다.

 

 

3. 비관적 락

비관적 락은 DB의 레코드에 LOCK 거 방법이다.
읽기 락(Shared Lock)쓰기 락(Exclusive Lock) 을 내부적으로 제어하여 동시성을 보장한다.

코드레벨에서는 LockModeType 으로 제어할 수 있다.

  • PESSIMISTIC_READ: 다른 트랜잭션에서 READ만 가능
  • PESSIMISTIC_WRITE: 다른 트랜잭션에서 READ/WRITE만 가능
  • PESSIMISTIC_FORCE_INCREMENT: 다른 트랜잭션에서 READ/WRITE 불가, version update 진행
public interface BookRepository extends JpaRepository<Book, Long> {  

    /**  
     * 비관적 락  
     */  
    @Lock(LockModeType.PESSIMISTIC_WRITE)  
    @Query("select b from Book b where b.id = :id")  
    Book findByWithPessimisticLock(@Param("id") final Long id);

}

/**  
 * 비관적 락 사용  
 * */  
@Transactional  
public void decreaseV2(Long idx){  
    Book book = bookRepository.findByWithPessimisticLock(idx);  

    book.removeStock(1);  
}

@Test  
@DisplayName("비관적 락을 사용하여 동시성을 보장한다.")  
void decreaseV2() throws InterruptedException {  
    //given  
    final int threadCnt = 100;  
    final ExecutorService executorService = Executors.newFixedThreadPool(32);  
    final CountDownLatch countDownLatch = new CountDownLatch(threadCnt);  

    //then  
    for (int i=0; i<threadCnt; i++){  
        executorService.submit(() -> {  
            try {  
                bookService.decreaseV2(1L);  
            } finally {  
                countDownLatch.countDown();  
            }  
        });  
    }  

    countDownLatch.await();  
    final Book book = bookService.getById(1L);  

    //when  
    assertThat(book.getStockQuantity()).isEqualTo(0);  

    System.out.println(book.getStockQuantity());  
}

장점

LOCK 을 통해서 데이터 정합성을 보장한다.
충돌이 빈번한 경우에는 낙관적 락 보다 성능이 좋을 수 있다.

단점

별도의 LOCK을 점유하고 있기 때문에 성능 문제가 발생할 수 있다.(데드락)
보통 단점으로 타임아웃이 힘들다고 하는데

  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout",value = "1000")}) 

위처럼 그리 어렵지 않게 타임아웃을 설정할 수 있다.
내가 봤을때는 단점이 아닌거 같다.

 

 

3. 네임드 락


네임드 락은 실제 DB의 LOCK을 이용하는 것이다.
서버는 먼저 DB의 LOCK의 접근하고 LOCK을 얻게 된 후 실제 데이터에 접근할 수 있다.
여기서 LOCK은 단순 개발자가 지정한 문자열에 대한 획득과 반환이며 전혀 테이블이나 인덱스 LOCK과는 관련이 없으니 유의하자.

public interface LockRepository extends JpaRepository<Book, 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);  
}

@Transactional  
public void decreaseV1(Long idx){  
    Book book = bookRepository.findById(idx).orElseThrow();  

    book.removeStock(1);  
}

@RequiredArgsConstructor  
@Service  
public class NamedLockBookFacade {  

    private final BookService bookService;  
    private final LockRepository lockRepository;  

    public void decrease(Long id){  
        try{  
            lockRepository.getLock(id.toString());  
            bookService.decreaseV1(id);  
        }finally {  
            lockRepository.releaseLock(id.toString());  
        }  
    }  

}

@Test  
@DisplayName("네임드 락을 사용하여 동시성을 보장한다.")  
void decreaseV4() throws InterruptedException {  
    //given  
    final int threadCnt = 100;  
    final ExecutorService executorService = Executors.newFixedThreadPool(32);  
    final CountDownLatch countDownLatch = new CountDownLatch(threadCnt);  

    //then  
    for (int i=0; i<threadCnt; i++){  
        executorService.submit(() -> {  
            try {  
                namedLockBookFacade.decrease(1L);  
            } finally {  
                countDownLatch.countDown();  
            }  
        });  
    }  

    countDownLatch.await();  
    final Book book = bookService.getById(1L);  

    //when  
    assertThat(book.getStockQuantity()).isEqualTo(0);  

    System.out.println(book.getStockQuantity());  
}

주의 해야 할점은 Select get_Lock(key, timeout) 보다시피 파라미터로 timeout을 설정할 수 있다. timeout에 따라서 대기하고 있는 스레드들이 많아질 수 있고 이는 성능문제로 연결될 수 있으니 적절한 값을 사용하자.

장점

동시성 환경에서 안전하게 데이터 정합성을 보장한다.
분산 락 환경에서도 적용이 가능하다.

단점

개발자가 철저하게 LOCK 대한 획득/해제, 세션관리를 잘 해야 한다.

728x90
반응형
LIST