[삽질 로그] Hibernate에서 연관 관계를 맺고 바로 끊으면 orphanRemoval이 작동하지 않는 문제

woodyn·2021년 2월 1일
0

삽질 로그

목록 보기
2/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<>();

    public void addChild(Child child) {
        this.children.add(child);
        child.setMother(this);
    }

    public void removeChild(Child child) {
        this.children.remove(child);
        child.setMother(null);
    }
}

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

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

    @ToString.Exclude
    @ManyToOne
    private Mother mother;

}

@DataJpaTest
public class MotherTest {

    @Autowired
    private MotherRepository motherRepository;

    @Autowired
    private ChildRepository childRepository;

    @Test
    void removeChild_MotherHasChild_ChildDeleted() {
        // Given
        Mother mother = motherRepository.save(new Mother());
        Child child = childRepository.save(new Child());
        mother.addChild(child);

        // When
        mother.removeChild(child);

        // Then
        assertThat(mother.getChildren()).isEmpty(); // Success
        assertThat(childRepository.findAll()).isEmpty(); // Failure
    }

}

위 테스트 코드가 실패했다.
Mother에 Child를 추가하고 제거했는데, orphanRemoval 기능이 작동하지 않았다.
그 결과로 Mother의 children은 비어있지만 Child는 여전히 남아있는 상태가 되었다.
뭐가 문제일까?

원인 파악 과정

로그 확인하기

Hibernate: 
    insert 
    into
        mother
        (id) 
    values
        (null)
Hibernate: 
    insert 
    into
        child
        (id, mother_id) 
    values
        (null, ?)
2021-02-01 21:06:59.762 TRACE 4809 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [null]
Hibernate: 
    select
        child0_.id as id1_0_,
        child0_.mother_id as mother_i2_0_ 
    from
        child child0_

발생한 쿼리 기록을 확인하니, delete가 일어나지 않았다!
분명 orphanRemoval=true를 설정했는데.. 왜 작동하지 않지?
심지어 child에도 mother_id가 입력되지 않았다. 연관 관계가 전혀 만들어지지 않은 셈이다.
그렇다면 쿼리는 언제 날아가는 것이고, 관계를 만드는 쿼리는 왜 만들어지지 않은 걸까?

flush() 이해하기

JPA에서 Entity의 변경 사항은 flush() 시 쿼리로 만들어져 DB에 전송된다.
일반적으로 flush()가 호출되는 시점은 트랜잭션 커밋 시와 JPQL 쿼리 실행 시이다.
Entity를 수정할 때마다 매번 쿼리를 전송하면 DB에 Lock을 거는 시간이 길어지고, 의미없는 쿼리를 보내 불필요한 작업을 하게 된다.
따라서 Hibernate는 기본적으로 flush()를 통한 지연 쓰기 기능을 제공하고 있다. 기초적인 내용인데 공부가 부족했던 것 같다.

그런데 나는 flush()를 호출한 적도 없고, 트랜잭션을 끝낸 적도 없다. 당장 코드에 flush() 호출이 없고, 트랜잭션은 메소드 호출이 종료될 때 커밋된다. (@DataJpaTest에는 @Transactional이 박혀있으므로 테스트 메소드 하나가 트랜잭션이다)
그럼 로그에 적힌 쿼리는 언제 만들어진 것일까?
Breakpoint를 걸어 확인해보니 repository.findAll() 호출 시 만들어진다. 하긴, Entity를 찾을 때 영속성 컨텍스트에서만 찾는건 말이 안 된다..

다시 돌아와서, 결국 flush()가 호출되지 않으면 쿼리가 만들어지지 않는다. 현재 상황에서는 연관 관계를 만들고 끊는 쿼리가 전혀 생기지 않고 있다. 그럼 flush()를 호출해보면 되지 않을까?

해결

테스트 코드가 성공했다! 쿼리도 원하는 대로 만들어진다.
원인은 flush()의 부재였다. Mother에 Child를 추가하고 바로 지우면 Mother에 변경 사항이 없는 셈이고, Child는 Mother와 제대로 관계를 맺은 적이 없어 Orphan으로 취급되지 않은 듯 하다.

그러나 한 가지 남는 의문은, 개발자가 항상 제때 flush()를 걸어줘야 하느냐이다. flush()가 어느 부분에서 필요하고, 어느 부분에서 없어도 되는지 알 수 있을까?
테스트 코드라면 쉽게 알 수 있다고 해도 비즈니스 로직이라면 조금 어려울 지도 모른다. 그러나, 비즈니스 로직에서 관계를 맺다 끊을 일이 많을 것 같진 않다..

가급적 Given 코드 이후 영속성 컨텍스트를 비우는 방향으로 하고, 코드를 짧게 쓰고 싶다면 flush()를 호출하기로 해야겠다.

결론

flush() 호출 전 변경 사항은 최적화된다. 연관 관계를 맺는 과정도 쿼리 최적화로 인해 무시될 수 있으므로 주의해야 한다.
연관 관계를 맺은 이후에는 가급적 영속성 컨텍스트를 초기화하거나 flush() 호출을 고려해보자.

반성

영속성 컨텍스트에 대한 이해가 부족했던 것 같다. 이론 공부에 조금 더 집중할 필요가 있다.

profile
🦈

0개의 댓글