JPA 트랜잭션 격리 수준과 낙관적 락 동작을 테스트해보자.

kindof

·

2023. 10. 15. 14:25

이전에 작성한 글에서는 트랜잭션이 보장해야 할 ACID 개념과 MySQL에서의 트랜잭션 격리 수준에 대해 살펴보고 직접 테스트해봄으로써 각 격리수준에서 어떤 문제가 발생할 수 있는지 살펴봤습니다.

 

 

MySQL의 트랜잭션 격리 수준 실습해보기

트랜잭션은 ACID라는 아래 네 가지 조건을 만족해야 합니다. 원자성(Atomicity) : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하든, 모두 실패해야 한다. 일관성(Consistency)

studyandwrite.tistory.com

위 글에서 이야기했던 내용을 간단하게 요약해보면 아래와 같이 정리할 수 있을 것 같은데요.

 

[1] READ UNCOMMITTED 격리 수준에서는 커밋하지 않은 데이터를 읽을 수 있어서 DIRTY READ 라는 문제가 발생할 수 있었고,

 

[2] READ COMMITTED 방식은 커밋한 데이터만 읽을 수 있다. DIRTY READ 문제는 없지만 같은 트랜잭션 내에서 SELECT 쿼리의 결과가 달라질 수 있는 NON-REPEATABLE READ 문제가 있다.

 

[3] 그리고 REPEATABLE READ 방식에서는 MVCC(Multi Version Concurrency Control, 트랜잭션이 롤백될 가능성에 대비해 변경되기 전 레코드를 Undo 공간에 백업하고 실제 레코드 값을 변경)를 위해 Undo 영역에 있는 데이터를 이용해 동일 트랜잭션 내에서는 동일한 결과를 보여줄 수 있도록 보장할 수 있다.

 

[4] 그래서 MySQL InnoDB 엔진에서는 해당 격리 수준을 기본적으로 채택한다.

 

[5] SERIALIZABLE 격리 수준은 가장 높은 격리 수준으로 SELECT 쿼리에서 LOCK을 걸기 때문에 PHANTOM READ가 발생하지 않지만 성능 문제가 있다.

 


 

이번 글에서는 JPA의 영속성 컨텍스트를 활용할 때는 어떤 단계의 트랜잭션의 격리 수준을 채택하는지, 그리고 해당 트랜잭션 격리 수준을 사용할 때 발생할 수 있는 문제와 그 해결 방법인 낙관적 락, 비관적 락에 대해 살펴보려고 합니다.

 

 

1. JPA 트랜잭션 격리 수준

Spring 트랜잭션에서 기본적인 격리 수준은 'DEFAULT' 입니다. 즉, 스프링 트랜잭션에서 격리 수준은 현재 사용하고 있는 RDBMS가 채택하는 기본 트랜잭션 격리 수준을 따라간다는 뜻인데요.

@Transactional > Isolation
Isolation Enum Class

 

예를 들어, MySQL은 REPEATABLE READ를 기본으로 채택하고 Postgres는 READ_COMMITTED 격리 수준을 기본으로 채택하기 때문에 MySQL을 사용하고 있다면 스프링 트랜잭션의 기본 격리 수준은 REPEATABLE READ가 된다는 뜻입니다.

 

그런데, JPA의 영속성 컨텍스트(1차 캐시)를 활용하면 트랜잭션 격리 수준이 READ COMMITTED임에도 불구하고 애플리케이션 내에서 REPEATABLE READ가 가능한데요.

 

이것이 가능한 이유는 아래 세 가지 사실을 바탕으로 이해할 수 있습니다.

 

[1] 영속성 컨텍스트는 내부에 캐시를 가지고 있는데 이것을 1차 캐시라 합니다. em.find()를 호출하면 1차 캐시에서 식별자 값으로 엔티티를 찾고, 해당 엔티티가 없을 때만 DB를 조회하게 됩니다.

 

[2] 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용합니다. 즉, 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다는 뜻입니다. 따라서, 같은 트랜잭션 안에서는 같은 영속성 컨텍스트에 접근합니다.

쓰레드와 영속성 컨텍스트 - [자바 ORM 표준 JPA 프로그래밍]

 

[3] JPA에서 트랜잭션을 커밋하면 EntityManager는 영속성 컨텍스트를 플러시합니다. 플러시는 영속성 컨텍스트의 변경 내용을 DB에 저장하는 작업인데 이 때, 등록, 수정, 삭제한 엔티티를 DB에 반영합니다.

 

