JPA Entity에서 Set 사용 시 equals, hashCode 구현과 해시 충돌 해결기

2025. 8. 18. 01:35·JPA

서론

Hibernate 6로 업그레이드하면서 기존에 잘 동작하던 코드에서 성능 문제가 발생했다. 문제의 원인은 JPA Entity에서 Set 컬렉션을 사용할 때 equals와 hashCode를 제대로 구현하지 않아서 생긴 해시 충돌이었다. 특히 클래스 기반 hashCode 구현으로 인해 모든 같은 타입의 Entity가 동일한 해시값을 가지면서 HashSet이 O(n) 성능으로 동작하는 치명적인 문제를 겪었다. 오늘은 이 문제의 원인과 해결 과정을 정리해보자.

본론

문제 상황: 클래스 기반 hashCode의 함정

처음에는 JPA Buddy가 생성해주는 equals, hashCode를 그대로 사용했다. JPA Buddy를 선택한 이유는 다음과 같았다:

JPA Buddy를 사용한 이유:
- Hibernate 프록시 문제를 고려한 안전한 구현 제공
- ID기반 비교의 문제점(새 Entity의 null ID)을 비즈니스 키로 해결
- 개발자가 직접 구현할 때 놓치기 쉬운 엣지 케이스들을 처리
- 코드 생성 속도와 일관성 확보

 

하지만 JPA Buddy의 기본 hashCode 구현에는 성능상 치명적인 문제가 숨어있었다.

Hibernate 프록시란?

먼저 코드를 이해하기 위해 Hibernate의 프록시 개념을 간단히 알아보자. Hibernate는 지연 로딩을 위해 실제 Entity 대신 프록시 객체를 생성한다.

// 지연 로딩 시 실제로는 프록시 객체가 반환됨
ExamSubject subject = entityManager.getReference(ExamSubject.class, 1L);
System.out.println(subject.getClass().getName());
// com.example.ExamSubject$HibernateProxy$... 이런 식으로 나온다

// 실제 클래스 정보를 얻으려면
Class<?> realClass = subject instanceof HibernateProxy
    ? ((HibernateProxy) subject).getHibernateLazyInitializer().getPersistentClass()
    : subject.getClass();
// 이제야 com.example.ExamSubject가 나온다

이런 프록시 문제 때문에 단순히 getClass()로 비교하면 같은 Entity임에도 불구하고 다르다고 판별될 수 있다.

JPA Buddy는 이런 복잡성을 처리해준다.

@Entity
public class ExamSubject {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "exam_session_id")
    private ExamSession examSession;
    
    @ManyToOne
    @JoinColumn(name = "cert_subject_id")
    private CertSubject certSubject;
    
    // JPA Buddy가 생성한 클래스 기반 hashCode
    @Override
    public final int hashCode() {
        return this instanceof HibernateProxy 
            ? ((HibernateProxy) this).getHibernateLazyInitializer()
                .getPersistentClass().hashCode()
            : getClass().hashCode();
    }
    
    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        
        Class<?> oEffectiveClass = o instanceof HibernateProxy
            ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
            : o.getClass();
        Class<?> thisEffectiveClass = this instanceof HibernateProxy
            ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
            : this.getClass();
            
        if (thisEffectiveClass != oEffectiveClass) return false;
        
        ExamSubject that = (ExamSubject) o;
        
        // 비즈니스 키 기반 equals
        Long thisCertSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long thatCertSubjectId = that.getCertSubject() != null 
            ? that.getCertSubject().getId() : null;
            
        return Objects.equals(thisCertSubjectId, thatCertSubjectId);
    }
}

문제 발견: 빈번한 해시 충돌

// 모든 ExamSubject 객체가 같은 hashCode를 가짐
ExamSubject subject1 = new ExamSubject(session1, cert1);
ExamSubject subject2 = new ExamSubject(session2, cert2);
ExamSubject subject3 = new ExamSubject(session3, cert3);

subject1.hashCode() == subject2.hashCode() == subject3.hashCode(); // TRUE

// HashSet 성능이 O(n)으로 저하
Set<ExamSubject> subjects = new HashSet<>();
subjects.add(subject1); // 버킷[123] = [subject1]
subjects.add(subject2); // 버킷[123] = [subject1, subject2]
subjects.add(subject3); // 버킷[123] = [subject1, subject2, subject3]

// contains 연산이 선형 탐색으로 동작
subjects.contains(subject2); // O(n) 시간 복잡도

해시 충돌로 인한 실제 성능 문제

