예약 서비스 병행 제어 (낙관적 락, 비관적 락)

junto·2024년 12월 10일
1

database

목록 보기
11/11
post-thumbnail

전체 코드 링크: https://github.com/ji-jjang/Learning/commit/9b080a5e2cdaea4fbad2cf6cbc2c1559ebc47052
특정 경쟁 조건을 만족시키기 위해 sleep으로 조정한 코드가 있습니다. 운영체제, 컴퓨터 사양, 실행 상태 등에 따라 쓰레드 스케줄링이 달라 다른 결과가 나올 수 있는 점 참고해 주세요.

병행 제어 기본 용어

1) 임계 영역

  • 변수나 자료구조 같은 공유 자원에 접근하는 코드의 일부분

2) 경쟁 조건

  • 여러 쓰레드가 거의 동시에 임계 영역에 접근하는 상황

3) 비결정적

  • 경쟁 조건이 생긴다면 그 실행 결과가 각 쓰레드가 실행된 시점에 의존하므로 매번 프로그램의 결과가 다른 현상

4) 상호 배제

  • 하나의 쓰레드가 임계 영역 내의 코드를 실행 중일 때는 다른 쓰레드가 실행할 수 없도록 하는 것

5) 락

  • 상호 배제를 구현하기 위한 매커니즘으로 락을 획득한 쓰레드만 임계 영역에 접근할 수 있게 하는 것

예약 시스템 병행 제어 필요성

  • 아래와 같이 30분 단위 예약 슬롯이 8개 존재하는 간단한 공간 예약 서비스를 생각해 보자.

  • 유저가 앞에 슬롯부터 4개를 예약하면 슬롯의 상태는 아래와 같을 것이다.

  • 사용자가 한 명이라면, 크게 문제가 없다. 하지만 실제로는 여러 사용자가 동시에 예약할 수 있는 슬롯을 보게 된다.

  • 세 명의 사용자가 거의 동시에 4개의 슬롯(10:00 ~ 12:00)을 예약했다고 해보자. 그럼 무슨 일이 일어날까? 이 상황을 간단하게 자바 코드로 구현해 보자.
public class TimeSlot {

  private Long id;
  private LocalDateTime startTime;
  private LocalDateTime endTime;
  private Boolean isReserved;
  private Integer price;
}

public class Reservation {

  private Long id;
  private LocalDateTime startTime;
  private LocalDateTime endTime;
  private Integer price;
}

// 예약 서비스, 예약 생성 메서드
@Transactional
public Reservation createReservationRaceCondition(List<Long> timeSlotIds) {

  LocalDateTime startTime = null;
  LocalDateTime endTime = null;
  for (var timeSlotId : timeSlotIds) {

    TimeSlot timeSlot = getTimeSlotRaceCondition(timeSlotId);

    startTime = getStartTime(startTime, timeSlot.getStartTime());
    endTime = getEndTime(endTime, timeSlot.getEndTime());

    timeSlot.setIsReserved(true);
    timeSlotRepository.updateIsReservedTrue(timeSlot);
  }

  Reservation reservation = new Reservation(null, startTime, endTime, 10000);
  reservationRepository.save(reservation);

  return reservation;
}

private TimeSlot getTimeSlotRaceCondition(Long timeSlotId) {
  TimeSlot timeSlot = timeSlotRepository
    .findById(timeSlotId)
    .orElseThrow(
      () -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));

  if (timeSlot.getIsReserved()) {
    throw new RuntimeException("TimeSlot is reserved");
  }
  return timeSlot;
}
  • 예약 생성 메서드는 해당 슬롯이 예약되었다면, 이미 예약된 슬롯이라는 메시지와 함께 에러를 발생시킨다. 그러면 두 개의 쓰레드를 생성하여, 예약 생성이 거의 동시에 일어나는 테스트 코드를 작성해 보자.
@Test
@DisplayName("예약 생성 경쟁 조건이 발생하여 같은 슬롯에 2개의 예약이 생성된다.")
void createReservationRaceCondition() throws InterruptedException {

  List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);

  ExecutorService executor = Executors.newFixedThreadPool(2);

  List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());

  Runnable task1 = () -> {
    Reservation reservation = reservationService.createReservationRaceCondition(timeSlotIds);
    reservations.add(reservation);
  };

  Runnable task2 = () -> {
    Reservation reservation = reservationService.createReservationRaceCondition(timeSlotIds);
    reservations.add(reservation);
  };

  executor.execute(task1);
  executor.execute(task2);

  executor.shutdown();
  executor.awaitTermination(5, TimeUnit.SECONDS);

  Assertions.assertThat(reservations.size()).isEqualTo(2);
}
  • 위 코드를 실행하면서 발생한 쿼리는 아래와 같다.
