Spring Boot와 JPA(HIbernate)를 사용하면서 Entity 에 Set이나 Map 같은 컬렉션을 사용하시나요?
오늘은 JPA 사용 시 Set 을 사용하면서 발생한 ClassCastException에 대해서 정리하고자 합니다.
1. 문제상황
오늘 Spring Data JPA를 사용하는 환경에서 @OneToMany로 Set 컬렉션을 사용하다가 다음과 같은 에러를 만나게 되었습니다.
java.lang.ClassCastException: class java.util.HashSet cannot be cast to class org.hibernate.collection.spi.PersistentCollection
Hashset은 Hibernate의 PersistentCollection으로 전환 될 수 없음을 말하고 있습니다.
결론부터 말씀드리자면, 외부에서 엔티티의 Set을 데이터 재처리된 Set으로 대체하면서 발생한 문제인데요.
즉, 다음과 같은 상황에 발생한 케이스입니다.
public void foo() {
Example example = findById(1L);
ExampleItem exampleItem = findXXX();
Set<ExampleItem> filteredExmapleItems = exampleItem.stream()
.filter(ExampleItem::isXXX)
.collect(Collectors.toSet());
example.set(filteredExampleItems); // 에러의 원인
}
단순히, 맞는 자료구조에 매칭을 해주었는데 왜 발생한 것일까요?
2. Hibernate Collection

Hibernate는 엔티티의 Collection 필드(Set, List 등)를 단순한 데이터 컨테이너로 다루지 않습니다. 컬렉션을 프록시(proxy) 객체로 감싸서 세션, 지연 로딩, 매핑 정보를 관리하기 위해 PersistentCollection이라는 특별한 래퍼를 사용해요. 그렇기에, Hibernate가 엔티티를 조회할 때, Set<ExampleItem>을 선언해놨다고 하더라도 실제로는 HashSet을 바로 주입하지 않습니다.
대신 PersistentSet, PersistentBag 같은 PersistentCollection의 하위 클래스를 주입해서 컬렉션을 제어합니다. 이 프록시 컬렉션들은 평소에는 DB에 접근하지 않지만, 컬렉션의 메서드(get, size, iterator 등)를 호출하여 필요한 시점에만 데이터를 읽어오기 위해 DB 쿼리를 실행합니다. 즉, 지연 로딩(Lazy Loading) 을 효율적으로 구현하기 위해서 존재하는 컬렉션입니다.
추가적으로 이 프록시는 지연로딩과 같이 단순히 데이터를 읽어오는 데 그치지 않고, Hibernate 내부에서 다음과 같은 기능들을 담당하고 있습니다.
- 영속성 컨텍스트와의 동기화: 세션(Session)과 컬렉션 상태를 연결해 일관성 보장
- 변경 감지(Dirty Checking): 컬렉션 변경 이력을 추적해 flush 시점에 DB 반영
- 초기화 여부 추적: 아직 로딩되지 않은 컬렉션인지, 이미 초기화되었는지 구분 가능
따라서, 일반 HashSet 객체로 수정을 했다면 cascade, orphanRemoval 등 컬렉션에 필요한 모든 기능들을 사용하지 못하게 됩니다.
그럼, 위와 같이 Set를 사용하는 상황에서는 어떻게 전환을 해야할까요? 답은 생각보다 간단합니다.
3. 해결 방안
옛날에는 자바빈패턴으로 데이터를 다루기 위하여 Setter와 Getter를 많이 사용하고는 했는데요. 규모가 큰 코드에 롬복의 Setter를 제거하기 어려운 상황이라면 다음과 같이 Setter를 재정의 해주어야 합니다. 위의 다이어그램에서 보이듯이 PersistentSet은 Set을 구현하고 있기 때문에 원소 대체가 가능합니다.
public void setExampleItems(Set<ExmapleItem> exampleItems) {
this.exmapleItems.clear();
this.exampleItems.addAll(exampleItems);
}
Setter를 사용하고 있지 않다면 의미에 맞게 다음과 같이 메서드를 재정의해주는 것이 좋겠지요.
public void reviseExampleItems(Set<ExmapleItem> exampleItems) {
this.exmapleItems.clear();
this.exampleItems.addAll(exampleItems);
}
4. 결론
이번 문제의 원인은 Hibernate의 컬렉션 관리 방식에 있었습니다. Hibernate는 PersistentCollection이라는 프록시 객체를 주입해 세션, 지연 로딩, 변경 감지 등을 관리합니다. 따라서, 엔티티 컬렉션을 통째로 교체해버리면 Hibernate가 주입한 프록시가 깨지고,
다음과 같은 문제가 발생할 수 있습니다.
- ClassCastException 발생
- Lazy Loading 동작 실패
- Dirty Checking(변경 감지) 무력화
- orphanRemoval, cascade 기능 비활성화
따라서, 콜렉션을 사용하는 경우에는 컬렉션의 레퍼런스 자체를 바꾸지 않고, 프록시를 유지한 채 내부 데이터만을 교체하는 것이 중요하다는 것을 알아가게 되었습니다.
'Backend > JPA' 카테고리의 다른 글
| Spring Data JPA는 왜 @Repository를 사용하지 않아도 될까? (1) | 2023.05.14 |
|---|---|
| JPA 상속관계 매핑 (0) | 2023.05.01 |
| JPA 연관관계 매핑의 다중성 (0) | 2023.04.28 |
| 영속성 컨텍스트 (0) | 2023.04.25 |