실제 운영 환경에서 ExamSession에 여러 ExamSubject를 관리하는 Set 컬렉션이 있었는데, 데이터가 많아질수록 성능이 급격히 저하되었다.

@Entity
public class ExamSession {
    @OneToMany(mappedBy = "examSession", cascade = ALL, orphanRemoval = true)
    private Set<ExamSubject> examSubjects = new HashSet<>();
    
    // 성능 문제 발생 지점
    public void addCertSubject(CertSubject certSubject) {
        // contains 체크가 O(n)으로 동작
        boolean exists = examSubjects.stream()
            .anyMatch(es -> es.getCertSubject().equals(certSubject));
            
        if (exists) {
            throw new BusinessException("이미 등록된 자격종목입니다");
        }
        
        ExamSubject examSubject = ExamSubject.of(this, certSubject);
        examSubjects.add(examSubject); // 해시 충돌로 인한 성능 저하
    }
}

equals/hashCode 계약 규칙 이해하기

문제를 해결하기 전에 기본 원칙부터 정리했다. Java의 equals/hashCode 계약은 다음과 같다:

핵심 계약:
- equals가 true를 반환하면 hashCode도 반드시 동일해야 함 (필수)
- hashCode가 같아도 equals는 false일 수 있음 (해시 충돌 허용)

이 계약을 어기면 HashMap, HashSet 등의 해시 기반 컬렉션에서 예상치 못한 동작이 발생한다.

// 필수 규칙: equals가 true면 hashCode도 반드시 동일해야 함
if (a.equals(b) == true) {
    then a.hashCode() == b.hashCode(); // 반드시 성립해야 함
}

// 역은 성립하지 않아도 됨 (해시 충돌 허용)
if (a.hashCode() == b.hashCode()) {
    then a.equals(b); // true일 수도, false일 수도 있음
}

올바른 구현 예시:

class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        // name과 age 둘 다 비교
        return Objects.equals(name, that.name) 
               && Objects.equals(age, that.age);
    }
    
    @Override
    public int hashCode() {
        // equals보다 적은 필드 사용 가능 (name만)
        return Objects.hash(name);
    }
}

Person p1 = new Person("김철수", 30);
Person p2 = new Person("김철수", 40);

p1.hashCode() == p2.hashCode(); // TRUE (같은 name)
p1.equals(p2);                  // FALSE (다른 age)
// 이건 정상적인 해시 충돌 상황이다

잘못된 구현 예시:

class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        // name만 비교
        return Objects.equals(name, that.name);
    }
    
    @Override
    public int hashCode() {
        // equals보다 많은 필드 사용 (문제!)
        return Objects.hash(name, age);
    }
}

Person p1 = new Person("김철수", 30);
Person p2 = new Person("김철수", 40);

p1.equals(p2);                  // TRUE (같은 name)
p1.hashCode() == p2.hashCode(); // FALSE (다른 age)
// 이건 계약 위반이다! equals가 true면 hashCode도 같아야 한다

해결 방법 1: 비즈니스 키 기반 hashCode

가장 효과적인 해결책은 비즈니스 키를 활용한 hashCode 구현이었다.

@Entity
public class ExamSubject {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "exam_session_id")
    private ExamSession examSession;
    
    @ManyToOne
    @JoinColumn(name = "cert_subject_id")
    private CertSubject certSubject;
    
    @Override
    public final boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        
        Class<?> oEffectiveClass = o instanceof HibernateProxy
            ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
            : o.getClass();
        Class<?> thisEffectiveClass = this instanceof HibernateProxy
            ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
            : this.getClass();
            
        if (thisEffectiveClass != oEffectiveClass) return false;
        
        ExamSubject that = (ExamSubject) o;
        
        // 비즈니스 키 기반 equals
        Long thisCertSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long thatCertSubjectId = that.getCertSubject() != null 
            ? that.getCertSubject().getId() : null;
            
        return Objects.equals(thisCertSubjectId, thatCertSubjectId);
    }
    
    @Override
    public final int hashCode() {
        // 비즈니스 키 기반 hashCode로 해시 충돌 최소화
        Long certSubjectId = this.getCertSubject() != null 
            ? this.getCertSubject().getId() : null;
        Long examSessionId = this.getExamSession() != null 
            ? this.getExamSession().getId() : null;
            
        return Objects.hash(certSubjectId, examSessionId);
    }
}

성능 개선 결과:

// 이제 각 객체가 다른 hashCode를 가짐
ExamSubject subject1 = new ExamSubject(session1, cert1); // hash: 1001
ExamSubject subject2 = new ExamSubject(session2, cert2); // hash: 1002  
ExamSubject subject3 = new ExamSubject(session3, cert3); // hash: 1003