1. thread2 슬롯 조회 (슬롯 예약되지 않은 상태)
2. thread1 슬롯 조회 (슬롯 예약되지 않은 상태)
3. thread1 슬롯 1, 2, 3, 4 UPDATE 및 예약 생성
4. thread2 슬롯 1, 2, 3, 4 UPDATE 및 예약 생성
  • 예약되지 않은 슬롯에 2개의 쓰레드가 거의 동시에 접근했기 때문에 2개의 예약이 생성된다. 이처럼 임계 영역에 경쟁 조건이 발생하는 상황은 상호 배제 수단인 락을 통해 해결할 수 있다.

낙관적 락 (Optimistic Lock)

  • 낙관적 락은 데이터를 읽을 때 락을 걸지 않고, 데이터를 수정할 때 버저닝된 값의 비교를 통하여 충돌 여부를 판단한다. 처음 데이터를 읽을 때 버저닝 값과 수정할 때 버저닝 값이 일치하지 않는다면 충돌이 발생한 것이다. 충돌이 발생하면 롤백시킨다. 충돌이 빈번한 상황에서 낙관적 락을 사용한다면 계속해서 롤백될 수 있기 때문에 롤백 오버헤드가 락 오버헤드보다 더 클 수 있다.
<update id="updateIsReservedTrue">
  UPDATE time_slots_versioning
  SET is_reserved = TRUE,
      version     = version + 1
  WHERE id = #{id}
    AND version = #{version}
</update>
  • 그럼, 낙관적 락을 사용해서 병행 제어를 해보자. 먼저 버저닝 필드를 추가한다.
public class TimeSlotVersioning {

  private Long id;
  private LocalDateTime startTime;
  private LocalDateTime endTime;
  private Boolean isReserved;
  private Integer price;
  private Integer version;
}

@Transactional
public Reservation createReservationOptimistic(List<Long> timeSlotIds) {

  List<TimeSlotVersioning> timeSlots = new ArrayList<>();
  for (var timeSlotId : timeSlotIds) {
    TimeSlotVersioning timeSlot = timeSlotVersioningRepository
      .findById(timeSlotId)
      .orElseThrow(
        () -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));
    timeSlots.add(timeSlot);
  }

  LocalDateTime startTime = null;
  LocalDateTime endTime = null;
  for (var timeSlot : timeSlots) {
    startTime = getStartTime(startTime, timeSlot.getStartTime());
    endTime = getEndTime(endTime, timeSlot.getEndTime());

    timeSlot.setIsReserved(true);
    int rows = timeSlotVersioningRepository.updateIsReservedTrue(timeSlot);
    if (rows == 0) {
      throw new RuntimeException("Optimistic reservation failed");
    }
  }
  Reservation reservation = new Reservation(null, startTime, endTime, 10000);
  reservationRepository.save(reservation);

  return reservation;
}
  • 버저닝 값이 같으면 Where 조건으로 인해 row가 수정되지 않고, 애플리케이션에서 사용자 설정 예외를 발생시킨다. 4개의 슬롯을 100개의 쓰레드로 예약해 보자. 이미 눈치챘을 수도 있지만, 위 코드는 제대로 병행 제어를 할 수 없다.
