Important Study - Concurrency issues And Lock


스레드 동기화 & 동시성 이슈 문제들

  • 레이스 컨디션 ( Race Condition )
    • 두 개 이상의 스레드가 동시에 접근하여 결과값에 영향을 줄 수 있는 상태 흔히 말하는 동시성 문제라고 할 수 있다.
  • 교착 상태 ( Deadlock )
    • 공유 자원에 대한 요구가 엉켜서 자원 관리를 잘못하여 프로세스나 스레드가 자원의 락을 획득하기 위해 무한 대기 하는 것
  • 기아 상태 ( Starvation )
    • 스레드들에게 우선 순위를 부여하여 공유 자원에 접근할 때, 우선순위가 낮은 스레드가 소외되어 아무일도 하지 못하는 상태
  • 라이브락 ( Livelock )
    • 스레드들이 동시에 실행되면서 락의 해제와 획득을 반복적으로 하면서 정상적으로 동작하는 것처럼 보이지만 사실상 아무것도 못하고 무한 동작중인 상황



가시성, 동시성, 그리고 원자성

  • 가시성 ( Visibility )
    • CPU 안에는 CPU cache 라는 영역이 있다.

    • 우리가 선언한 변수의 값은 CPU cache를 거쳐서 메인메모리에 올라가고 불러온다.

    • 이는 CPU가 메인 메모리에서 값을 읽고 쓰는 시간을 아끼기 위해서이다.

    • 그런데, CPU cache 에서 메인 메모리로 값이 언제 옮겨갈지 모르는다는 것이다.

    • 그러한 이유로 하나의 스레드에서 변경한 변수의 값이 다른 스레드에서 보이지 않는 것을 ‘가시성 이슈’ 라고 한다.


  • 동시성 ( Concurrency )
    • 하나의 코어에 여러개의 스레드가 번갈아가며 작업을 실행하는 성질을 뜻한다.

    • 동시에 하는 것처럼 보일 수 있지만, 사실은 동시에 작업하는 것처럼 보일만큼 빠르게 작업을 번갈아가며 진행한다.

    • 그 때 일어나는 일관성 문제나, 위의 레이스컨디션, 데드락등의 문제가 일어나는 것을 ‘동시성 이슈’ 라고 한다.


  • 원자성 ( Atomicity )
    • 더 이상 쪼개질 수 없는 성질.

    • 원자성을 가지는 작업은 실행되어 진행되다가 종료되지 않는 한, 중간에서 멈출 수 없음.

    • 그리고 해당 명령을 수행 할 때 다른 곳에서 접근할 수 없음.