즉, "A 트랜잭션에서 최초 데이터를 조회하는 순간 해당 데이터는 영속성 컨텍스트에 캐시로 저장되고 트랜잭션 B에서 변경한 내용은 다른 영속성 컨텍스트를 사용하여 데이터를 캐시에 넣어둔 뒤 DB에 플러시한다. A 트랜잭션에서 다시 데이터를 조회해도 B 트랜잭션과는 다른  자신의 영속성 컨텍스트에서 데이터를 조회하기 때문에 REPEATABLE READ가 가능하다"는 것입니다.

 

하지만 결국 REPEATABLE READ 격리 수준은 동시에 여러 트랜잭션이 있을 때 동일한 데이터를 읽을 수는 있도록 하지만, 특정 데이터에 대한 동시 변경에 따른 충돌이 발생할 때 이를 해결할 수 있는 전략을 제공하는 것은 아닙니다. 

 

그래서 JPA에서는 이보다 세밀하고 높은 수준의 격리 수준이 필요할 때 낙관적 락과 비관적 락 개념을 사용합니다.

 

 

2. 낙관적 락(Optimistic Lock)

낙관적 락이란 트랜잭션 대부분이 서로 충돌하지 않는다는 가정하는 방법으로 JPA가 제공하는 Version 관리 기능을 사용합니다.

@Version

JPA에서는 엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트를 플러시하는데, 이 때 버전을 사용하는 엔티티라면 UPDATE 쿼리에 버전 관련 정보를 추가하게 됩니다. 

 

아래 코드를 보겠습니다.

 

[Student.java]

@Entity
@Getter
public class Student {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "email")
    private String email;

    @Version
    private Integer version;


    public void setEmail(String email) {
        this.email = email;
    }
}

학생 클래스에는 @Version 어노테이션으로 관리되는 필드가 하나 있는데요.

 

이 때, 학생의 이메일을 변경하는 트랜잭션이 실행되면 아래와 같이 version에 대한 정보가 쿼리에 추가적으로 포함되는 것을 볼 수 있습니다.

@Test
void version_test() {
    Student student = new Student();
    student.setEmail("before@naver.com");
    entityManager.persist(student);
    entityManager.flush();

    Student student1 = entityManager.find(Student.class, 1L);
    student1.setEmail("after@naver.com);");
    entityManager.persist(student1);
    entityManager.flush();
}

Version을 사용하는 엔티티의 update 쿼리

 

그리고 Version 속성은 하나의 엔티티 클래스에 하나만 존재하여 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외를 발생시킵니다.

 

이를 통해 A 트랜잭션이 조회한 데이터를 수정하고 있을 때 B 트랜잭션에서 해당 엔티티를 수정하고 커밋하면 다른 버전으로 인해 A 트랜잭션의 커밋 시점에 예외가 발생하게 되죠.

 

아래 테스트 코드를 통해 확인해보겠습니다.

 

[StudentService.java]

@Service
@RequiredArgsConstructor
public class StudentService {

    private final StudentRepository studentRepository;

    @Transactional
    public void changeStudentEmail(Long studentId, String email) {
        Student student = studentRepository.findById(studentId).orElseThrow(IllegalStateException::new);
        student.setEmail(email);
    }
}

 

[TestCode]

