Optimistic Lock in QueryDSL

6 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

JPA(java persistence api)는 @Version 애너테이션으로 낙관적 락(optimistic lock) 기능을 제공합니다. spring-data-jpasave 메소드로 추가(insert), 업데이트(update) 기능을 제공하면서 내부에 버전 확인 로직이 있기 때문에 쉽게 낙관적 락 기능을 사용할 수 있습니다.

반면에 QueryDSL은 업데이트에 낙관적인 락 방식이 자동으로 적용되지 않습니다. 다음과 같은 방법으로 낙관적인 락 기능을 사용할 수 있습니다.

  • 조회 쿼리와 엔티티의 오염 확인(dirty check)
    • 조회 쿼리에 락 타입(lock type)을 지정합니다.
    • 조회된 엔티티를 변경하고, 엔티티의 오염 여부를 확인하는 과정을 통해 업데이트가 수행됩니다.
    • 업데이트를 수행할 때 버전 정보를 증가시킵니다.
    • 다른 트랜잭션(transaction)에 의해 업데이트가 되었다면 버전이 달라지므로 낙관적 락 예외를 발생합니다.
  • 업데이트 쿼리 사용
    • 업데이트 쿼리 조건에 버전 정보를 추가합니다.
    • 업데이트에 실패하면 버전 정보를 제외한 조회 조건으로 엔티티를 탐색합니다.
    • 엔티티 존재함에도 업데이트가 실패했다면 버전 차이로 인식하여 낙관적 락 예외를 발생합니다.

1. Using LockType and Dirty Check

엔티티 오염 확인을 통한 업데이트에서 낙관적 락을 발생시키는 방법에 대해 먼저 정리하였습니다.

1.1. Test Context

다음과 같은 테스트를 수행합니다.

  • 트랜잭션-1은 다음과 같이 동작합니다.
    • 엔티티를 조회합니다.
    • 엔티티 필드 값을 변경합니다.
    • 1초 대기합니다.
    • 트랜잭션 종료 시 버전 정보가 맞지 않음을 확인합니다.
    • 다른 트랜잭션에 의해 해당 엔티티가 업데이트 되었다고 인지하고, 커밋을 실패시키고 예외를 던집니다.
  • 트랜잭션-2은 다음과 같이 동작합니다.
    • 엔티티를 조회합니다.
    • 엔티티 필드 값을 변경합니다.
    • 정상적으로 커밋이 수행되고 엔티티 버전이 증가합니다.

1.2. Implementation Code

  • updateEntityWithLongTransaction 메소드
    • 엔티티를 조회합니다.
    • 조회 쿼리의 락 모드를 OPTIMISTIC으로 지정합니다.
    • 엔티티의 값을 변경합니다.
    • 1초 대기합니다.
  • updateEntity 메소드
    • 엔티티를 조회합니다.
    • 조회 쿼리의 락 모드를 OPTIMISTIC으로 지정합니다.
    • 엔티티의 값을 변경합니다.
package action.in.blog.dsl;

import action.in.blog.dsl.entity.PostEntity;
import action.in.blog.dsl.entity.QPostEntity;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import javax.persistence.LockModeType;

@Slf4j
@Repository
public class DirtyCheckPostStore {

    private final JPAQueryFactory jpaQueryFactory;

    public DirtyCheckPostStore(EntityManager entityManager) {
        this.jpaQueryFactory = new JPAQueryFactory(entityManager);
    }