// HashSet이 O(1) 성능으로 동작
Set<ExamSubject> subjects = new HashSet<>();
subjects.add(subject1); // 버킷[1001] = [subject1]
subjects.add(subject2); // 버킷[1002] = [subject2]
subjects.add(subject3); // 버킷[1003] = [subject3]

subjects.contains(subject2); // O(1) 시간 복잡도

해결 방법 2: UUID 기반 @Id 활용

새로 설계하는 Entity라면 @Id 필드 자체를 UUID로 사용하는 것도 좋은 방법이다.

@Entity
public class Product {
    @Id
    @GeneratedValue(generator = "UUID")
    @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;
    
    private String name;
    private BigDecimal price;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return Objects.equals(id, product.id);
    }
    
    @Override
    public int hashCode() {
        // UUID 기반 hashCode로 해시 충돌 최소화
        return Objects.hash(id);
    }
}

UUID @Id의 장점:

  • 별도 컬럼 추가 없이 해시 충돌 해결
  • 분산 환경에서 ID 충돌 방지
  • 예측 불가능한 ID로 보안성 향상

주의사항:

  • 성능상 Long 타입보다 다소 느림
  • 인덱스 크기가 더 큼
  • 기존 시스템에서는 마이그레이션 비용 고려 필요

해결 방법 3: List 사용 + 비즈니스 로직 검증

Set 대신 List를 사용하고 중복 검증을 비즈니스 로직으로 처리하는 방법도 있다.

@Entity
public class ExamSession {
    // Set 대신 List 사용
    @OneToMany(mappedBy = "examSession", cascade = ALL, orphanRemoval = true)
    private List<ExamSubject> examSubjects = new ArrayList<>();
    
    // 비즈니스 로직으로 중복 방지
    public void addCertSubject(CertSubject certSubject) {
        validateNotDuplicate(certSubject);
        
        ExamSubject examSubject = ExamSubject.of(this, certSubject);
        examSubjects.add(examSubject);
    }
    
    public void removeCertSubject(CertSubject certSubject) {
        examSubjects.removeIf(es -> es.getCertSubject().equals(certSubject));
    }
    
    private void validateNotDuplicate(CertSubject certSubject) {
        boolean exists = examSubjects.stream()
            .anyMatch(es -> es.getCertSubject().equals(certSubject));
            
        if (exists) {
            throw new BusinessException("이미 등록된 자격종목입니다: " + 
                certSubject.getName());
        }
    }
    
    // Set 스타일 조회 (필요 시)
    public Set<CertSubject> getCertSubjectSet() {
        return examSubjects.stream()
            .map(ExamSubject::getCertSubject)
            .collect(Collectors.toSet());
    }
}

Hibernate 성능 차이:

// Set은 add() 시 중복 체크를 위해 equals() 호출
examSession.getExamSubjects().add(newExamSubject);
// 메모리에서 기존 모든 객체와 equals() 비교
// 해시 충돌 시 O(n) 시간 복잡도

// List는 단순히 끝에 추가
examSession.getExamSubjects().add(newExamSubject);
// 중복 체크 없이 즉시 추가, O(1) 시간 복잡도

Set의 성능 문제 핵심:

  • Set.add()는 중복 방지를 위해 내부적으로 equals() 비교 수행
  • 해시 충돌이 발생하면 같은 버킷의 모든 객체와 equals() 비교 필요
  • 클래스 기반 hashCode로 인한 해시 충돌 시 O(n) 성능 저하

DB 레벨 중복 방지

마지막으로 데이터베이스 레벨에서도 중복을 완전히 차단할 수 있다.

@Entity
@Table(
    name = "exam_subject",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_exam_session_cert_subject",
            columnNames = {"exam_session_id", "cert_subject_id"}
        )
    }
)
public class ExamSubject {
    // 데이터베이스 레벨에서 중복 완전 차단
}

실제 테스트 코드로 검증하기

@Test
void hashCodeCollisionTest() {
    // 클래스 기반 hashCode (문제 상황)
    ExamSubject subject1 = new ExamSubject(session1, cert1);
    ExamSubject subject2 = new ExamSubject(session2, cert2);
    
    // 모든 객체가 같은 hashCode (문제 상황)
    assertThat(subject1.hashCode()).isEqualTo(subject2.hashCode());
    
    // 비즈니스 키 기반 hashCode (해결책)
    subject1.optimizeHashCode(); // 비즈니스 키 기반으로 변경
    subject2.optimizeHashCode();
    
    // 이제 다른 hashCode를 가짐 (해결됨)
    assertThat(subject1.hashCode()).isNotEqualTo(subject2.hashCode());
}

