반응형

결론

SpringJPA Data 사용 할때 @Id를 직접 관리할 경우, 1:N 연관관계에 Cascade 사용시 Merge도 사용해야한다.

이슈내용

Spring Data JPA를 사용하여 JPARepository를 사용하고, 해당 인터페이스를 사용하여 save를 진행할때 Parent -(1:N)- Child 관계의 엔티티가 있을 경우 save(Parent)시 하위 Child들이 전부 null로 Insert 되는 상황이 있었다.

원인분석

다음과 같이 Parent, Child 엔티티를 작성하였다

Parent Entity

참고사항

@Id 어노테이션만 정의
@OneToMany 어노테이션의 cascade가 PERSIST만 정의
@Entity
public class Parent {
    @Id
    private String id;
    private String name;
    private Integer age;
    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList();
    
    public Parent() {
    }

    public Parent(String id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }
    
    ...getter
    
    public List<Child> getChildren() {
        return children;
    }    
    
    public void addChild(Child child){
        child.setParent(this);
        children.add(child);
    }
}

Child Entity

@Entity
public class Child {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Parent parent;
    
    public Child(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    ...getter

    public void setParent(Parent parent) {
        this.parent = parent;
    }
}

Repository

public interface ParentRepository extends JpaRepository<Parent, String>{}

Test Code

간단하게 Parent와 Child를 생성하여 JPARepository의 save를 하고 제대로 입력이 되었는지 테스트하는 코드이다

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
public class ParentRepositoryTests {

    @Autowired
    private ParentRepository parentRepository;

    @Test
    void createParentWithRepository(){
        Parent parent = new Parent("1", "부모", 40);
        parent.addChild(new Child("자식1", 4));
        parent.addChild(new Child("자식2", 2));

        parentRepository.save(parent);

        Parent findParent = parentRepository.findById(parent.getId()).get();
        List<Child> children = findParent.getChildren();

        Child firstChild = children.get(0);
        Child secondChild = children.get(1);
        assertAll(
            () -> assertEquals(2, children.size()),
            () -> assertEquals("자식1",firstChild.getName()),
            () -> assertEquals("자식2", secondChild.getName())
        );
    }
}

테스트를 하게 되면 들어갔다고 생각할수 있는 Child들이 전부 실패가 뜨게 된다.

Multiple Failures (2 failures)
org.opentest4j.AssertionFailedError: expected: <자식1> but was: <null>
org.opentest4j.AssertionFailedError: expected: <자식2> but was: <null>

실제로 들어간 데이터들을 확인하면 전부 null로 들어가있는것을 확인할 수 있다.


Why?

Parent 데이터는 제대로 들어가고 하위 엔티티들이 제대로 들어가지 않는 상황이다보니 관련된 문제점은 Cascade라고 판단되어 살펴보았다. CascadeType.PERSIST로 설정이 되어있었고, 새롭게 생성되는 Parent이기 때문에 당연히 em.persist(entity)가 될것이라고 생각했었다.

그리고 JPARepository의 save 메소드의 코드를 확인해보았다

package org.springframework.data.jpa.repository.support;

@Transactional
@Override
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null.");

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}

새로운 엔티티인지 확인하는 로직이 있었고 아닌 경우에는 em.merge()을 호출하고 있었다. 그렇다면 새로운 엔티티의 기준이 뭘까?

일반적인 관점에서 새로운 객체(엔티티)를 생성하고 save() 호출하면 개발자 입장에서는 새로운 엔티티지만 JPA의 관점에서는 이게 신규 엔티티인지 데이터베이스에 이미 있는 엔티티인지 확인해야하는 입장이었다. 

 

다음 로직을 확인해 보자

entityInformation.isNew(entity)

org.springframework.data.repository.core.support 패키지 안에 AbstractEntityInformation를 보면

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로 지정한 타입이 원시타입이 아니라면 null일 경우에만 신규 엔티티다

 

이 말인 즉슨, 새로운 엔티티라고 정의한 Parent가 사실 isNew() 호출로 인하여 persist가 아닌 merge가 되고 있었다. 그렇기 때문에 CascadeType.PERSIST라고 설정했을 경우 하위 엔티티들의 변경사항을 저장하지 않았던 것이다.

평소에는 @Id에 @GeneratedValue키생성 전략을 IDENTITY로 정의했기 때문에 CascadeType.PERSIST를 사용했더라도 문제가 없었것인데, 실제로 Key(=id)를 입력해야하는 상황이 생겨서 이런 이슈를 만나게 되었다

 

위의 코드에 다음과 같이 CascadeType.MERGE를 추가하게 되면 문제가 해결된다.

@Entity
public class Parent {
    ...
    @OneToMany(mappedBy = "parent", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private List<Child> children = new ArrayList();
    ...
}

테스트가 성공적으로 완료 됨

반응형

'개발 > JPA' 카테고리의 다른 글

Kotlin에서 JPA 사용시 참고사항  (0) 2022.03.30

+ Recent posts