가시성, 동시성 이슈를 해결하는 방법

  • Java 에서는 Volatile 과 Synchronized 그리고 Atomic 이렇게 세 가지를 지원한다.

  • Volatile

    • volatile 은 변수에 설정해주는 것인데, 해당 변수에 대해 CPU cache를 사용하는 것이 아닌 메인 메모리에서 바로 올리고 내릴 수 있게 하여, 가시성을 보장해준다.

    • 그러나 원자성을 보장하지 않기 때문에 동시성 이슈는 해결하지 못한다.

    • 다음은 Volatile 을 활용한 예시 코드이다.

     public class VolatileExample {
         private static volatile int sharedValue = 0;
    
         public static void main(String[] args) {
             // 스레드 1: 값을 증가시킴
             Thread thread1 = new Thread(() -> {
                 for (int i = 0; i < 5; i++) {
                     System.out.println("Thread 1: Incrementing sharedValue");
                     sharedValue++;
                     sleepForRandomTime();
                 }
             });
    
             // 스레드 2: 값을 읽음
             Thread thread2 = new Thread(() -> {
                 for (int i = 0; i < 5; i++) {
                     System.out.println("Thread 2: Reading sharedValue: " + sharedValue);
                     sleepForRandomTime();
                 }
             });
    
             // 스레드 실행
             thread1.start();
             thread2.start();
         }
    
         private static void sleepForRandomTime() {
             try {
                 Thread.sleep((long) (Math.random() * 1000));
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }
    
    • volatile 을 통해 가시성 이슈를 해결 할 수 있다.

    • 해당 코드의 결과는 다음과 같이 나온다.

     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 1
     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 2
     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 3
     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 4
     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 5
    
    • 그러나, 만약 volatile을 사용하지 않았다면 숫자 증가가 메인 메모리에 늦게 올라가기 때문에, 가시성 이유때문에 아래와 같은 결과가 나오게 된다.
     Thread 1: Incrementing sharedValue
     Thread 1: Incrementing sharedValue
     Thread 1: Incrementing sharedValue
     Thread 1: Incrementing sharedValue
     Thread 1: Incrementing sharedValue
     Thread 2: Reading sharedValue: 0
     Thread 2: Reading sharedValue: 0
     Thread 2: Reading sharedValue: 0
     Thread 2: Reading sharedValue: 0
     Thread 2: Reading sharedValue: 0
    


  • Synchronized
    • synchronized 은 메서드에 걸거나, synchronized 블럭을 만들어서 사용한다.

    • 해당 메서드 및 블럭에는 한 개의 쓰레드만 접근이 가능한데, 연산에 대하여 쓰레드 하나씩 수행하기 때문에 가시성과 원자성을 모두 보장한다.

    • 다만, 이러한 Lock을 거는 방식 자체가 Blocking 방식이기 때문에 병목 현상을 일으키기 쉽고, 데드락 상태가 될 수도 있기 때문에 조심해서 사용해야한다.

    • 그리고 JPA나 Spring 환경에서 사용할 때에는 @Transactional 어노테이션을 지우고 사용해야 한다.

    • @Transactional 은 프록시 객체로 동작하여 @Transactional 이 붙은 그 메서드 앞 뒤로 startTransaction( ); endTransaction( ); 메서드를 동작시키는데 endTransaction( ) 이 작동하기 전에 다른 스레드가 들어올 수 있어서 동기화가 제대로 이루어지지 않을 수 있다. ( 프록시에 대해서는 추후 포스팅 할 예정이다. )

    • synchronized 를 활용하여 동시성 문제를 해결한 예시코드이다.

     public class SynchronizedExample {
         // 공유 변수
         private static int counter = 0;
    
         // 메서드에 synchronized 키워드 추가
         private synchronized static void incrementCounter() {
             for (int i = 0; i < 1000000; i++) {
                 counter++;
             }
         }
    
         public static void main(String[] args) throws InterruptedException {
             // 두 개의 스레드 생성
             Thread thread1 = new Thread(SynchronizedExample::incrementCounter);
             Thread thread2 = new Thread(SynchronizedExample::incrementCounter);
    
             // 스레드 시작
             thread1.start();
             thread2.start();
    
             // 스레드가 종료될 때까지 대기
             thread1.join();
             thread2.join();
    
             // 결과 출력
             System.out.println("최종 카운터 값: " + counter);
         }
     }
    
     public class SynchronizedExample {
         private static int counter = 0;
         private static final Object lock = new Object();  // 락 객체 생성
    
         private static void incrementCounter() {
             for (int i = 0; i < 1000000; i++) {
                 // synchronized 블록 사용
                 synchronized (lock) {
                     counter++;
                 }
             }
         }
    
         public static void main(String[] args) throws InterruptedException {
             Thread thread1 = new Thread(SynchronizedExample::incrementCounter);
             Thread thread2 = new Thread(SynchronizedExample::incrementCounter);
    
             thread1.start();
             thread2.start();
    
             thread1.join();
             thread2.join();
    
             System.out.println("최종 카운터 값 (synchronized block): " + counter);
         }
     }
    
    • 해당 코드의 출력값은 2000000 이 나온다.

    • 그러나, synchronized 를 사용하지 않는다면 그것보다 적은 예측할 수 없는 값이 나오게 된다.


  • Atomic
    • Atomic 이 붙은 클래스.. 예를 들어 AtomicInteger, AtomicLong 과 같은 클래스를 통해 사용할 수 있다.

    • Lock 을 거는 synchronized 방식과 다르게 CAS 알고리즘을 사용한다.

    • CAS 알고리즘 : 현재 스레드가 존재하는 CPU의 Cache 메모리와 Main 메모리의 값을 비교하여, 일치하는 경우 새로운 값으로 교체하고, 일치 하지 않으면 기존 교체를 실패처리하고 재시도 하는 방식

    • 내부 코드를 확인해보면 volatile 처리가 된 value를 가지고 있어서 가시성도 보장이 된다.

    • 그리고 CAS 알고리즘을 적용한 메서드 호출을 통해 원자성도 보장해준다.

    • Atomic은 직접적으로 Lock 을 거는게 아니기 때문에 synchronized 보다 성능이 좋다.

    • 다음은 Atomic 을 사용한 예시 코드이다.

     import java.util.concurrent.atomic.AtomicInteger;
    
     public class AtomicExample {
         private static AtomicInteger counter = new AtomicInteger(0);
    
         private static void incrementCounter() {
             for (int i = 0; i < 1000000; i++) {
                 counter.incrementAndGet(); // Atomic 연산: 현재 값에 1을 더하고 그 값을 반환
             }
         }
    
         public static void main(String[] args) throws InterruptedException {
             Thread thread1 = new Thread(AtomicExample::incrementCounter);
             Thread thread2 = new Thread(AtomicExample::incrementCounter);
    
             thread1.start();
             thread2.start();
    
             thread1.join();
             thread2.join();
    
             System.out.println("최종 카운터 값 (Atomic): " + counter.get());
         }
     }
    
    • 추가 상식
      • Map 을 보면 HashTable과 HashMap 그리고 ConcurrentHashMap 이 있는데, 이 중 thread-safe 하지 않은 HashMap 은 동기화가 안되니 제외하고, HashTable은 내부적으로 메서드 자체에 synchronized 를 걸기에 성능이 다소 떨어진다.

      • 그리고 ConcurrentHashMap 은 내부적으로 synchronized 블럭과 CAS 알고리즘을 채택하여, 특정 버킷에 대해서만 Lock 을 걸기때문에 성능이 우수하다.

      • 멀티스레드 환경에서는 CAS 알고리즘을 사용하는 Atomic 이나 ConcurrentHashMap 을 사용하면 좋다.


  • 그러나 Volatile, Synchronized, Atomic 위의 세 가지 방법은 서버와 DB가 모두 1개만 있을 경우에 사용 가능한 방법이다. 애플리케이션 서버가 여러개일경우에는 위 방식을 사용하면 안된다. 애플리케이션 단에서 동시성을 보장할 방법이 없기 때문이다.

  • 그럴 경우 이제 비관적 락이나, 낙관적 락, 유저 락을 걸어 해결해야 한다.



비관적 락 & 낙관적 락 & 유저 락

  • 비관적 락 ( Pessimistic Lock )
    • DB의 Lock 기능을 이용한다. ( 공유락, 배타락 )

    • 레코드 단에 Lock을 걸어서 하나의 스레드만 접근 할 수 있게 잠금을 거는 방식이다. 다만 성능적 측면에서 손실이 생길 수 있다.

    • 충돌( 동시에 update 하는 일 )이 빈번하게 일어날 것 같은 로직에는 비관적 락을 거는 것이 좋다.

    • 충돌이 빈번하게 일어나는 로직에 낙관적 락을 걸게되면 계속 재시도 로직이 일어나서 오히려 성능이 좋지 않을 수 있다. ( 데드락 가능성도 생긴다 )

    • JPA에서는 Repository 단에서 PESSIMISTIC_READ 으로 공유락을, PESSIMISTIC_WRITE 으로 배타락을 거는데.. 보통은 배타락으로 많이 건다.

    • 아래는 비관적 락을 적용한 코드이다

     @Entity
     public class Stock {
    
         @Id
         @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         private Long productId;
    
         private Long quantity;
    
         public Stock() {
    
         }
    
         public Stock(Long productId, Long quantity) {
             this.productId = productId;
             this.quantity = quantity;
         }
    
         public Long getQuantity() {
             return quantity;
         }
    
         public void decrease(Long quantity) {
             if (this.quantity - quantity < 0) {
                 throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
             }
    
             this.quantity -= quantity;
         }
    
     }
    
     @Service
     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);
         }
     }
    
     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);
     }
    
    • 추가 상식
      • MySQL의 기본적인 Update 문에는 업데이트락이 걸려있다.

        • 업데이트락은 UPDATE 문으로 데이터를 수정할 때 임시적으로 걸리며 데드락 방지를 위해 배타락 대신 사용한다.

        • 업데이트락은 데이터를 수정할 때 배타락으로 전환하며 읽을 때는 공유락으로 전환하여 업데이트중에 데이터를 읽도록 허락한다.

        • 기본적으로 UPDATE ~ WHEHE 가 실행되는 과정에서 적용된다.

      • 그래서 게시판의 조회수 증가 로직 같이 레코드를 Update 해주는 경우 동시에 많은 대상이 몰려 조회수를 Update 할 경우 베타락 때문에 게시글을 수정하는 Update문도 기다려야되는 성능 악화가 일어날 수 있다.

      • 그래서 조회수 증가는 잘 고민해서 로직을 생각해야한다. ( 조회수 DB를 따로 만들어서 배치를 돌리거나, 레디스에 저장한 후 배치를 돌리거나 등 )


  • 낙관적 락 ( Optimistic Lock )
    • Application 단에서 로직으로 해결

    • JPA의 @Version 과 같이 버전을 관리해주는 컬럼을 만들어서.. 커밋을 하기 전에 엔티티 버전과 DB 버전이 같은지를 확인하고, 버전이 같으면 커밋을.. 버전이 다르면 롤백 후 재시도를 해주는 방식이다.

    • 동시 요청에 대한 처리 성능이 좋으나, 롤백 로직을 직접 구현해줘야하고, 빈번한 충돌이 있는 서비스에 사용할 경우 롤백 로직이 자주 동작하여 성능에 문제가 생길 수 있다.

    • 하나의 작업만 성공하고 나머지는 ObjectOptimisticLockingException 예외를 일으키며 실패한다.

      • 고로, 동시에 요청이 들어와도 모두 누락되지 않고 정상 처리되어야 하려면, 재시도 로직을 추가로 구현해주어야 하고,

      • 재시도 처리도 실패할 경우를 대비한 케이스도 고려해야 한다. ( 복잡성 증가 )

    • 읽기가 잦아 성능적으로 중요할 때 사용하며, 어쩌다 한 번씩 일어날 충돌을 대비해야 하는 로직에 사용하는게 좋다.

    • 그리고 여러 요청중 하나만 처리해야 하는 경우에 사용하면 좋다.

    • 아래는 낙관적 락을 적용한 예시 코드이다.

     @Entity
     public class Stock {
    
         @Id
         @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         private Long productId;
    
         private Long quantity;
    
         @Version // 낙관적 락을 위해 version 추가
         private Long version;
    
         public Stock() {
    
         }
    
         public Stock(Long productId, Long quantity) {
             this.productId = productId;
             this.quantity = quantity;
         }
    
         public Long getQuantity() {
             return quantity;
         }
    
         public void decrease(Long quantity) {
             if (this.quantity - quantity < 0) {
                 throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
             }
    
             this.quantity -= quantity;
         }
    
     }
    
     @Service
     public class OptimisticLockStockService {
    
         private final StockRepository stockRepository;
    
         public OptimisticLockStockService(StockRepository stockRepository) {
             this.stockRepository = stockRepository;
         }
    
         @Transactional
         public void decrease(Long id, Long quantity) {
             Stock stock = stockRepository.findByIdWithOptimisticLock(id);
             stock.decrease(quantity);
             stockRepository.save(stock);
         }
     }
    
     @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);
                 }
             }
         }
    
     }
    
     public interface StockRepository extends JpaRepository<Stock, Long> {
         // 낙관적 락
         @Lock(LockModeType.OPTIMISTIC)
         @Query("select s from Stock s where s.id = :id")
         Stock findByIdWithOptimisticLock(Long id);
     }
    


  • 유저 락 ( User Lock / Named Lock / 분산 락 )
    • 유저락은 분산락이라고도 한다.

    • 분산락은 로직, API 등과 같은 자원에 접근하려는 대상에 대해 락을 건다. ( 비관락은 레코드에 걸었다. )

    • 네임드락은 MySQL에서 지원해주는 분산락 방식이다.

    • 비관적락이나 낙관적락으로 성능제어가 어려울 경우, 그리고 DB가 분산된 DB일 경우에 사용한다. DB가 분산되어있으면 비관적, 낙관적 락으로는 해결할 수 없기 때문이다.

    • 분산 DB를 사용하지만, 굳이 레디스를 쓸만큼 트래픽이 많지 않거나, 비용적 여유가 적은 경우에 사용하는 방법이며, 트래픽이 너무 많아서 성능 이슈가 발생하거나 비용적 여유가 많은 경우에는 Redis의 분산락을 활용하는 것이 좋다.

    • 트래픽이 너무 많으면.. 네임드 락은 일시적인 락에 대한 정보가 DB에 저장되고, 락을 획득하고 제거하는 쿼리가 매번 발생하는 방식이기 때문에 DB에 불필요한 부하를 줄 수 있다.

    • 구현을 할 때 부모 트랜잭션과 다른 물리적 트랜잭션 범위를 지정해주어야 한다.

    • 다음은 유저락을 거는 예시이다.

     /**
      * 부모의 Transactional 과 별도로 실행되어야 하기 때문에 Propagation 을 설정해 줌.
      */
     @Transactional(propagation = Propagation.REQUIRES_NEW)
     public void decreaseNamedLock(Long id, Long quantity) {
         Stock stock = stockRepository.findById(id).orElseThrow();
         stock.decrease(quantity);
    
         stockRepository.saveAndFlush(stock);
     }
    
     @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.decreaseNamedLock(id, quantity);
             } finally {
                 lockRepository.releaseLock(id.toString());
             }
         }
    
     }
    
     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);
    
     }
    



