[JPA] detached entity passed to persist 해결방법 (feat. InvalidDataAccessApiUsageException)

Woo Yong·2024년 3월 22일
1

JPA

목록 보기
6/7
post-thumbnail

JPA에서 1:N 양방향 관계에서 양쪽 관계 필드에 cacasde=CascadeType.ALL을 적용해서 편의 메서드를 사용했을 때 부모 엔티티와 자식 엔티티에서 모두 객체간 일관성을 유지할 수 있는지 테스트하는데 있어서 detached entity passed to persist 예외가 발생하여 회고하기 위해 글을 작성해보려고한다.

우선 detached entity passed to persist가 언제 발생하는지 알아보자.

InvalidDataAccessApiUsageException: detached entity passed to persist 발생 이유

detached entity passed to persist 에러는 객체의 부분에 다시 한번 더 설정을 해주거나 저장을 해주려고 할 때 생기는 것! 즉, 엔티티 종속성 문제거나 객체 생성할 때 문제가 있는 것 !!

예를들어, 엔티티들의 연관관계가 존재할 때 User엔티티의 user1,user2에 연관된 Order엔티티의 order1 있을 때, (user1 <-> order1)을 저장하고 (user2<->order1)저장할때 중복으로 저장되는 경우이다.

@Test
public void test(){
  Users user1 = Users.builder().name("user1").build();
  Users user2 = Users.builder().name("user2").build();
  Orders order1 = Orders.builder().name("order1").build();
  Orders order2 = Orders.builder().name("order2").build();

  user1.편의_메서드(order1);
  user2.편의_메서드(order1);

  userDao.save(user1);
  userDao.save(user2);

  order2.setUser(user2);
  orderDao.save(order2);
}

처음에 오류를 발견했을 때 대체 왜 중복 저장되는지 이해가 되지 않았다. 왜냐하면 order2를 저장하게 되면 order2와 연관 관계에 있는 user1을 저장하기 위해서 DB에서 조회한 결과와 변경감지 기능으로 비교한 후 다시 저장을 한다고 생각했다.

그리고 에러의 원인을 찾기 위해서 save() 메서드를 확인해봤다.

SimpleJpaRepository 클래스의 save 구현 메서드 코드

  @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);
  	}
  }

코드를 확인해보면, isNew() 메서드를 통해 entity를 persist할지, merge할지 분기하고 있는 것을 확인할 수 있다.

isNew 메서드에 간단하게 설명하면 엔티티가 새로운 엔티티라면 persist를 수행하고, DB의 존재하는 엔티티라면 merger를 수행하는 역할을 담당하고 있다.

  public abstract class AbstractEntityInformation<T, ID> implements EntityInformation<T, ID> {
    ...
    public boolean isNew(T entity) {
      
        ID id = getId(entity);
        Class<ID> idType = getIdType();
        
        if (!idType.isPrimitive()) {
            return id == null;
        }
        
        if (id instanceof Number) {
            return ((Number) id).longValue() == 0L;
        }
        
        throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
    }
}

해당 엔티티가 새로운 엔티티인지 확인하는 방법은 엔티티의 id 값으로 비교한다.
id가 원시타입일 경우, null값으로 새로운 엔티티인지 아닌지 구분하고, 래퍼 클래스의 인스턴스라면 getId()로 얻은 값이 0인지 아닌지로 새로운 엔티티인지 구분한다.

계속해서 디버깅을 진행해보았다.
디버깅을 진행 중 DefaultPersistEventListeneronPersist메서드에서 order2를 persist하는 것을 발견했다.
왜냐하면 order2 엔티티는 실제로 id값을 지정하지 않은 새로운 객체이기 때문이다.

그리고 Cascade 클래스의 cascade 메서드 내부에서 cascade 속성으로 Users 엔티티 존재를 확인하는 코드를 확인할 수 있다.

그리고 cascade 속성으로 인해 order 엔티티와 동일하게 user 엔티티도 onPersist가 되는 것을 확인할 수있다.

그리고 isTransient 메서드를 통해서 user 엔티티가 영속상태인지 확인하는 코드를 확인할 수 있다.
id == null 이면 isTransient 메서드가 true를 반환하기 때문에 엔티티가 비영속(TRANSIENT)을 의미하고, null 이 아니면 준영속(DETACHED) 혹은 영속상태(MANAGED)라는 것이다.
하지만 해당 id가 2이기 때문에 user 엔티티의 영속상태는 DETACHED , MANAGED가 되게 된다.

계속해서 디버깅을 하다보면 엔티티의 상태 변화를 추적하는 EntyEntity 인터페이스를 통해서 엔티티의 영속상태를 결정하는 코드를 확인할 수 있다.
하지만 user엔티티는 isTransient 메서드 내부에서 id값이 존재하고 데이터베이스 조회를 통해 EntityEntry에 (MANAGED 상태로)add 된 것을 확인할 수 있다.

따라서 entity값은 null, status도 DELETED도 아니기 때문에 PERSISTENT을 return하게 된다.

그리고 외래키가 일시적인지 확인하는 메서드ForeignKeys.isTransient를 통해 해당 엔티티가 TRANSIENT상태인지 변환 시킨다.
결국 외래키가 일시적이라는 말은 객체가 영속성 컨텍스트에 저장되기 전의 상태를 말하는 것이다.
하지만 EntityState가 PERSISTENT라면

메서드를 타고 들어가면 isUnsaved 메서드를 호출하는데 id값의 null 여부를 통해 true 혹은 false를 반환한다.
user엔티티는 2라는 id값을 통해 null 아니라 false를 반환한다.

따라서 ForeignKeys.isTransient메서드는 false로 해당 분기는 지나가게된다.

그리고 마지막으로 영속성 컨텍스트에서 삭제되거나 로딩되지 않은 엔티티 키를 포함하는지 확인 메서드를 통과하기 때문에 최종적으로 EntityState 값은 DETACHED로 준영속 상태가 된다.


하지만 최종적으로 cascade 속성으로 인해서 persist 메서드를 호출하게 되는데 내부적으로 DETACHED 상태의 엔티티는 PersistentObjectException 예외를 발생하는 것을 확인할 수 있다.
왜냐하면

그리고 처음에 오류 메시지를 확인해보면 위 사진에서 출력하는 예외메시지하고 동일한 것을 확인할 수 있다.

정리를 해보면, order엔티티는 isTransient 메서드에 따라서 TRANSIENT으로 상태로 persist를 수행하지만, user엔티티는 영속화된 적이 있어서 id값이 존재하기 때문에 PERSISTENT 상태가 된다. 하지만 EntityEntry에 존재하는 id값이 존재하기 때문에 일시적인 Entity가 아니라는 것이다. 즉, 데이터베이스를 한번 조회했다는 말이다.
그렇기 때문에 엔티티의 상태가 DETATCHED로 변화된다.
하지만 user 엔티티는 cascade속성에 따라서 persist가 호출되는데 persist 메서드는 내부적으로 DETACHED상태의 엔티티는 예외를 호출하기 때문에 해당 에러를 호출하게 되는 것이다.

profile
Back-End Developer

0개의 댓글

관련 채용 정보