JPA에서의 Lock과 Isolation 테스트 및 회고

한재희·2021년 9월 29일
0
post-thumbnail

준비

발단

지인에게 데이터베이스에서의 DeadLock을 경험해본적이 있냐는 질문을 받았다. 없었다. 지금까지 만들어왔던 모든 기능들에서 데드락에 대한 처리를 한 적이 없었다..
그래서 가상의 숫자 카운팅 어플리케이션을 통해 데드락이 일어날 상황을 시뮬레이션해보고 테스트해봤다.

환경

M1 MAC, JAVA 11, MySQL

시작

요구사항

들어온 요청만큼 숫자가 반드시 1이 증가해야하는 어플리케이션이 있다. 요청은 많은 사람들이 동시에 지속적으로 요청을 보낸다고 가정한다.

이론

1. 무방비

아무 처리를 하지 않았을 땐, 동시 요청이 들어올 경우 MySQL의 기본 Isolation인 REPEATABLE READ로 적용된다.
따라서, 트랜잭션이 시작될때 읽은 값이 끝날때 까지 유지되므로, 동시에 N개의 요청이 들어왔을 때의 숫자가 0이라면, 모두 1로 업데이트 될 것이다.

2. Lock - PESSIMISTIC WRITE (비관적 락, X Lock)

요구사항에 맞추기 위해 데드락이 자주 발생할 것이라고 생각하고, Select 시 배타적 락(X Lock)을 걸어 블락을 걸고, 순서를 보장한다.
=> 공유 락(S Lock)이 안되는 이유 : 한 트랜잭션이 공유 락을 걸었을 경우, 다른 트랜잭션 또한 공유 락을 걸어 둘 다 읽을 수는 있다.
하지만, 요구사항처럼 데이터를 수정해야하는 경우, 공유 락이 걸린 데이터는 수정할 수 없기 때문에 배타적 락을 걸어야 하는데 공유 락과 배타적 락은 함께 사용할 수 없어 데드락이 발생한다.

3. Isolation - SERIALIZABLE

Serializable로 데드락을 유도하고, 발생 시 리트라이 한다.

어플리케이션 구성

  • NumberEntity
    엔티티는 구성되어 있지만, 하나의 데이터를 생성해 숫자를 증가시킨다. (ID가 1로 고정)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class NumberEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private Long count;

    @Builder
    private NumberEntity(int id, Long count) {
        this.id = id;
        this.count = count;
    }

    public static NumberEntity of(int id, Long count){
        return NumberEntity.builder()
                .id(id)
                .count(count)
                .build();
    }

    public Long increment(){
        return ++this.count;
    }

    public Long decrement(){
        return --this.count;
    }
}
  • NumberRepository
    각각 일반, Lock의 PESSIMISTIC_WRITE, Isolation의 Serializable을 적용한 같은 동작을 하는 find함수들
    (Isolation은 서비스 단에서 적용 예정)
@Repository
public interface NumberRepository extends JpaRepository<NumberEntity, Integer> {

    Optional<NumberEntity> findById(int id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select n from NumberEntity n where n.id = :id")
    Optional<NumberEntity> findByNumberIdLock(@Param("id") int id);

    @Query("select n from NumberEntity n where n.id = :id")
    Optional<NumberEntity> findByNumberIdIsolation(@Param("id") int id);
}
  • NumberService
    각각 이론 1,2,3에 해당하는 경우를 구현
@RequiredArgsConstructor
@Service
@Slf4j
public class NumberService {

    private final NumberRepository numberRepository;

    private final int numberId = 1;

    @Transactional
    public void incrementNumberNormal(){
        NumberEntity numberEntity = numberRepository.findById(numberId).orElseThrow(NoSuchElementException::new);
        System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment() + " " + numberEntity);
    }

    @Transactional
    public void incrementNumberLock() {
        NumberEntity numberEntity = numberRepository.findByNumberIdLock(numberId).orElseThrow(NoSuchElementException::new);
        System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment() + " " + numberEntity);
    }

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void incrementNumberSerializable(){
        NumberEntity numberEntity = numberRepository.findByNumberIdIsolation(numberId).orElseThrow(NoSuchElementException::new);
        System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment());
    }
}