@Test
public void update_an_entity_at_same_time_throws_optimistic_lock_exception() {
    Student studentA = new Student();
    studentA.setEmail("studentA@example.com");
    studentRepository.save(studentA);

    // A 트랜잭션에서 학생 정보 수정
    CompletableFuture<Void> futureA = CompletableFuture.runAsync(() -> {
        studentService.changeStudentEmail(studentA.getId(), "AAA@example.com");
    });

    // B 트랜잭션에서 학생 정보 수정
    CompletableFuture<Void> futureB = CompletableFuture.runAsync(() -> {
        studentService.changeStudentEmail(studentA.getId(), "BBB@example.com");
    });

    // 두 CompletableFuture를 조합하고 완료될 때까지 대기
    CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(futureA, futureB);

    Exception result = null;

    try {
        combinedFuture.get(); // 모든 작업이 완료될 때까지 대기
    } catch (ExecutionException e) {
        result = (Exception) e.getCause();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    assertTrue(result instanceof OptimisticLockingFailureException);
}

같은 Student 엔티티에 대해 서로 다른 트랜잭션에서 값을 변경하니, OptimisticLockingFailureException이 발생하여 테스트 코드가 성공하는 것을 확인할 수 있습니다.

 

다만, 위 테스트에서 알 수 있듯이 낙관적 락은 트랜잭션을 커밋하는 시점이 되서야 충돌이 발생했음을 알 수 있습니다.

 

@Version + LockModeType.OPTIMISTIC

JPA가 제공하는 여러 락 옵션 중에는 OPTIMISTIC 타입이 있는데요.

 

위에서 설명한 @Version을 통한 낙관적 락은 엔티티의 수정 시점에 버전을 체크하지만 @Version을 사용하면서 OPTIMISTIC 타입의 락 옵션을 사용하면 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증합니다.

 

아래 테스트 코드를 보겠습니다.

@Test
void version_with_optimistic_lock_test() {
    Student studentA = new Student();
    studentA.setEmail("studentA@example.com");
    studentRepository.save(studentA);

    // A 트랜잭션에서 학생 조회
    EntityManager entityManager = emf.createEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    Student student = entityManager.find(Student.class, studentA.getId(), LockModeType.OPTIMISTIC);

    // B 트랜잭션에서 학생 정보 수정
    studentService.changeStudentEmail(studentA.getId(), "AAA@example.com");

    // A 트랜잭션 커밋 시점에 예외 발생
    try {
        transaction.commit();
    } catch (RollbackException e) {
        Throwable cause = e.getCause();
        Assertions.assertTrue(cause instanceof OptimisticLockException);
    }
}

해당 테스트 코드를 실행하면 마지막에 OptimisticLockException이 발생하면서 테스트가 성공하게 됩니다.

 

이는 처음 트랜잭션에서 OPTIMISTIC 락 타입으로 엔티티를 조회한 뒤 다른 트랜잭션에서 엔티티를 수정하면 조회 트랜잭션의 커밋 시점에서 예외가 발생하기 때문입니다.

 

즉, @Version + LockModeType.OPTIMISTIC을 사용한 낙관적 락에서는 DIRTY READ, NON-REPEATABLE READ가 방지된다는 것을 알 수 있습니다.

 

단, 이 설명과 서두에서 설명한 "JPA 영속성 컨텍스트를 활용했을 때 REPEATABLE READ 수준의 트랜잭션 격리 수준이 제공될 수 있다"라는 것과는 별개의 이야기입니다. @Version, LockModeType을 사용한다는 것은 락을 통해 조금 더 엄격하게 트랜잭션의 동작을 제한하여 데이터의 정합성을 유지한다는 것입니다.

 

 

@Version + LockModeType.OPTIMISTIC_FORCE_INCREMENT

@Version과 OPTIMISTIC_FOR_INCREMENT 락 타입을 같이 사용하면 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때마다 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킵니다.

 

아래 테스트 코드에서는 학생 엔티티 하나를 생성한 뒤 엔티티를 조회하기만 하고 트랜잭션을 커밋합니다.

@Test
void version_with_optimistic_force_increment_lock_test() {
    Student studentA = new Student();
    studentA.setEmail("studentA@example.com");
    studentRepository.save(studentA);

    // A 트랜잭션에서 학생 조회
    EntityManager entityManager = emf.createEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    Student student = entityManager.find(Student.class, studentA.getId(), LockModeType.OPTIMISTIC_FORCE_INCREMENT);
    transaction.commit();

    Assertions.assertEquals(1, student.getVersion());
}

 마지막 결과에서 해당 엔티티의 버전값을 조회해보면 0이 아닌 1이라는 것을 확인할 수 있습니다.

테스트 코드 실행 결과

그리고 테스트 코드를 실행했을 때 마지막 쿼리 로그에서 엔티티의 버전 정보가 업데이트된다는 것도 확인해볼 수 있습니다.

 

위 테스트를 보면 OPTIMISTIC_FORCE_INCREMENT 타입은 꽤 강력하게 트랜잭션 간의 동작을 제한하는 것을 알 수 있는데요.

 

어떤 경우에 해당 타입의 락을 사용하는지 아래 예제를 보면 조금 더 와닿을 것 같습니다.

 

먼저, 위에서 사용했던 Student 엔티티 클래스를 조금 수정하고 Student 엔티티와 연관관계를 갖는 Club 엔티티 클래스를 생성하겠습니다.

 

[Student.java]

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Student {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "email")
    private String email;

    @Version
    private Integer version;

    @ManyToOne
    private Major major;

    public void setEmail(String email) {
        this.email = email;
    }
}

 

[Major.java]

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
public class Major {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    private String name;

    @Version
    private Integer version;

    public void setName(String name) {
        this.name = name;
    }
}

 

이 때, Lock 옵션없이 아래 작업을 수행해보겠습니다.

  • [1] Major를 갖는 Student 엔티티를 생성한다.
  • [2] Major 엔티티를 수정한다.
  • [3] Student 엔티티를 수정한다.

 

[TestCode]

