[JPA] DB동시성 이슈와 비관적 락, 낙관적 락

이호석·2023년 7월 25일
1

오늘은 DB동시성 문제의 해결법과 JPA에서의 락에 대하여 알아볼 것이다.

락(LOCK)이란?

데이터베이스는 여러 사용자들이 같은 데이터를 동시에 접근 상황에서, 데이터의 무결성, 일관성을 지키기 위해 LOCK을 사용한다. 락은 트랜잭션의 순차성을 보장하며, 락을 한 데이터는 다른 트랜잭션이 동시에 접근할 수 없게 된다.

트랜잭션 격리 수준

트랜잭션 격리 수준이란 동시에 여러 트랜잭션을 처리할 때, 어떤 데이터를 다루는 트랜잭션에서 다른 트랜잭션에서 그 데이터를 변경하거나 조회하는 것을 허용할지 말지 결정하는 것이다.
격리 수준은 4가지로 나눌 수 있다.

READ UNCOMMITTED

  • 커밋하지 않은 데이터(다른 트랜잭션의 작업이 끝나지 않은 데이터)를 읽을 수 있다.
  • Dirty Read(바로 위 줄과 같은 내용)가 발생할 수 있다.
  • 정합성에 문제가 많은 격리 수준이다.

READ COMMITTED

  • 커밋한 데이터만 읽을 수 있다.
  • Dirty Read와 같은 현상은 발생하지 않는다.
  • 오라클 DBMS에서 표준으로 사용하고 있고 가장 많이 선택된다.
  • Nonrepeatable Read가 발생할 수 있다.

REPETABLE READ

  • 한번 조회한 데이터를 반복해서 조회해도 같은 데이터가 나온다.
  • Phantom Read(다른 트랜잭션의 변경작업에 의해 데이터가 보였다가 안 보였다가 하는 현상)가 발생할 수 있다.

SERIALIZABLE

  • 가장 엄격한 격리 수준
  • 모든 트랜잭션이 순차적으로 실행되며, 그만큼 성능이 떨어진다.

트랜잭션 격리 수준이 높아질수록 오버헤드가 커지고, 성능이 떨어지므로 상황에 맞게 잘 선택해야한다.

낙관적 락과 비관적 락

비관적 락

비관적 락은 트랜잭션끼리 충돌이 발생한다고 가정을 하고 락을 거는 방법이다. DBMS의 락 기능을 사용하고, 데이터 수정시 트랜잭션의 충돌 여부를 확인할 수 있다.

낙관적 락

트랜잭션이 충돌할 가능성이 매우 낮은 상황에 사용한다. DB가 제공하는 락 기능을 사용하지 않고 어플리케이션 레벨에서 자체적으로 락과 유사한 동작을 하도록한다. 조회할 때 락을 사용하지 않기 때문에 성능이 좋다(충돌이 발생하지 않는다고 가정할 경우).

테스트 상황 가정

@Entity
public class Room{
	...
    private int bedCount;
    ...
    public void increaseBed(){
        this.bedCount = this.bedCount+1;
    }
}
@Service
public class RoomServiceImpl implements RoomService {
    private final RoomRepository roomRepository;

