oguri's garage

JPA Entity에서 Lombok @EqualsAndHashCode 사용 시 주의사항 본문

개발하다/Spring

JPA Entity에서 Lombok @EqualsAndHashCode 사용 시 주의사항

oguri 2025. 10. 24. 23:27



들어가며

JPA Entity를 개발하면서 Lombok의 @EqualsAndHashCode를 무심코 사용했다가 예상치 못한 버그를 경험했다.
그래서 개발 중 발생할 수 있는 컬렉션에 Entity를 넣고 영속화한 후 찾을 수 없는 문제, 무한 루프로 인한 StackOverflowError 등 다양한 이슈를 정리해보았다.

 

 

 




문제 1: 양방향 연관관계에서의 무한 루프

발생 상황

User와 Order가 양방향 연관관계로 설정되어 있고, 두 Entity 모두 @EqualsAndHashCode를 사용하는 경우다.

@Entity
@EqualsAndHashCode
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();
}

@Entity
@EqualsAndHashCode
public class Order {
    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    private User user;
}

 

이 상태에서 user.equals(otherUser)를 호출하면 StackOverflowError가 발생한다.
User의 equals()가 orders를 비교하고, Order의 equals()가 user를 비교하면서 무한히 순환하기 때문이다.



해결 방법

연관관계 필드를 exclude로 명시적으로 제외한다.

@Entity
@EqualsAndHashCode(exclude = {"orders"})
public class User {
    // ...
}

@Entity
@EqualsAndHashCode(exclude = {"user"})
public class Order {
    // ...
}

 

 

 




문제 2: GeneratedValue ID와 HashSet의 함정

발생 상황

ID가 @GeneratedValue로 설정된 Entity를 HashSet에 넣고, 영속화 후 다시 찾으려고 하면 찾을 수 없는 문제가 발생했다.

User user = new User("홍길동");  // id = null
Set<User> users = new HashSet<>();
users.add(user);

System.out.println(users.contains(user));  // true

userRepository.save(user);  // id = 1로 변경됨

System.out.println(users.contains(user));  // false! 못 찾음



원인 분석

HashSet은 내부적으로 hashCode()를 기반으로 버킷에 객체를 저장한다. ID가 null일 때와 1일 때의 hashCode()가 달라지면서, 다른 버킷 위치를 찾게 되어 객체를 찾을 수 없게 된다.



해결 방법 1: ID 기반이지만 안전하게

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof User)) return false;
        User user = (User) obj;
        return id != null && id.equals(user.id);  // null 체크 필수
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();  // 불변값 사용
    }
}



해결 방법 2: 비즈니스 키 사용

변하지 않는 고유값(이메일, 주민번호 등)을 equals/hashCode의 기준으로 사용한다.

@Entity
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @EqualsAndHashCode.Include
    @Column(unique = true, nullable = false)
    private String email;  // 비즈니스 키
}



해결 방법 3: UUID 사용

UUID를 ID로 사용하면 객체 생성 시점에 즉시 ID가 할당되어 hashCode가 안정적이다.

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;  // 생성 시점에 즉시 할당

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof User)) return false;
        return id != null && id.equals(((User) obj).id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

 

 

 




문제 3: Lazy Loading과 LazyInitializationException

발생 상황

지연 로딩(Lazy Loading)으로 설정된 필드를 equals()에서 비교하려고 할 때, 트랜잭션이 종료된 후라면 LazyInitializationException이 발생한다.

@Entity
@EqualsAndHashCode
public class User {
    @OneToMany(fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

// 트랜잭션 밖에서
user1.equals(user2);  // LazyInitializationException 발생



해결 방법

Lazy Loading 필드는 반드시 제외한다.

@Entity
@EqualsAndHashCode(exclude = {"orders"})
public class User {
    @OneToMany(fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

 

 

 




실무 권장 방법

가장 안전한 방법: 수동 구현

Lombok을 사용하지 않고 직접 equals/hashCode를 구현하는 것이 가장 안전하다.

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @OneToMany(mappedBy = "user")
    private List<Order> orders = new ArrayList<>();

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof User)) return false;
        User user = (User) obj;
        return id != null && id.equals(user.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}


이 방법의 장점
:

  • ID가 null이어도 안전하다
  • hashCode()가 불변이라 컬렉션에서 안전하다
  • 코드가 명확하고 예측 가능하다



Lombok을 사용한다면

비즈니스 키가 있는 경우에만 신중하게 사용한다.

@Entity
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {
    @Id
    @GeneratedValue
    private Long id;

    @EqualsAndHashCode.Include
    @Column(unique = true, nullable = false)
    private String email;

    @OneToMany(mappedBy = "user")
    private List<Order> orders;  // 자동으로 제외됨
}

 

 

 




절대 하지 말아야 할 것들

1. 양방향 연관관계 포함

@EqualsAndHashCode  // 연관관계 제외 없음
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;  // 무한 루프 발생
}



2. 변경 가능한 필드 사용

@EqualsAndHashCode
public class User {
    private String name;  // Setter로 변경 가능

    public void setName(String name) {
        this.name = name;  // hashCode가 바뀜
    }
}



핵심 정리

  1. 양방향 연관관계 필드는 반드시 제외한다 - 무한 루프 방지
  2. @GeneratedValue ID는 신중하게 사용한다 - null 체크 필수, hashCode는 불변값 사용
  3. Lazy Loading 필드는 제외한다 - LazyInitializationException 방지
  4. 가장 안전한 방법은 수동 구현이다 - ID 기반 equals(), getClass().hashCode()
  5. 비즈니스 키가 있다면 적극 활용한다 - 영속화 전후 안정적