이론 별 테스트

테스트는 @SpringBootTest를 활용해 어플리케이션 구동환경과 맞춘상태로 진행한다. 또한 동시 요청 테스트를 위해 쓰레드 100개를 활용해 각각 진행하며, 모든 테스트의 숫자는 0에서 시작한다.

@SpringBootTest
public class NumberServiceTest {

    private static final int COUNT = 100;
    private static final ExecutorService service = Executors.newFixedThreadPool(COUNT);

    @Autowired
    private NumberService numberService;
}

1. 무방비

@Test
@DisplayName("요청 수 만큼 숫자 증가 (Normal)")
void incrementNumber_normal() throws InterruptedException {
    // given
    CountDownLatch latch = new CountDownLatch(COUNT);
    Long before = numberService.getNumber();

    // when
    for (int i = 0; i < COUNT; ++i) {
        service.execute(() -> {
            numberService.incrementNumberNormal();
            latch.countDown();
        });
    }
    // then
    latch.await();
    assertEquals(before + COUNT, numberService.getNumber());
}

결과

theory1_test_result

이론과 같이 요청은 100번 들어왔지만, 실제 업데이트된 숫자는 14임을 볼 수 있다. 모든 요청이 숫자가 증가됨을 보장할 수 없다는 것을 알 수 있다.

2. Lock - PESSIMISTIC WRITE (비관적 락, X Lock)

@Test
@DisplayName("요청 수 만큼 숫자 증가 (Lock - PESSIMISTIC_WRITE)")
void incrementNumber_concurrency() throws InterruptedException {
    // given
    CountDownLatch latch = new CountDownLatch(COUNT);
    Long before = numberService.getNumber();
    // when
    for (int i = 0; i < COUNT; ++i) {
        service.execute(() -> {
            numberService.incrementNumberLock();
            latch.countDown();
        });
    }
    // then
    latch.await();
    assertEquals(before + COUNT, numberService.getNumber());
}

theory2_test_result
이론과 같이 모든 트랜잭션이 블로킹에 의해 순서대로 처리되어 정상적으로 100 증가되었다.

3. Isolation - SERIALIZABLE

@Test
@DisplayName("요청 수만 큼 증가 (Isolation - SERIALIZABLE)")
void incrementNumber_Isolation() throws InterruptedException {
    // given
    CountDownLatch latch = new CountDownLatch(COUNT);
    Long before = numberService.getNumber();
    // when
    for (int i = 0; i < COUNT; ++i) {
        service.execute(() -> {
            numberService.incrementNumberSerializable();
            latch.countDown();
        });
    }
    // then
    latch.await();
    assertEquals(before + COUNT, numberService.getNumber());
}

theory3_test_result
데드락이 발생했다. 그런데, 데드락 이후에 리트라이를 해주더라도 요청이 지속되는 어플리케이션이라면 리트라이에도 성공한다는 보장이 없다. 상황에 따라 다르겠지만, 이 어플리케이션의 요구사항에서는 데드락자체를 발생시키는 것이 적합하지 않다는 생각이 든다.

이론 중 최종선택을 한다면, Lock을 선택할 것이다.
하지만, 자료들을 찾아보면서 Lock과 Isolation 모두 신중하게 적용해야한다는 내용들을 공통적으로 볼 수 있었다. 확실히 Lock에 의한 순서 보장이 장점일 수도 있지만, 요청이 많아진다면 언제까지 기다릴수도 없다고 생각된다. 특히나 서버 여러 개가 물려있어 요청에 요청을 무는 구조에서는 더더욱 그럴 것이다.
앞으로의 과제는 이 동시성과 일관성의 균형을 상황에 맞게 잘 결정하는 것이 될 것 같다.

profile
IT관련 된 것들은 가리지 않고 먹어요.

1개의 댓글

comment-user-thumbnail
2022년 7월 3일

감사합니다. 덕분에 많은부분 도움되었습니다!

답글 달기