    @Transactional
    public void increaseBedCount(long id){
        Room room = roomRepository.findById(id).orElseThrow();
        room.increaseBed();
    }
}
    @Test
    @DisplayName("동시성 실패 테스트")
    public void concurrencyFailTest() throws InterruptedException {
        int count = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            executorService.execute(() -> {
                try{
                    roomService.increaseBedCount(3L);
                }catch (Exception e){
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }
        latch.await();

        Room room = roomRepository.findById(3L).get();
        log.info("room.getBedCount() : " + room.getBedCount());
        Assertions.assertThat(room.getBedCount()).isNotEqualTo(count);
    }

위 테스트는 단순히 room의 bedcount를 1씩 100번 증가시키는 코드이다. 물론 동시성 테스트를 위해서 멀티쓰레드 환경을 구현했다.
for문의 횟수와 new CountDownLatch(count)에서 count값이 같아야한다. CountDownLatch는 설정한 값에 도달할 때까지, latch.await(); 이후의 코드들은 실행되지 않기 때문이다(잘못하면 무한 대기 상태가 될 수 있다).

여러번 시도해본 결과 별다른 설정을 하지 않는다면, lost update가 발생하는 것을 확인할 수 있다.

JPA에서 비관적 락

타입설명
PESSIMISTIC_READ다른 트랜잭션에서 쓰기, 삭제를 방지한다(읽기는 가능). DB 대부분은 제공하지 않아 WRITE로 동작
PESSIMISTIC_WRITE다른 트랜잭션에서 읽기, 쓰기, 삭제 방지
PESSIMISTIC_FORCE_INCREMENTWRITE와 유사하게 동작. 비관적 락이지만, 버전 정보를 강제로 증가시킨다.
public interface RoomRepository extends JpaRepository<Room, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select r from Room r where r.id = :id")
    Optional<Room> findByIdPessimisticLock(@Param("id") Long id);
}
@Service
public class RoomServiceImpl implements RoomService {
    private final RoomRepository roomRepository;

    @Transactional
    public void increaseBedCountPessimisticLock(long id){
        Room room = roomRepository.findByIdPessimisticLock(id).orElseThrow();
        room.increaseBed();
    }
}
	@Test
    @DisplayName("동시성 테스트 - 비관적락")
    public void concurrencyPessimisticLockTest() throws InterruptedException {
        int count = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            executorService.execute(() -> {
                try{
                    roomService.increaseBedCountPessimisticLock(3L);
                }catch (Exception e){
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }
        latch.await();

        Room room = roomRepository.findById(3L).get();
        log.info("room.getBedCount() : " + room.getBedCount());
        Assertions.assertThat(room.getBedCount()).isEqualTo(count);
    }

비관적 락을 사용하려면 select하려는 메소드에 @Lock(LockModeType.PESSIMISTIC_WRITE)을 추가해주면 된다. JPA에서 비관적 락을 사용하면 select 문에 for update 쿼리가 추가되는데, select한 row에 lock을 거는 방식이고 이로 인해 성능이 저하 될수 있다.

아무 설정 안할 때보다 100ms 정도 증가한 모습.

JPA에서 낙관적 락

JPA에서는 낙관적 락을 위해서 버전 관리 기능을 제공한다.

타입설명
NONE엔티티에 @Version이 있으면 기본으로 사용되는 옵션이다. 엔티티를 수정할 때 버전이 증가하며, 커밋할 때 조회시점의 버전과 다르면 예외 발생
OPTIMISTICNONE은 수정할 때만 버전을 올리지만, OPTIMISTIC은 조회만해도 버전을 올린다.
OPTIMISTIC_FORCE_INCREMENT낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다.
@Entity
public class Room{
	...
    
    @Version
    private Long version;
}
public interface RoomRepository extends JpaRepository<Room, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    @Query("select r from Room r where r.id = :id")
    Optional<Room> findByIdWithLock(@Param("id") Long id);
}
@Service
public class RoomServiceImpl implements RoomService {
    private final RoomRepository roomRepository;

    @Transactional
    public void increaseBedCountOptimisticLock(long id){
        Room room = roomRepository.findByIdWithLock(id).orElseThrow();
        room.increaseBed();
    }
}
	@Test
    @DisplayName("동시성 테스트 - 낙관적락")
    public void concurrencyOptimisticLockTest() throws InterruptedException {
        int count = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            executorService.execute(() -> {
                try{
                    roomService.increaseBedCountOptimisticLock(3L);
                }catch (Exception e){
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }
        latch.await();

        Room room = roomRepository.findById(3L).get();
        log.info("room.getBedCount() : " + room.getBedCount());
        Assertions.assertThat(room.getBedCount()).isEqualTo(count);
    }

낙관적 락은 락이 없기 때문에(!) 당연히 테스트 결과 실패가 뜨면서 ObjectOptimisticLockingFailureException이 발생했다. 이를 해결하기 위해서, Spring AOP를 이용하여 재시도 로직을 적용했다.

Spring AOP 적용

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
    int value() default 20;
}
@Log4j2
@Order(Ordered.LOWEST_PRECEDENCE - 1)	//@Transactional annotation보다 먼저 실행되어야하므로.
@Aspect
public class RetryAspect {

    @Around("@annotation(retry)")
    public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
        int maxRetry = retry.value();

        Exception exceptionHolder = null;
        for (int retryCount = 1; retryCount <= maxRetry; retryCount++) {
            try{
                return joinPoint.proceed();
            }catch (Exception e){
                log.error("[retry] try count ={}/{}", retryCount, maxRetry);
                exceptionHolder = e;
            }
        }
        throw exceptionHolder;
    }
}
@Service
public class RoomServiceImpl implements RoomService {
    private final RoomRepository roomRepository;

    @Retry
    @Transactional
    public void increaseBedCountOptimisticLock(long id){
        Room room = roomRepository.findByIdWithLock(id).orElseThrow();
        room.increaseBed();
    }
}

원래 retry 횟수가 20이 아니라 5였는데, 100개의 동시요청을 감당하려다보니 20개로 늘렸다. 앞서 설명한대로 낙관적 락은 충돌이 발생하는 상황이 거의 없을때 사용하기 때문에, 충돌로 인한 재시도 로직으로 인해, 테스트 시간은 훨씬 증가했다.

1개의 댓글

comment-user-thumbnail
2023년 9월 13일

잘 참고했습니다!
AOP 부분에 @Component가 빠져있네요! 참고 부탁드리겠습니다~

답글 달기