@Test
void setPerformanceTest() {
    Set<ExamSubject> subjects = new HashSet<>();
    
    // 1000개 추가
    for (int i = 0; i < 1000; i++) {
        subjects.add(new ExamSubject(session, certSubjects.get(i)));
    }
    
    long startTime = System.nanoTime();
    
    // contains 연산 100번 수행
    for (int i = 0; i < 100; i++) {
        subjects.contains(subjects.iterator().next());
    }
    
    long endTime = System.nanoTime();
    long duration = endTime - startTime;
    
    // 클래스 기반: 수 밀리초, 비즈니스 키 기반: 마이크로초
    System.out.println("Contains operation time: " + duration + " ns");
}

주의사항

  1. 불변 값 사용: hashCode에 사용되는 필드는 가능한 한 불변이어야 한다.
  2. null 체크: 비즈니스 키가 null일 수 있는 경우 적절한 처리가 필요하다.
@Override
public int hashCode() {
    Long certSubjectId = this.getCertSubject() != null 
        ? this.getCertSubject().getId() : null;
    Long examSessionId = this.getExamSession() != null 
        ? this.getExamSession().getId() : null;
        
    // null 안전 처리
    return Objects.hash(certSubjectId, examSessionId);
}
  1. 연관 관계 필드 주의: 연관 관계 필드를 직접 hashCode에 사용하면 지연 로딩 문제가 발생할 수 있다.

결론

JPA Entity에서 Set 컬렉션을 사용할 때 equals와 hashCode 구현은 단순히 정상 동작만 보장하면 되는 것이 아니다. 성능까지 고려한 적절한 구현이 필요하다.

클래스 기반 hashCode는 안전하지만 모든 같은 타입 객체가 동일한 해시값을 가져서 HashSet의 O(1) 성능을 O(n)으로 만드는 치명적인 문제가 있다. 반면 비즈니스 키 기반 hashCode는 안전성과 성능을 모두 확보할 수 있는 최적의 해결책이다.

따라서 Entity 설계 시점부터 Set 컬렉션 사용 시 적절한 equals, hashCode 구현을 고려하는 것이 중요하다. 특히 해시 충돌이 빈번하게 발생할 수 있는 환경에서는 성능에 미치는 영향이 더욱 클 수 있다.

참고자료

  • Hibernate Documentation - Implementing equals() and hashCode()
  • Vlad Mihalcea - The best way to implement equals, hashCode, and toString with JPA and Hibernate
  • Baeldung - JPA Entity Equality
  • JPA Buddy가 생성해주는 메소드 살펴보기

'JPA' 카테고리의 다른 글

[JPA] No EntityManager with actual transaction available for current thread - cannot reliably process 'flush' call  (2) 2024.09.16
[JPA] cascade = CascadeType.REMOVE, orphanRemoval = true 두 옵션을 명시적으로 사용하는 이유  (1) 2024.01.28
'JPA' 카테고리의 다른 글
  • [JPA] No EntityManager with actual transaction available for current thread - cannot reliably process 'flush' call
  • [JPA] cascade = CascadeType.REMOVE, orphanRemoval = true 두 옵션을 명시적으로 사용하는 이유
옐리yelly
옐리yelly
  • 옐리yelly
    개발 갤러리
    옐리yelly
  • 전체
    오늘
    어제
    • 모든 글 보기 (85)
      • Project (22)
      • Java (4)
      • Spring (8)
      • Kubernetes (6)
      • Docker (2)
      • JPA (3)
      • Querydsl (2)
      • MySQL (9)
      • ElasticSearch (7)
      • DevOps (4)
      • Message Broker (3)
      • Git & GitHub (2)
      • Svelte (1)
      • Python (8)
        • Python Distilled (4)
        • Anaconda (1)
        • Django (0)
        • pandas (3)
      • Algorithm (1)
      • Computer Science (0)
      • 내 생각 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    RabbitMQ
    비사이드
    gitops
    데드락
    blue-green 배포
    argocd
    docker
    커넥션 풀
    Spring
    JPA
    Project
    ncloud
    pymysql
    k8s
    프로젝트
    성능 테스트
    리팩토링
    pandas
    querydsl
    Message Broker
    예약 시스템
    devops
    mybatis
    nks
    OOP
    Python
    svelte
    elasticsearch
    포텐데이
    MySQL
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
옐리yelly
JPA Entity에서 Set 사용 시 equals, hashCode 구현과 해시 충돌 해결기
상단으로

티스토리툴바