Data

Spring JPA Transactional과 Transaction Isolation Level 격리수준 실습

jw92 2024. 6. 21. 14:01

2022.11.04 - [Backend & Spring (스프링)] - Spring Data JPA와 Hibernate. 그리고 Persistence

 

Spring을 이용해 RDB를 다루면 JPA, Transactional과 DB 격리 수준에 대해서 많이 들어보았을 것이다.

다만, 각각에 대해서는 잘 들어봤더라도

서로 정확히 어떻게 동작하는지, 어떤 경우에 에러가 발생하는지에 대한 정보는 부족하다.

 

이 글에서는 특히 Transactional과 Database Isolation Level에 대한 관계를 실제 코드를 이용해서 실험해보고자 한다.

 

격리 수준에 대한 자세한 내용은 다음 링크 참고 - 2024.06.21 - [Data] - 트랜잭션 격리 수준 / Transaction Isolation Level

 

현재 DB의 격리 수준은 아래 명령어를 통해 확인할 수 있다.

(5.7 이하 버전에서는 transaction_isolation 대신  tx_isolation 사용)

SELECT @@GLOBAL.transaction_isolation;
SELECT @@SESSION.transaction_isolation;

 

참고로 MySQL의 기본 값은 REPEATABLE READ이다.

 

각 코드는 아래 github에서 확인할 수 있다.

https://github.com/jinwookkk/transactional

 

1. Transactional

간단한 @Transactional 예제를 보자.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test01_Basic.java

    private final SimpleRepository simpleRepository;

    public void exceptionAfterSave(SimpleDto simpleDto) {
        SimpleDto savedSimpleDto = simpleRepository.save(simpleDto);
        throw new IllegalArgumentException();
    }

    @Transactional
    public void exceptionAfterSaveWithTransactional(SimpleDto simpleDto) {
        SimpleDto savedSimpleDto = simpleRepository.save(simpleDto);
        throw new IllegalArgumentException();
    }

 

        // Basic Code without Transactional
        try {
            SimpleDto simpleDto = SimpleDto.builder().simpleData(1).build();
            transactionalService.exceptionAfterSave(simpleDto);
        } catch (Exception e) {

        }

        // exceptionAfterSave with Transactional
        try {
            SimpleDto simpleDto = SimpleDto.builder().simpleData(10).build();
            transactionalService.exceptionAfterSaveWithTransactional(simpleDto);
        } catch (Exception e) {

        }

 

위 코드를 실행한 경우 어떻게 될까?

Transactional에 의해 10인 값은 저장되지 않고, 1인 값만 저장되게 된다.

 

2. READ UNCOMMITTED

SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

우선 위 Query를 통해 격리 수준을 변경해준다.

 

프로덕션에서 사용되지는 않는 격리 수준이지만

해당 수준에서 궁금한 것은 @Transactional에서 값이 삭제되는 경우에도 다른 Transaction에서 진짜로 읽을 수 있을까?

(DIRTY READ)

 

코드는 다음과 같다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test02_ReadUncommitted.java

실제로 Exception 발생 전까지 값이 잠시 읽어진다..!

log로도 확인할 수 있다.

 

READ UNCOMMITTED의 무서움을 실제로 확인하였다..

 

3. READ COMMITTED

READ COMMITTED로 변경하여 READ UNCOMMITTED의 테스트 내용을 다시 한번 확인해보겠다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test03_ReadCommitted.java

 

다행히(당연히) 위에서 처럼 값이 읽어지는 시간은 없었다.

 

READ COMMITTED에서 문제가 되는 상황은 무엇일까?

NON-REPETABLE READ의 Consistency 이다.

한 Transaction 내에서 Update를 하지 않았다면 READ는 항상 같은 결과를 가져와야한다.

하지만 다른 Transaction에서 Update가 일어나고 이것이 COMMIT 되었다면, 해당 문제가 발생한다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test04_ReadCommitted_NonRepetableRead.java

 

코드는 위 링크 중 readCommitted() method이다.

이를 실행해 보았...... 오잉? NON-REPETABLE READ 문제가 발생하지 않고 값이 100으로 똑같다.

 