@Test
void no_optimistic_force_increment_test() throws InterruptedException {
    ExecutorService es = Executors.newFixedThreadPool(2);
    try {
        Major major = Major.builder().name("Math").build();
        EntityManager em = emf.createEntityManager();
        em.persist(major);

        Student student = Student.builder().email("AAA@example.com").major(major).build();
        em.getTransaction().begin();
        em.persist(student);
        System.out.println("-- persisting student --");
        em.getTransaction().commit();
        em.close();
        System.out.println("Student persisted: " + student);

        es.execute(() -> {
            EntityManager em2 = emf.createEntityManager();
            
            em2.getTransaction().begin();
            Student student2 = em2.find(Student.class, 1L);
            student2.setEmail("BBB@example.com");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("-- updating student --");
            em2.getTransaction().commit();
            em2.close();
            System.out.println("Student updated 1: " + student2);
        });

        es.execute(() -> {
            EntityManager em3 = emf.createEntityManager();
            
            em3.getTransaction().begin();
            Major m = em3.find(Major.class, 1);
            m.setName("Science");
            System.out.println("-- updating department --");
            em3.getTransaction().commit();
            em3.close();
            System.out.println("Major updated: " + m);
        });
        es.shutdown();
        es.awaitTermination(10, TimeUnit.SECONDS);
    } finally {
        emf.close();
    }
}

 

테스트 코드의 수행 결과 로그를 보면 아래와 같습니다.

-- persisting student --
Student persisted: Student(id=1, email=AAA@example.com, version=1, major=Major(id=1, name=Math, version=0))

-- updating major --
Major updated: Major(id=1, name=Science, version=1)

-- updating student --
Student updated 1: Student(id=1, email=BBB@example.com, version=2, major=Major(id=1, name=Math, version=0))

문제가 보이시나요?

 

학생과 연관있는 Major 정보가 업데이트되었음에도 최종적인 학생의 정보에는 Major 정보가 수정되지 않았습니다.

 

이러한 상황을 방지하기 위해 OPTIMISTIC_FORCE_INCREMENT 타입의 락을 사용할 수 있습니다.

 

아래와 학생을 조회하는 부분에서 LockModeType.OPTIMISTIC_FORCE_INCREMENT 락 타입을 추가하여 테스트 코드를 수행해보겠습니다.

테스트 코드 수정
테스트 코드 실행 결과

테스트 결과, 학생 정보를 업데이트하는 트랜잭션의 커밋 시점에서 RollbackException이 발생했다는 것을 알 수 있습니다.

 

즉, 이처럼 OPTIMISTIC_FORCE_INCREMENT 락 타입은 논리적인 단위로 연관이 있는 엔티티를 관리할 때 사용할 수 있다는 것을 알 수 있습니다. 학생과 관련된 Major 정보의 수정이 학생 엔티티의 수정에서 제약이 되는 것이죠.

 

 

3. 정리 / Reference

이번 글에서는 JPA를 사용할 때 트랜잭션 격리 수준에 대해 이해해보고, 조금 더 구체적인 제약이 필요할 때 JPA에서 사용할 수 있는 낙관적 락에 대해 살펴봤습니다.

 

JPA는 영속성 컨텍스트의 1차 캐시를 이용하여 REPEATABLE READ 트랜잭션 격리 수준을 제공할 수 있습니다. 

 

단, 트랜잭션 간의 데이터 정합성을 위해서 조금 더 강한 제약을 둘 수 있는데 이 때 사용하는 개념이 낙관적 락입니다.

 

낙관적 락은 트랜잭션 대부분이 서로 충돌하지 않는다는 전제 하에 사용하는데, 이는 낙관적 락 자체가 트랜잭션의 커밋 시점에 문제 상황을 인지할 수 있기 때문입니다.

 

기본적으로 아무런 Lock 옵션없이 @Version만 사용하면 최초 변경에 대한 커밋만 인정하게 되고, OPTIMISTIC 락 타입을 함께 사용하면 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 확인하여 REPEATABLE READ를 가능하게 했습니다.

 

마지막으로 OPTIMISTIC_FORCE_INCREMENT 락 타입을 같이 사용하면 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시키는데, 이를 통해 연관된 엔티티의 수정을 확인하여 논리적인 엔티티 단위로 데이터 정합성을 유지할 수 있게됨을 테스트해봤습니다.

 

감사합니다.

 

 

 

[Spring] DB Isolation Level과 Spring Default Isolation 알아보기

Transactional Isolation(트랜잭션 격리 레벨) 옵션 설명 READ_UNCOMMITTED 트랜잭션에서 commit 되지 않은 다른 트랜잭션에서 읽는 것을 허용한다. Dirty Read가 발생한다. READ_COMMITTED 트랜잭션에서 commit 되어 확

devlog-wjdrbs96.tistory.com

 

JPA + Hibernate - When to use OPTIMISTIC_FORCE_INCREMENT lock mode?

LockModeType.OPTIMISTIC_FORCE_INCREMENT can be used in the scenarios where we want to lock another entity while updating the current entity. The the two entities can be or cannot be directly related, but they might be dependent on each other from business

www.logicbig.com