Database ~ SQL

[JPA] 쓰기 지연 SQL 저장소 flush 시, 쿼리 실행 순서

yooverd 2024. 11. 20. 16:07
728x90
반응형
SMALL

 

JPA 를 활용하여 데이터를 관리하는 중인데 동일한 unique key 에 대한 delete 와 save 메서드를 사용하였더니 Duplicate Key 문제가 발생하였다.

 

결론적으로는 jpa를 통해 delete 관련 메서드를 호출해도 db에 동기화가 바로 일어나지 않고(직접 flush를 하거나 db 동기화가 일어나도록 하지 않는다면) 쓰기 지연  SQL 저장소에 쌓아두고 나중에 isnert 문과 함께 Db에 쿼리문을 보내준다. 이때 dbms 가 쿼리를 실행하기 전에 각 쿼리를 평가(최적화 및 실행 계획 등..)하는 과정에서 uniqueKey가 중복되는 insert문이 존재하므로 여기서 문제가 발생했던 것이다.

 


 

문제 원인

실제 코드는 다음과 같다.

public class MyEntity {
    @Id
    private String id;
    private String uniqueKey;

    public MyEntity(MyEntity oldEntity, String newId) {
        this.id = newId;
        this.uniqueKey = oldEntity.getUniqueKey();
    }
}
    
    
//////////////// 로직부분 //////////////////


@Transactional
public void deleteAndInsert (String targetId) {
    // 타겟 엔티티 가져오기
    MyEntity oldEntity = jpaRepository.findById(targetId);

    // 타겟 엔티티 삭제
    jpaRepository.delete(oldEntity);

    // 엔티티를 수정하여 삽입
    MyEntity newEntity = new MyEntity(oldEntity, "newId");
    jpaRepository.save(newEntity);
}

 


다음과 같은 메서드를 실행하면 메서드가 끝날 때 트랜잭션이 커밋되기 전 flush가 자동으로 호출된다.
따라서 쓰기 지연 SQL 저장소에 담긴 쿼리들이 동기화를 위해 데이터베이스로 전달된다.


내가 헷갈렸던 부분은 다음과 같다.

JPA 쓰기 지연 SQL 저장소에 담긴 쿼리를 flush 할 때, hibernate 는 데이터 불일치 문제를 해결하기 위해 쿼리문을 삽입, 수정, 삭제 순서로 정렬해서 db에 전달해준다는 점이다.

즉, 내가 삽입 메서드를 호출하기 전에 삭제 메서드를 호출하였다 해도 hibernate가 insert 쿼리가 먼저 실행되도록 하므로 uniqueKey 가 중복되므로 Duplicate Key 에러가 발생하는 것이다.

 

즉,

1. 조회한 엔티티를 삭제하는 쿼리가 쓰기 지연 저장소에 쌓인다.

2. 새로운 엔티티를 삽입하는 쿼리가 쓰기지연 저장소에 쌓인다.

3.  메서드가 종료되며 트랜잭션이 커밋되기 전에 flush 가 호출된다.'

   3-1. hibernate에 의해 삽입 -> (수정) -> 삭제 순서로 쿼리를 db에 보내준다.

   3-2. 삽입 쿼리를 수행하려는데, 기존에 존재하는 데이터 중 uniquekey 가 중복되는 데이터가 존재하므로 Duplicate Key 에러가 발생한다.

 

해결방법

해결 방법은 다음과 같다.
쓰기 지연 저장소에 담긴 쿼리의 delete 문이 먼저 나가도록 하면 되는 것이다.
트랜잭션을 다르게 관리해도 괜찮지만, 더 간단한 방법으로는 flush 를 사용하면 된다.

@Transactional
public void deleteAndInsert (String targetId) {
    // 타겟 엔티티 가져오기
    MyEntity oldEntity = jpaRepository.findById(targetId);

    // 타겟 엔티티 삭제
    jpaRepository.delete(oldEntity);

    // db 와 동기화 (삭제 우선 수행)
    jpaRepository.flush();

    // 엔티티를 수정하여 삽입
    MyEntity newEntity = new MyEntity(oldEntity, "newId");
    jpaRepository.save(newEntity);
}

코드를 이렇게 작성하는 경우,
1. 조회한 엔티티를 삭제하는 쿼리가 쓰기 지연 저장소에 쌓인다.
2. flush 호출 시, 쓰기 지연 저장소에 담긴 delete 문이 db로 전달되어 타겟 데이터가 우선 삭제되고 전달된 쿼리들은 쓰기 지연 저장소에서 제거된다.