    private void waitMillis(int millis) {
        try {
            Thread.sleep(millis);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

    @Transactional
    public void updateEntityWithLongTransaction(PostEntity entity) {
        updateEntity(entity);
        waitMillis(1000);
    }

    @Transactional
    public void updateEntity(PostEntity entity) {
        QPostEntity postEntity = QPostEntity.postEntity;
        PostEntity targetEntity = jpaQueryFactory
                .selectFrom(postEntity)
                .where(postEntity.id.eq(entity.getId()))
                .setLockMode(LockModeType.OPTIMISTIC)
                .fetchOne();
        targetEntity.setTitle(entity.getTitle());
        targetEntity.setContents(entity.getContents());
    }
}

1.3. Test Code

  • 테스트를 위한 엔티티를 하나 생성합니다.
  • 각 트랜잭션 별로 스레드를 만들어 업데이트를 수행합니다.
    • tx1 - 중간에 대기 시간이 1초 있는 업데이트가 수행됩니다.
    • tx2 - 일반적인 업데이트가 수행됩니다.
  • tx1 처리 중간에 OptimisticLockException 예외가 발생할 것을 예상합니다.
  • tx1, tx2이 모두 종료된 후 저장된 모습은 tx2의 처리 결과 모습임을 확인합니다.
package action.in.blog.dsl;

import action.in.blog.dsl.entity.PostEntity;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
public class DirtyCheckPostStoreIT {

    @PersistenceUnit
    EntityManagerFactory factory;

    void transactionCommit(Consumer<EntityManager> consumer) {
        EntityManager em = factory.createEntityManager();
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();
        try {
            consumer.accept(em);
        } catch (Exception ex) {
            throw ex;
        } finally {
            transaction.commit();
            em.close();
        }
    }

    CompletableFuture<Void> transactionAsyncWithCommit(Consumer<EntityManager> consumer) {
        return CompletableFuture.runAsync(() -> {
            EntityManager em = factory.createEntityManager();
            EntityTransaction transaction = em.getTransaction();
            transaction.begin();
            try {
                consumer.accept(em);
            } catch (Exception ex) {
                throw ex;
            } finally {
                transaction.commit();
                em.close();
            }
        });
    }

    @Test
    void optimistic_lock_with_select_query_and_lock_type() {
        PostEntity entity = PostEntity.builder()
                .title("Hello World Title")
                .contents("This is Contents")
                .build();
        transactionCommit(em -> {
            em.persist(entity);
        });


        CompletableFuture<Void> tx1 = transactionAsyncWithCommit((em) -> {
            DirtyCheckPostStore sut = new DirtyCheckPostStore(em);
            sut.updateEntityWithLongTransaction(
                    PostEntity.builder()
                            .id(entity.getId())
                            .title("Changed title with long transaction")
                            .contents("Changed contents with long transaction")
                            .build()
            );
        }).exceptionally(exception -> {
            Throwable throwable = exception.getCause();
            assertThat(throwable.getCause()).isInstanceOf(OptimisticLockException.class);
            return null;
        });
        CompletableFuture<Void> tx2 = transactionAsyncWithCommit((em) -> {
            DirtyCheckPostStore sut = new DirtyCheckPostStore(em);
            sut.updateEntity(
                    PostEntity.builder()
                            .id(entity.getId())
                            .title("Changed title with short transaction")
                            .contents("Changed contents with short transaction")
                            .build()
            );
        });
        tx1.join();
        tx2.join();


        transactionCommit(em -> {
            PostEntity result = em.find(PostEntity.class, entity.getId());
            assertThat(result.getTitle()).isEqualTo("Changed title with short transaction");
            assertThat(result.getContents()).isEqualTo("Changed contents with short transaction");
            assertThat(result.getVersionNo()).isEqualTo(1);
        });
    }
}
Test Result
  • 업데이트 이전에 엔티티 조회 쿼리가 실행됩니다.
  • 업데이트 쿼리의 조건으로 ID, 버전 정보를 사용합니다.
  • 변경한 필드 값들 이 외에도 버전 정보를 업데이트합니다.
Hibernate: call next value for hibernate_sequence
Hibernate: insert into post_entity (contents, title, version_no, id) values (?, ?, ?, ?)
Hibernate: select postentity0_.id as id1_0_, postentity0_.contents as contents2_0_, postentity0_.title as title3_0_, postentity0_.version_no as version_4_0_ from post_entity postentity0_ where postentity0_.id=?
Hibernate: select postentity0_.id as id1_0_, postentity0_.contents as contents2_0_, postentity0_.title as title3_0_, postentity0_.version_no as version_4_0_ from post_entity postentity0_ where postentity0_.id=?
Hibernate: update post_entity set contents=?, title=?, version_no=? where id=? and version_no=?
Hibernate: select version_no as version_ from post_entity where id =?
Hibernate: update post_entity set contents=?, title=?, version_no=? where id=? and version_no=?

1.4. Considerations

해당 기능을 사용할 때 다음과 같은 것들을 고려해야 합니다.

  • 테스트가 어렵습니다.
    • 트랜잭션의 경합을 테스트하기 때문에 각 스레드를 만들고 트랜잭션을 커밋해야 합니다.
    • H2 같은 임베디드(embedded) 데이터베이스를 사용하지 못하면 데이터베이스를 오염시킵니다.
  • 엔티티 객체의 캡슐화가 깨집니다.
    • 엔티티의 오염 확인은 영속성 컨텍스트에서 관리 중인 엔티티를 기준으로 일어납니다.
    • 필드 변경을 위해 엔티티에 setter 메소드를 만들어야 합니다.
  • 트랜잭션 경합이 발생하지 않는다면 낙관적인 락 메커니즘이 동작하지 않습니다.
    • 트랜잭션 경합이 거의 발생하지 느슨한 상황이라면 매 트랜잭션마다 조회한 엔티티를 기준으로 업데이트합니다.

2. Update Query and Presence Check

QueryDSL 업데이트 쿼리를 사용하면서 자체적으로 낙관적인 락 메커니즘을 구현합니다.

2.1. Test Context

  • 트랜잭션-1은 다음과 같이 동작합니다.
    • 해당 ID, 버전 정보를 가지는 엔티티를 업데이트합니다.
    • 업데이트 시 버전 정보를 현재보다 1 증가시킵니다.
  • 트랜잭션-2은 다음과 같이 동작합니다.
    • 해당 ID, 버전 정보를 가지는 엔티티를 업데이트합니다.
    • 업데이트 시 버전 정보를 현재보다 1 증가시킵니다.
    • 업데이트가 정상적으로 수행되지 않은 경우 이를 확인하고 예외를 던집니다.

2.2. Implementation Code

  • 조회 조건을 만족하는 엔티티를 업데이트합니다.
    • 버전 정보 확인을 제외한 다른 조회 조건은 BooleanExpression 객체로 만듭니다.
  • 업데이트 된 데이터가 0건이 아니라면 해당 로직을 종료합니다.
  • 업데이트 된 데이터가 0건인 경우 다음과 같이 동작합니다.
    • 버전 정보를 제외한 조건으로 데이터를 존재합니다.
    • 해당 데이터가 존재하는 경우 다른 트랜잭션에 의한 엔티티 변경이므로 OptimisticLockException 예외를 던집니다.
    • 해당 데이터가 존재하지 않는 경우 EntityNotFoundException 예외를 던집니다.
package action.in.blog.dsl;

import action.in.blog.dsl.entity.PostEntity;
import action.in.blog.dsl.entity.QPostEntity;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import javax.persistence.OptimisticLockException;

@Repository
public class UpdateQueryPostStore {

    private final JPAQueryFactory jpaQueryFactory;

    public UpdateQueryPostStore(EntityManager entityManager) {
        this.jpaQueryFactory = new JPAQueryFactory(entityManager);
    }

    private boolean isNotExisted(BooleanExpression whereClause) {
        QPostEntity postEntity = QPostEntity.postEntity;
        return jpaQueryFactory
                .selectFrom(postEntity)
                .where(whereClause)
                .fetchFirst() == null;
    }

    @Transactional
    public void updateEntity(PostEntity entity) {
        QPostEntity postEntity = QPostEntity.postEntity;
        BooleanExpression whereClause = postEntity.id.eq(entity.getId());
        long result = jpaQueryFactory
                .update(postEntity)
                .set(postEntity.title, entity.getTitle())
                .set(postEntity.contents, entity.getContents())
                .set(postEntity.versionNo, entity.getVersionNo() + 1)
                .where(whereClause, postEntity.versionNo.eq(entity.getVersionNo()))
                .execute();
        if (result != 0) {
            return;
        }
        if (isNotExisted(whereClause)) {
            throw new EntityNotFoundException("entity is not existed");
        } else {
            throw new OptimisticLockException("entity should be updated by other transaction");
        }
    }
}

2.3. Test Code

  • 하나의 트랜잭션에서 2회 업데이트를 수행합니다.
  • 1차 업데이트는 정상적인 버전 정보를 지닌 엔티티로 업데이트를 수행합니다.
  • 2차 업데이트는 이전 버전 정보를 지닌 엔티티로 업데이트를 수행합니다.
  • 2차 업데이트에서 OptimisticLockException 예외가 발생할 것을 예상합니다.
  • 테이블에 저장된 모습은 1차 업데이트의 수행 결과일 것으로 예상합니다.
package action.in.blog.dsl;

import action.in.blog.dsl.entity.PostEntity;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import javax.persistence.*;
import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

@DataJpaTest
public class UpdateQueryPostStoreIT {

    @PersistenceUnit
    EntityManagerFactory factory;

    void transaction(Consumer<EntityManager> consumer) {
        EntityManager em = factory.createEntityManager();
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();
        try {
            consumer.accept(em);
        } catch (Exception ex) {
            throw ex;
        } finally {
            transaction.rollback();
            em.close();
        }
    }

    void flushAndClear(EntityManager em) {
        em.flush();
        em.clear();
    }

    @Test
    void optimistic_lock_with_update_query() {
        transaction((em) -> {
            PostEntity entity = PostEntity.builder()
                    .title("Hello World Title")
                    .contents("This is Contents")
                    .build();
            em.persist(entity);
            flushAndClear(em);

            long obsoleteVersionNo = entity.getVersionNo();
            UpdateQueryPostStore sut = new UpdateQueryPostStore(em);


            sut.updateEntity(
                    PostEntity.builder()
                            .id(entity.getId())
                            .title("Changed title by first transaction")
                            .contents("Changed contents by first transaction")
                            .versionNo(entity.getVersionNo())
                            .build()
            );
            assertThrows(OptimisticLockException.class, () -> {
                sut.updateEntity(
                        PostEntity.builder()
                                .id(entity.getId())
                                .title("Changed title by second transaction")
                                .contents("Changed contents by second transaction")
                                .versionNo(obsoleteVersionNo)
                                .build()
                );
            });


            PostEntity result = em.find(PostEntity.class, entity.getId());
            assertThat(result.getTitle()).isEqualTo("Changed title by first transaction");
            assertThat(result.getContents()).isEqualTo("Changed contents by first transaction");
            assertThat(result.getVersionNo()).isEqualTo(1);
        });
    }
}
Test Result
  • 업데이트 쿼리의 조건으로 ID, 버전 정보를 사용합니다.
    • 업데이트 수행 시 엔티티의 버전을 함께 증가시킵니다.
  • 업데이트가 성공한다면 조회 쿼리를 수행하지 않습니다.
  • 업데이트가 실패한다면 조회 쿼리를 수행하고, 해당 결과에 따른 예외를 전달합니다.
Hibernate: call next value for hibernate_sequence
Hibernate: insert into post_entity (contents, title, version_no, id) values (?, ?, ?, ?)
Hibernate: update post_entity set title=?, contents=?, version_no=? where id=? and version_no=?
Hibernate: update post_entity set title=?, contents=?, version_no=? where id=? and version_no=?
Hibernate: select postentity0_.id as id1_0_, postentity0_.contents as contents2_0_, postentity0_.title as title3_0_, postentity0_.version_no as version_4_0_ from post_entity postentity0_ where postentity0_.id=? limit ?

2.4. Considerations

해당 기능을 사용할 때 다음과 같은 것들을 고려해야 합니다.

  • 테스트가 단순해집니다.
    • 테스트를 위해 트랜잭션 별로 스레드를 만들 필요가 없습니다.
    • 테스트 중간에 트랜잭션을 커밋할 필요가 없습니다.
  • 데이터 정합성을 위한 업데이트를 하고자 엔티티의 버전 정보를 항상 들고 다녀야 합니다.
  • 업데이트 쿼리를 직접 작성하기 때문에 엔티티의 캡슐화를 깰 필요가 없습니다.
  • 낙관적인 락에 대한 비즈니스 로직을 개발자가 직접 작성해야 합니다.

CLOSING

두 가지 방법에 대한 각 고려 사항들과 비즈니스를 고민하여 적절한 방법으로 구현합니다.

TEST CODE REPOSITORY

REFERENCE

댓글남기기