[삽질 로그] Hibernate에서 부모가 둘인 Entity의 한쪽 부모를 지우면 참조 무결성 오류가 발생하는 문제

woodyn·2021년 2월 1일
9

삽질 로그

목록 보기
3/5

문제 정의

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Entity
public class Mother {

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

    @ToString.Exclude
    @OneToMany(mappedBy = "mother", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Child> children = new HashSet<>();

}

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Entity
public class Father {

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

    @ToString.Exclude
    @OneToMany(mappedBy = "father", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Child> children = new HashSet<>();

}

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Entity
public class Child {

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

    @Setter(AccessLevel.NONE)
    @ToString.Exclude
    @ManyToOne
    private Mother mother;

    @Setter(AccessLevel.NONE)
    @ToString.Exclude
    @ManyToOne
    private Father father;

    public void setMother(Mother mother) {
        this.mother = mother;
        mother.getChildren().add(this);
    }

    public void setFather(Father father) {
        this.father = father;
        father.getChildren().add(this);
    }
    
}

@DataJpaTest
class ChildTest {

    @Autowired
    private MotherRepository motherRepository;

    @Autowired
    private FatherRepository fatherRepository;

    @Autowired
    private ChildRepository childRepository;

    @Test
    void deleteMother_MotherAndFatherExist_FatherUpdated() {
        // Given
        Mother mother = motherRepository.save(new Mother());
        Father father = fatherRepository.save(new Father());

        Child child = childRepository.save(new Child());
        child.setMother(mother);
        child.setFather(father);
        childRepository.flush();

        // When
        motherRepository.deleteById(mother.getId()); // Referential integrity constraint violation

        // Then
        assertThat(motherRepository.findAll()).isEmpty();
        assertThat(father.getChildren()).isEmpty();
        assertThat(childRepository.findAll()).isEmpty();
    }

}

위 테스트 코드 실행 시 다음 Exception이 발생한다:

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FK4UWOR9P1MSVJBX0TCEQ594TJC: PUBLIC.CHILD FOREIGN KEY(MOTHER_ID) REFERENCES PUBLIC.MOTHER(ID) (1)"; SQL statement:
delete from mother where id=? [23503-200]

Child는 Mother과 Father 간의 M:N 관계를 두 짝의 OneToMany와 ManyToOne으로 풀어 쓴 중간 Entity이다.
따라서 Mother 혹은 Father가 제거되면 Child도 제거되어야 한다(상태가 전이되어야 한다).
그러므로 CascadeType.ALL을 통해 부모의 모든 상태를 자식에게 전이하도록 했다.

그러나 위와 같이 Mother를 제거하려고 하면 참조 무결성 오류가 발생한다. 어째서일까?

원인 파악 과정

로그 확인하기

역시 원인을 파악하려면 로그를 봐야 한다.

Hibernate: 
    insert 
    into
        mother
        (id) 
    values
        (null)
Hibernate: 
    insert 
    into
        father
        (id) 
    values
        (null)
Hibernate: 
    insert 
    into
        child
        (id, father_id, mother_id) 
    values
        (null, ?, ?)
2021-02-01 22:20:18.080 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [null]
2021-02-01 22:20:18.080 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [null]
Hibernate: 
    update
        child 
    set
        father_id=?,
        mother_id=? 
    where
        id=?
2021-02-01 22:20:18.086 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-02-01 22:20:18.086 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2021-02-01 22:20:18.086 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [1]
Hibernate: 
    delete 
    from
        mother 
    where
        id=?
2021-02-01 22:20:18.150 TRACE 5213 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2021-02-01 22:20:18.152  WARN 5213 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23503, SQLState: 23503
2021-02-01 22:20:18.152 ERROR 5213 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : Referential integrity constraint violation: "FK4UWOR9P1MSVJBX0TCEQ594TJC: PUBLIC.CHILD FOREIGN KEY(MOTHER_ID) REFERENCES PUBLIC.MOTHER(ID) (1)"; SQL statement:
delete from mother where id=? [23503-200]

다른 쿼리는 모두 정상이었지만, 어째서인지 mother를 지우기 전에 child를 지우는 쿼리가 나가지 않았다.
엥? 나는 분명 Cascade를 걸어놔서 Mother를 지울 때 Child를 먼저 지우도록 했는데..
Cascade가 모종의 이유로 작동하지 않고 있다.

Hibernate의 모든 로그 확인하기

logging:
  level:
    org.hibernate: trace

Cascade 기능은 Hibernate가 수행한다. 따라서 application.yml을 수정해서 Hibernate의 전체 로그를 확인해보기로 했다.

TRACE 5281 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Flushing session
DEBUG 5281 --- [    Test worker] o.h.e.i.AbstractFlushingEventListener    : Processing flush-time cascades
TRACE 5281 --- [    Test worker] org.hibernate.engine.internal.Cascade    : Processing cascade ACTION_PERSIST_ON_FLUSH for: me.woodyn.twoparentscascadeproblem.model.Father
TRACE 5281 --- [    Test worker] org.hibernate.engine.internal.Cascade    : Cascade ACTION_PERSIST_ON_FLUSH for collection: me.woodyn.twoparentscascadeproblem.model.Father.children
TRACE 5281 --- [    Test worker] o.hibernate.engine.spi.CascadingAction   : Cascading to persist on flush: me.woodyn.twoparentscascadeproblem.model.Child
TRACE 5281 --- [    Test worker] o.hibernate.event.internal.EntityState   : Deleted instance of: me.woodyn.twoparentscascadeproblem.model.Child
TRACE 5281 --- [    Test worker] o.h.e.i.DefaultPersistEventListener      : un-scheduling entity deletion [[me.woodyn.twoparentscascadeproblem.model.Child#1]]
TRACE 5281 --- [    Test worker] org.hibernate.engine.internal.Cascade    : Processing cascade ACTION_PERSIST_ON_FLUSH for: me.woodyn.twoparentscascadeproblem.model.Child

로그를 확인하는 중 수상한 부분을 발견했다. flush가 일어날 때마다 'flush-time cascades'를 수행한다.
그리고 그 과정 속에서 Father에게 ACTION_PERSIST_ON_FLUSH Cascade가 적용되고, children에게도 해당 Cascade가 전이됐다.
이게 뭐지? 왜 지워진 Entity에 Persist Cascade가 전이되는 걸까?
PERSIST_ON_FLUSH는 왜 변경 사항이 없는 Father에게도 적용되는 걸까?

JPA 2.2 스펙 문서 확인하기

Hibernate의 공식 문서를 확인해봤지만 큰 수확은 없었다. 구글링을 통해 여러 레퍼런스를 확인해봤는데, JPA에도 공식 문서가 있다는 것을 알게 되었다.
그 내용 속에는 flush 수행 시에 관리되고 있는 Entity들을 데이터베이스에 동기화하기 위해 cascade 동작을 수행한다고 나와있었다.
영속성 컨텍스트 내의 Entity X가 Y에게 CascadeType.PERSIST로 연관 관계를 맺고 있다면, X에서 Y로 Persist 상태를 전이한다는 것이다. (여기서 X가 Father, Y가 Child인 셈이다)
왜 이런 작업이 flush 시마다 필요한 걸까?

조금만 생각해보면 그 답을 알 수 있었다.
트랜잭션 내 변경 사항은 flush 시 DB에 반영되어야 한다.
그렇다면 Persistent 상태의 부모 Entity에 Transient 상태의 자식 Entity를 추가하면 어떻게 될까?
flush 시 변경 사항이 DB에 반영되어야 하므로, 부모에서 자식으로 PERSIST 영속성 전이를 진행해야만 한다.
그러므로 쿼리가 만들어지는 flush 시에, 모든 부모 Entity가 자식에게 PERSIST 영속성 상태를 전이하는 것이다.

그렇다면 나의 상황에서는 어떤가?
나는 Mother와 Father의 OneToMany 필드에 CascadeType.ALL을 걸어놨다. Persistent와 Removed 두 상태 모두 전이되는 것이다.
Mother의 삭제는 자식에게 Removed 상태를 전이할 것이다. 이는 내가 생각한 대로 작동했다.
그러나 방금 설명한 이유로 flush 시마다 Father에게 PERSIST_ON_FLUSH가 작동될 것이다. Father는 Persist 상태를 자식에게 전이하고, 결과적으로 자식은 Removed 상태에서 Persistent 상태로 돌아온다.
결국 Child는 Removed 상태가 되었다가, Persistent 상태로 돌아왔다. 이는 변경 사항이 없는 것이므로 쿼리로 최적화하면 아무 일도 일어나지 않은 것이다.
그 상태에서 Mother를 지우려고 하면 당연히 DB쪽에서 참조 무결성 오류가 난다!

해결

상황을 정리하자면 다음과 같다:

  1. Mother와 Father는 연관 관계를 맺은 Child에게 Persistent와 Removed 상태를 전이하고 있었다.
  2. Mother를 지우면 Child에게 Removed 상태가 전이된다.
  3. flush 시 영속성 컨텍스트의 모든 Managed Entity들 각각이 cascade=PERSIST 관계를 맺고 있는 Entity들에게 영속화 전이를 수행된다.
  4. Father는 Child에게 cascade=PERSIST 연관 관계를 맺고 있으므로, Father가 영속화 전이를 수행하면 자식인 Child는 Persistent 상태가 된다.
  5. Child는 Persistent→Removed→Persistent 순으로 상태가 변화한다. 최적화된 쿼리로 변환하면 아무 일도 하지 않은 것이므로, 아무 쿼리도 실행되지 않는다.
  6. Mother는 여전히 Removed 상태이다. 이 변경 사항은 delete 쿼리로 변환된다.
  7. DB의 Child 테이블에는 Mother와 Father 테이블을 참조하는 컬럼이 존재한다.
  8. DB 상에서 Child는 여전히 Mother를 참조하고 있으므로, delete mother 쿼리를 실행하면 참조 무결성 오류가 발생한다.
    @ToString.Exclude
    @OneToMany(mappedBy = "father", cascade = CascadeType.REMOVE, orphanRemoval = true)
    private Set<Child> children = new HashSet<>();

Mother와 Father의 OneToMany 필드에 cascade를 CascadeType.REMOVE로 고치면 문제가 해결된다! 애초에 부모의 영속화를 자식에게 전이할 이유가 없었는데 CascadeType.ALL을 사용하고 있었던 점이 문제였다.
굳이 CascadeType.PERSIST를 사용하고 싶다면 부모를 지울 때마다 다른 쪽 부모를 detach 해주면 된다. 하지만 그렇게까지 할 이유가 없을 것 같다.

결론

연관 관계를 여러 개 갖는 Entity의 경우, 두 개 이상의 관계 필드에 CascadeType.PERSIST를 적용하지 말자.
그리고 CascadeType.ALL을 설정하기 전에 꼭 필요한 기능인지 충분히 고민해보자.

반성

이번 문제는 Hibernate의 전체 로그 확인만으로 충분히 해결할 수 있는 문제였다. 그러나 그 방법을 너무 늦게 생각해내서 Hibernate의 소스 코드를 뜯어보다가 많은 시간을 보내버렸다 (사실 이런 문제는 처음 겪어 조금 방황했다).
앞으로는 로그를 통해 최대한 정보를 수집하고, 그 정보를 바탕으로 공식 문서와 외부 레퍼런스에서 해결책을 찾는 전략을 취해야겠다. 소스 코드를 뜯어보는 일은 정 방법이 없을 때에만 택하도록 하자.

profile
🦈

2개의 댓글

comment-user-thumbnail
2022년 10월 21일

덕분에 문제 해결하고갑니다...!

답글 달기
comment-user-thumbnail
2023년 3월 19일

덕분에 문제 해결했습니다 감사합니다

답글 달기