3. 새로운 엔티티를 삽입하는 쿼리가 쓰기지연 저장소에 쌓인다.

4. 메서드가 종료되며 트랜잭션이 커밋되기 전에 flush 가 호출되어 삽입 쿼리가 db 에 전달되어 데이터가 삽입된다.

 


 

생각보다 문제 해결방법은 간단했다.
다만 flush 를 활용하기 전에 트랜잭션을 이용하는 방식을 쓰고싶었고 코드를 타고 들어가다 보니 여러가지 사실을 알게되면서 트랜잭션을 활용한 방법은 더 많은 구상을 필요로 한다는 것을 깨달았다.

 

JpaRepostory 를 구현하는 SimpleJpaRepository 클래스를 들여다보니 save 와 delete 라는 메서드 자체에 @Transactional 아노테이션이 달려있었다.

    @Transactional
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (this.entityInformation.isNew(entity)) {
            this.entityManager.persist(entity);
            return entity;
        } else {
            return this.entityManager.merge(entity);
        }
    }
    
    
    @Transactional
    public void delete(T entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (!this.entityInformation.isNew(entity)) {
            Class<?> type = ProxyUtils.getUserClass(entity);
            T existing = this.entityManager.find(type, this.entityInformation.getId(entity));
            if (existing != null) {
                this.entityManager.remove(this.entityManager.contains(entity) ? entity : this.entityManager.merge(entity));
            }
        }
    }
    
    ///////////////////// flush 가 포함된 save 메서드 /////////////////////////
    
    @Transactional
    public <S extends T> S saveAndFlush(S entity) {
        S result = this.save(entity);
        this.flush();
        return result;
    }


더 많은 생각이 필요할 것 같다는 추측을 한 이유는 다음과 같다.

Spring 은 @Transactional 아노테이션에 대한 트랜잭션 전파 기본 옵션은 Propagation.REQUIRED 이다.
해당 옵션은 트랜잭션이 이미 존재한다면 해당 트랜잭션을 그대로 받아 사용하도록 한다.
Propagation.REQUIRES_NEW 라는 옵션도 존재하는데 이는 항상 새 트랜잭션을 시작하는 옵션이다.

나의 경우 트랜잭션 전파 옵션에 대한 추가적인 설정을 하지 않았기 때문에 기본 옵션으로 트랜잭션이 관리되는 상황이었다.
이때, 문제가 된 메서드(deleteAndInsert)가 있는 클래스에  @Transactional 를 달아놓았기 때문에 deleteAndInsert 메서드가 실행될 때 트랜잭션이 시작될 것이다.
따라서 SimpleJpaRepository 의 delete 가 호출되어도 트랜잭션이 종료되지 않으므로 flush 가 호출되지 않고 문제가 발생했던 것이다....

만일 REQUIRES_NEW  옵션을 주었다면 delete 메서드 호출 시 새로운 트랜잭션이 열리고 닫히면서 자동적으로 flush도 호출되어 Duplicated key 문제가 발생하지 않았을지도 모른다는 생각이 든다.

 

그런데 위의 문제를 해결하기 위해서 트랜잭션과 관련된 옵션을 건드리는 것은 좋은 방법이 아닌 것 같다고 판단되어 flush 를 호출하는 방식으로 문제를 해결하였다.

 


 

그리고 자세히 살펴보니 saveAndFlush라는 메서드도 이미 존재했는데, 만일 JpaRepository 를 상속하는데 있어서 추가적인 로직이 있는 것이 아니라면 가독성을 높일 목적으로 saveAndFlush 메서드를 그대로 사용해도 괜찮아 보였다.

 



참고

 

JPA(hibernate) INSERT, UPDATE, DELETE 순서 주의사항

문제가 발생한 상황은 다음과 같다. 회원은 프로필 이미지를 하나만 가질 수 있는 상황이다. Member @Entity @Data public class Member { ... @OneToOne(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) pr

non-stop.tistory.com

 

AbstractFlushingEventListener (Hibernate JavaDocs)

Execute all SQL (and second-level cache updates) in a special order so that foreign-key constraints cannot be violated: Inserts, in the order they were performed Updates Deletion of collection elements Insertion of collection elements Deletes, in the order

docs.jboss.org

 

728x90
반응형
LIST