@Test
@DisplayName("낙관적 락을 사용할 때 조회한 슬롯들을 한 번에 수정하지 않으면 병행 제어가 되지 않는다.")
void createReservationOptimisticFailed() throws InterruptedException {

  List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);

  ExecutorService executor = Executors.newFixedThreadPool(100);

  List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());

  for (int i = 0; i < 100; ++i) {
    executor.execute(
        () -> {
          try {
            Reservation reservation = reservationService.createReservationOptimistic(timeSlotIds);
            reservations.add(reservation);
            System.out.println("ThreadInfo: " + Thread.currentThread());
          } catch (Exception e) {
            System.err.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
          }
        });
  }

  executor.shutdown();
  executor.awaitTermination(5, TimeUnit.SECONDS);

  System.out.println("reservations = " + reservations);
  Assertions.assertThat(reservations.size()).isEqualTo(1);
}
  • 계속해서 충돌이 발생하면 예약이 0개가 되거나, 1개가 되어야 한다. 하지만 결과는 예상과 달리 예약이 10개가 생성된다. 이유는 4개의 슬롯을 부분적으로 업데이트하는 과정에서 여러 쓰레드가 수정 전의 상태를 보고 값을 덮어씌우는 현상이 발생하기 때문이다. 아래와 같이 한 번에 업데이트 해야 한다.
  <update id="bulkUpdateIsReservedTrue">
    UPDATE time_slots_versioning
    SET is_reserved = TRUE, version = version + 1
    WHERE id IN
    <foreach item="id" collection="list" open="(" separator="," close=")">
      #{id}
    </foreach>
    AND is_reserved = FALSE
  </update>
  @Transactional
  public Reservation createReservationOptimisticBulk(List<Long> timeSlotIds) {

    List<TimeSlotVersioning> timeSlots = new ArrayList<>();
    LocalDateTime startTime = null;
    LocalDateTime endTime = null;
    for (var timeSlotId : timeSlotIds) {
      TimeSlotVersioning timeSlot = timeSlotVersioningRepository
        .findById(timeSlotId)
        .orElseThrow(
          () -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));
      timeSlots.add(timeSlot);
      startTime = getStartTime(startTime, timeSlot.getStartTime());
      endTime = getEndTime(endTime, timeSlot.getEndTime());
    }

    int rowsUpdated = timeSlotVersioningRepository.bulkUpdateIsReservedTrue(timeSlotIds);
    if (rowsUpdated < timeSlotIds.size()) {
      throw new RuntimeException("Some TimeSlots failed to reserve due to optimistic locking");
    }

    Reservation reservation = new Reservation(null, startTime, endTime, 10000);
    reservationRepository.save(reservation);

    return reservation;
  }
  • 위 코드를 100, 1000개의 쓰레드로 테스트해도 여러 개의 예약이 생성되지 않는 걸 확인할 수 있다.

이후 설명 할 비관적 쓰기 락 (Select For Update)에서는 조회와 동시에 락을 걸기 때문에 다른 쓰레드에서 조회 및 수정이 불가능하다. 이 경우 하나씩 업데이트해도 경쟁 조건이 발생하지 않는다.

비관적 락 (Pessimistic Lock)

  • 비관적 락은 이러한 동시성 문제를 사전에 해결하기 위해 Lock을 걸어 다른 트랜잭션의 접근을 차단하는 방식이다. 충돌이 빈번하게 발생하는 상황에서 락을 획득한 쓰레드만 임계 영역에 접근할 수 있기에 효율적으로 동작한다.
  • 크게 비관적 쓰기 락과 비관적 읽기 락으로 구분한다.

1.비관적 쓰기 락(Pessimisic Write Lock)

  • Select For Update 명령어를 통해 조회 시 비관적 쓰기 락을 건다. 다른 트랜잭션은 해당 데이터에 대해 읽기 및 수정을 하지 못한다. 강력한 잠금 방식이며, 충돌이 빈번하지 않은 상황에서는 성능상 오버헤드가 크다. 4개의 슬롯을 조회하며 비관적 쓰기 락을 걸어보자.
<select id="findByIdsForUpdate" resultType="com.juny.locktest.TimeSlot">
  SELECT
    id,
    start_time AS startTime,
    end_time AS endTime,
    is_reserved AS isReserved,
    price
  FROM time_slots
  WHERE id IN
  <foreach item="id" collection="list" open="(" separator="," close=")">
    #{id}
  </foreach>
  FOR UPDATE
</select>
@Transactional
public Reservation createReservationPessimisticWriteLock(List<Long> timeSlotIds)
  throws InterruptedException {

  List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForUpdate(timeSlotIds);

  for (var timeSlot : timeSlots) {
    if (timeSlot.getIsReserved()) {
      throw new RuntimeException("TimeSlot is reserved");
    }
  }

  LocalDateTime startTime = null;
  LocalDateTime endTime = null;
  for (TimeSlot timeSlot : timeSlots) {
    startTime = getStartTime(startTime, timeSlot.getStartTime());
    endTime = getEndTime(endTime, timeSlot.getEndTime());

    timeSlot.setIsReserved(true);
    timeSlotRepository.updateIsReservedTrue(timeSlot);
  }
  Reservation reservation = new Reservation(null, startTime, endTime, 10000);
  reservationRepository.save(reservation);

  return reservation;
}
  • 100개, 1000개의 쓰레드를 실행해도 하나의 예약만 성공하는 것을 볼 수 있다.