id로 조회해서 그런가?

simpleData 값으로 조회하도록 변경하였다.

 

readCommittedFindByValue() method이다.

 

예상한 대로 transacional 내부에서 조회되는 size가 변경되어

NON-REPETABLE READ 정합성(Consistey) 문제가 발생하였다.

 

findById는 왜 이 문제가 발생하지 않는 걸까?

@Transactional
public Void getTwiceByIdUsingEm(Integer id, Integer seconds) {
    System.out.println("simpleData before update on transaction 1: " + findByIdUsingEm(id));
    try {
        Thread.sleep(1000 * seconds);
    } catch (Exception e) {

    }
    System.out.println("simpleData after update on transaction 1: " + findByIdUsingEm(id));

    return null;
}

public SimpleDto findByIdUsingEm(Integer id) {
    return entityManager.createQuery("SELECT s FROM SimpleDto s WHERE s.id = :id", SimpleDto.class)
            .setParameter("id", id)
            .getSingleResult();
}

혹시 JPA의 caching 문제일까 entity manager를 직접이용하여 해봤으나

문제가 발생하지 않았다.

 

@Transactional
public Void getTwiceByIdUsingEm(Integer id, Integer seconds) {
    System.out.println("simpleData before update on transaction 1: " + findByIdUsingEm(id));
    try {
        Thread.sleep(1000 * seconds);
    } catch (Exception e) {

    }
    entityManager.clear(); // Clear 추가 !!
    System.out.println("simpleData after update on transaction 1: " + findByIdUsingEm(id));

    return null;
}

public SimpleDto findByIdUsingEm(Integer id) {
    return entityManager.createQuery("SELECT s FROM SimpleDto s WHERE s.id = :id", SimpleDto.class)
            .setParameter("id", id)
            .getSingleResult();
}

 

두 번쨰 쿼리 직전에 entity manager의 clear() 까지 추가하고 나서야

우리가 원하던(?) 문제를 얻을 수 있었다.

 

JPA에서 자체적으로 영속성 컨텍스트 내에서 관리하는게 있는 것으로 추측된다.

이 부분에 대해서는 나중에 포스트 해보도록 하겠다.

4. REPEATABLE READ

우선 READ COMMITED의 코드를 그대로 실행해보았다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test05_RepeatableRead.java

예상한대로 NON-REPETABLE READ 정합성 문제가 발생하지 않았다.

REPEATABLE READ에는 2가지 문제가 존재한다. 각각에 대해서 테스트해보도록 하자

 

4-1. UPDATE 부정합

한 Transaction 내에서 READ로 읽은 데이터가 UPDATE 시에는 변경되지 않는 경우

방법은 간단하다.

한 Transaction 내의 READ와  UPDATE 사이에 다른 Transaction에서 Update 해주는 것이다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test06_RepeatableRead_PhantomRead_Update.java

            final Integer simpleData = 100;
            SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
            simpleDto = transactionalService.save(simpleDto);
            int id = simpleDto.getId();
            
            // Transaction 1
            ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
            executorService.submit(() -> transactionalService.getAndUpdateSimpleData(id, 999));

            // Transaction 2
            transactionalService.updateSimpleData(simpleData, 777);
            Thread.sleep(1000 * 5);

            // Result
            transactionalService.findById(id);
            System.out.println("updated simpleData: " +
                    transactionalService.findById(id).getSimpleData());

 

결과는 예상대로 Transaction 1에 의한 Update가 발생하지 않았다.

4-2. Phantom READ

한 Transaction 내에서 여러 번 READ를 한 경우 없던 Data가 나타나는 경우. (DELETE는 영향 없음) 다른 Transaction의 Insert에만 영향을 받음.

 

한 Transaction 내의 READ와  UPDATE 사이에 다른 Transaction에서 Save 해준다.

            final Integer simpleData = 66;
            SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
            simpleDto = transactionalService.save(simpleDto);
            System.out.println("inserted id:" + simpleDto.getId());

            // Transaction 1
            ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
            executorService.submit(() -> transactionalService.getSimpleDtoTwiceBySimpleData(simpleData));

            Thread.sleep(1000);

            // Transaction 2
            SimpleDto simpleDto2 = SimpleDto.builder().simpleData(simpleData).build();
            System.out.println("inserted id:" + transactionalService.save(simpleDto2).getId());

 