Redis 를 이용한 분산락

  • 일단 레디스의 분산락은 Key 값으로 Redis에 Lock 획득을 시도하는 방식이다.

  • Lettuce

    • 스핀 락 형태로 구현하므로 레디스에 부하를 줄 수 있다. 그래서 Thread sleep 을 활용하여 부하를 줄여야 한다.

    • 스핀 락 : 특정 조건이 충족 될 때까지 계속해서 루프를 돌며 기다리는 방식

    • 직접 스핀 락 형태로 구현을 해야하고, Thread sleep 으로 인해 연속적인 락 해제/획득이 되지 않아 비효율적일 수 있다.

    • 한 스레드가 Lock 획득 후 오류로 인해 UnLock 에 실패하면, 다른 스레드들은 Lock을 획득하지 못해 무한 대기상태에 빠질 수 있으므로, 이를 해결할 로직을 추가로 생각해주어야 한다.

    • Lettuce 를 활용한 예시 코드이다.

      /**
       * Lettuce 를 활용하여 분산 락을 구현하는 장점은, DB의 Named Lock에 비해 구현이 쉽다가 있다.
       * 그러나 단점은 스핀 락 방식이므로, 레디스에 부하를 줄 수 있다.
       * 그래서 Thread sleep 을 통해 요청 텀을 두어야 한다.
       *  - 스핀 락 방식 : 특정 조건이 충족 될 때까지 계속해서 루프를 돌며 기다리는 방식
       */
      @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);
              }
          }
      }
    
      @Repository
      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 void unlock(Long key) {
              redisTemplate.delete(generateKey(key));
          }
    
          private String generateKey(Long key) {
              return key.toString();
          }
    
      }
    


  • Redisson
    • pub sub 방식으로 이루어져있어서 연속적인 락 해제/획득이 가능하다.

    • 자체 구현된 TimeOut이 있기 때문에, 하나의 락이 최대 점유할 수 있는 시간을 설정할 수 있다.

    • 그래서 Lettuce처럼 무한 대기 상태에 대한 예외코드를 직접 작성하지 않아도 된다.

    • Lettuce 보다 성능이 좋지만, 라이브러리에 의존적이라는 단점이 있다.

    • 보통은 Redisson 을 많이 이용한다.

    • 다음은 Redisson 을 활용한 예시 코드이다.

      /**
       * Redisson 은 pubsub 기반이기 때문에 레디스의 과부하를 줄일 수 있다.
       * 대신 구현에 별도 로직이 들어간다는 것과, Redisson 이라는 라이브러리를 사용해야하는 부담이 있다.
       */
      @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) throws InterruptedException {
              RLock lock = redissonClient.getLock(id.toString());
    
              try {
                  boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
    
                  if (!available) {
                      System.out.println("lock 획득 실패");
                      return;
                  }
    
                  stockService.decrease(id, quantity);
    
              } catch (InterruptedException e) {
                  throw new RuntimeException(e);
              } finally {
                  lock.unlock();
              }
    
          }
    
      }
    






results matching ""

    No results matching ""