[삽질 로그] JPA의 Set 연관 관계에서 contains와 remove가 고장나는 문제

woodyn·2021년 2월 1일
0

삽질 로그

목록 보기
1/5
post-thumbnail

문제 정의

@EqualsAndHashCode(of = "id")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@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);
    }

}

@EqualsAndHashCode(of = "id")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Child {

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

    @ToString.Exclude
    @ManyToOne
    private Mother mother;
    
}

@DataJpaTest
class MotherTest {

    @Autowired
    private MotherRepository motherRepository;

    @Autowired
    private ChildRepository childRepository;

    @Test
    void containsChild() {
        Mother mother = new Mother();
        mother.addChild(new Child());
        mother = motherRepository.save(mother);

        Child child = childRepository.findAll().get(0);

        assertThat(mother.getChildren()).isNotEmpty(); // Success
        assertThat(mother.getChildren().stream().findFirst().get()).isEqualTo(child); // Success
        assertTrue(mother.getChildren().contains(child)); // Failure
    }

}

위 코드에서 테스트가 실패했다.
Mother 클래스에 Child를 추가했는데, contains(child)가 false라고 한다.
심지어 children에는 해당 child가 잘 들어있는 것이 확인된다.
이게 뭐고..??

원인 파악 과정

디버거 모드로 파헤치기

Mother의 children은 엔티티 컬렉션이므로 PersistentSet으로 구현된다.
혹시 PersistentSet이 원인인게 아닐까 싶었지만, 내부적으로는 평범한 자바의 HashSet을 사용하는 것으로 보인다.
따라서 PersistentSet은 무고하고, 문제는 HashSet에서 분명히 들어있는 원소를 contains로는 파악해낼 수 없다는 것으로 정리된다.
왜 멀쩡한 HashSet이 말썽인걸까..?

HashSet의 구현 방식

디버거 모드에서 더 내려가보면서 알게 된 사실인데, HashSet은 HashMap으로 구현되고 있었다. (찾아보니 JDK 공식 문서에도 적혀 있었다)
HashMap은 배열 기반으로 작동한다. 혹시나 해서 확인해봤지만, 역시나 HashMap 내부 배열에는 원하는 원소가 잘 들어있었다.
HashSet의 contains는 내부적으로 HashMap의 contains를 호출한다. 그럼 HashMap의 contains는 어떻게 작동할까? 분명 Hashing을 통해 원소를 찾을 것이다. 순차적으로 탐색할 리가 없다.
그러고보니 Entity 저장 시 해시 코드가 바뀔 수 있지 않을까? 해시 코드가 바뀌면 해싱 기반 컬렉션이 고장나지 않을까?

Child의 해시 코드 확인

repository.save() 이후 해시 코드가 바뀐다!
생각해보면 당연하다. Child 클래스는 id 필드만으로 해시 코드를 생성해내도록 했는데, Persistent 상태가 되면서 id가 발급되고, id 값의 변경으로 해시 코드 값도 바뀌는 것이다.
해시 코드 값이 바뀌면 같은 객체를 해싱했을 때 다른 인덱스를 반환할 것이다. 따라서 contains가 고장나게 된다!

해결

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

  1. Mother의 children에 Child를 추가한다. 이때 두 Entity는 모두 Transient 상태이며 id를 발급받지 못 했다.
  2. repository.save()로 인해 Mother가 Persist된다.
  3. Mother 내 children 필드의 CascadeType.PERSIST로 인해 Mother의 상태가 Child로 전이된다.
  4. Child가 Persist되어 새롭게 id 값을 갖는다. Child 클래스의 해시 코드는 id 필드를 기반으로 생성하므로, 해시 코드가 갑자기 바뀐다.
  5. HashSet의 contains는 원소의 해시 코드를 통해 원소 존재 유무를 판단한다. 해시 코드가 갑자기 바뀌었으나, 여전히 HashSet은 기존 인덱스에 원소를 기록하고 있고, contains는 새로운 인덱스를 기준으로 탐색하므로 원소를 찾을 수 없다.
    • 추가로 확인해보니 remove도 작동하지 않았다.

따라서 다음과 같이 코드를 변경했다:

@DataJpaTest
class MotherTest {

    @Autowired
    private MotherRepository motherRepository;

    @Autowired
    private ChildRepository childRepository;

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

        assertThat(mother.getChildren()).isNotEmpty();
        assertThat(mother.getChildren().stream().findFirst().get()).isEqualTo(child);
        assertTrue(mother.getChildren().contains(child));
    }

}

id가 바뀌기 전에 HashSet에 넣으면 문제가 발생한다. 따라서 영속화 이후 연관 관계를 맺는 방향으로 바꿨다.

// @EqualsAndHashCode(of = "id")
// @Data --> @Getter, @Setter, @ToString
@Getter
@Setter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Child {

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

    @ToString.Exclude
    @ManyToOne
    private Mother mother;
    
}

혹은 동일성 메소드를 재정의하지 않는 것도 방법이다.
사실 생각해보면 영속성 컨텍스트에 있는 이상 동일성이 보장되므로, 재정의 메소드가 필요한 경우는 관리되지 않은 Entity를 다루는 경우 뿐이다.
그러한 경우가 없다면, 무턱대고 @Data를 박지 말고 동일성 메소드를 제거하도록 하자.

결론

HashSet에 넣어둔 객체의 해시 코드가 바뀌면 contains와 remove가 고장난다.
따라서 해시 코드가 바뀌지 않도록 영속화 후 연관 관계를 맺거나, 특별한 이유가 없다면 동일성 메소드를 재정의하지 말자.

profile
🦈

0개의 댓글