@Test
void createReservationPessimisticWriteLock() throws InterruptedException {
  List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);

  ExecutorService executor = Executors.newFixedThreadPool(100);

  List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());

  for (int i = 0 ; i < 100; ++i) {
    executor.execute(
      () -> {
        try {
          Reservation reservation =
            reservationService.createReservationPessimisticWriteLock(timeSlotIds);
          reservations.add(reservation);
        } catch (Exception e) {
          System.err.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
        }
      });
  }
  executor.shutdown();
  executor.awaitTermination(5, TimeUnit.SECONDS);

  Assertions.assertThat(reservations.size()).isEqualTo(1);
}
  • 여기서 또 하나 중요한 것은 Select For Update가 호출되었을 때 다른 쓰레드가 대기하는지 확인하는 것이다. 아래처럼 출력문을 작성하고 2개의 쓰레드를 실행해보자.
@Transactional
public Reservation createReservationPessimisticWriteLock(List<Long> timeSlotIds)
  throws InterruptedException {
  
  System.out.println("비관적 쓰기 락 조회 전" + Thread.currentThread().getName());
  List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForUpdate(timeSlotIds);
  System.out.println("비관적 쓰기 락 조회 후" + Thread.currentThread().getName());
  Thread.sleep(3000);
  
  ... 생략
}
  • 로그로 실행 순서를 파악해보면, 비관적 쓰기 락이 걸리면 다른 쓰레드는 대기 중인 것을 확인할 수 있다.
1. 비관적 쓰기 락 조회 전pool-1-thread-1
2. 비관적 쓰기 락 조회 전pool-1-thread-2
3. thread-1 SELECT FOR UPDATE (쓰레드 2는 대기 중)
4. thread-1 개별 UPDATE 쿼리
5. thread-2 SELECT FOR UPDATE

2. 비관적 읽기 락(Pessimisic Read Lock)

  • Select For Share, 비관적 읽기 락은 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 락을 거는 방식이다.
  • 예약 생성 메서드(슬롯을 조회 및 수정하고, 예약을 생성함)에서 슬롯을 읽어올 때 비관적 읽기 락을 적용하면 어떻게 될까?
  <select id="findByIdsForShare" resultType="com.juny.locktest.TimeSlot">
    SELECT
    id,
    start_time AS startTime,
    end_time AS endTime,
    is_reserved AS isReserved,
    price
    FROM time_slots
    WHERE id IN
    <foreach item="id" collection="list" open="(" separator="," close=")">
      #{id}
    </foreach>
    FOR SHARE
  </select>
  @Transactional
  public Reservation createReservationPessimisticReadLock(List<Long> timeSlotIds)
    throws InterruptedException {


    List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForShare(timeSlotIds);

    for (var timeSlot : timeSlots) {
      if (timeSlot.getIsReserved()) {
        throw new RuntimeException("TimeSlot is reserved");
      }
    }

    LocalDateTime startTime = null;
    LocalDateTime endTime = null;
    for (TimeSlot timeSlot : timeSlots) {
      startTime = getStartTime(startTime, timeSlot.getStartTime());
      endTime = getEndTime(endTime, timeSlot.getEndTime());

      timeSlot.setIsReserved(true);
      timeSlotRepository.updateIsReservedTrue(timeSlot);
    }
    Reservation reservation = new Reservation(null, startTime, endTime, 10000);
    reservationRepository.save(reservation);

    return reservation;
  }
  • 위와 같이 조회를 하고, 수정하는 쿼리에서 비관적 읽기 락을 사용하면 데드락이 발생한다.
Exception in thread "pool-1-thread-2" org.springframework.dao.DeadlockLoserDataAccessException: 
### Error updating database.  Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
  • 동작 과정을 살펴보면 쓰레드 1이 쓰기 락(배타적 락)을 가지고 있을 때 쓰레드 2가 배타적 락을 가지려고 하면 데드락이 발생한다.
1. Thread-1 슬롯 1,2,3,4 비관적 읽기 락
2. Thread-2 슬롯 1,2,3,4 비관적 읽기 락
3. Thread-1 슬롯 1,2,3,4 수정 (쓰기 락)
4. Thread-2 슬롯 1 수정하려고 할 때 DeadLock
  • 이게 왜 데드락일까? Thread-1과 Thread2는 슬롯 1,2,3,4에 대해 공유 락(S)을 설정하고, 쓰레드 1이 수정할 때 쓰기 락(EX)은 쓰레드 2의 공유 락(S)을 해제하길 원하고, Thread-2가 슬롯 1을 수정(EX)하려고 할 때 Thread-1의 읽기 락(s)을 해제하길 원하니, 자원을 가진채 서로의 자원을 원하는 상황(Hold And Wait)이라 데드락이 발생한다.

  • 이처럼 비관적 읽기 락을 사용하면, 데이터를 읽는 시점에 다른 트랜잭션이 해당 데이터를 수정하는 것을 막아주는 효과가 있다.

참고 자료

profile
꾸준하게

0개의 댓글