결과는.. 

당연하게도 문제가 발생하지않는다. 왜냐하면 자신보다 낮은 Transaction의 결과만 조회하기 때문에 이후의 Transacition에서 추가된 Entity는 조회하지 않는다.

 

4-2-1. Phantom READ - Update

            {
                final Integer simpleData = 689;

                // Transaction 1
                ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
                executorService.submit(() -> transactionalService.getAllAndGetAllAndUpdateAndGetAllBySimpleData(simpleData, simpleData + 1));
                Thread.sleep(1000);

                // Transaction 2
                SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
                simpleDto = transactionalService.save(simpleDto);
                System.out.println("inserted id:" + simpleDto.getId());
            }
            
                @Transactional
    public Void getAllAndGetAllAndUpdateAndGetAllBySimpleData(Integer originSimpleData, Integer newSimpleData) throws Exception {
        System.out.println("Size of first query: " + simpleRepository.findAll().size());
        Thread.sleep(1000 * 3);
        System.out.println("Size of second query: " + simpleRepository.findAll().size());
        Thread.sleep(1000 * 5);
        System.out.println("Updated size: " + simpleRepository.updateBySimpleData(originSimpleData, newSimpleData));
        System.out.println("Size of fourth query: " + simpleRepository.findAll().size());

        return null;
    }

코드를 위처럼 변경한다.

예상하는 결과로는 나중에 생긴 Transaction에 의해 inserted됐으므로 update와 마지막 쿼리 모두 0건이어야 한다.

실제 결과를 보면 update와 select 모두 성공했음을 알 수 있다.

즉, read와 write에 대해서만 잠금이 적용되는 것 같다.

wikipedia를 확인해보면 "range-locks"이 관리되지 않아 Phantom Read가 발생할 수 있다고 한다.

entity는 0건인데 업데이트는 된다니.... 유령이다!!!

 

4-2-2. Range

Range를 이용한 예제이다.

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test07_RepeatableRead_PhantomRead_Range.java

BEGIN;
SELECT simple_data FROM simple_table WHERE simple_data > 17;
-- retrieve no result
    BEGIN;
    INSERT INTO simple_table VALUES (1, 20);
    COMMIT;
SELECT simple_data FROM simple_table WHERE simple_data > 17;
-- retrieve 20
COMMIT;

 

MySQL Query 시에 Phantom Read발생한다.

            final Integer simpleData = 666;
            SimpleDto simpleDto = SimpleDto.builder().simpleData(simpleData).build();
            simpleDto = transactionalService.save(simpleDto);
            System.out.println("inserted id:" + transactionalService.save(simpleDto).getId());

            // Transaction 1
            ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
            executorService.submit(() -> transactionalService.getSimpleDtoTwiceBySimpleDataGreaterThan(0));

            Thread.sleep(500);

            // Transaction 2
            SimpleDto simpleDto2 = SimpleDto.builder().simpleData(simpleData + 1).build();
            System.out.println("inserted id:" + transactionalService.save(simpleDto2).getId());

하지만 위와 같이 Spring JPA 코드를 사용하면 Phantom Read가 발생하지 않는다.

 

왜 그럴까.. JPA에서 또 다른 제어를 해주는걸까? 이것도 다음 기회에 포스팅해보겠다.

 

SERIALIZABLE

가장 엄격한 공유잠금이다.

Phantom Read 코드를 실행해보면 어떻게 될까?

https://github.com/jinwookkk/transactional/blob/main/src/test/java/com/wook/transactional/Test08_Serializable.java

insert는 0.01초 이하에 수행될 수 있음에도

10초가 걸리는 Transaction 1이 모두 수행된 뒤에 수행된다.

이런게 수십개만 쌓이게된다고 생각해도..

끔찍하다.

Transaction을 가장 완벽하게 고립할 수 있음에도 쓰지 않는 데는 이유가 있는 것 같다.