| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- ReverseNested
- static-factory-method
- 글또10기
- builder-pattern
- 헥사고날 아키텍처
- 포트앤어댑터 아키텍처
- OpenSearch
- 글쓰기세미나
- 가용영역
- React
- 회고
- 다짐글
- 레벨1
- SpringBoot
- 클린 아키텍처
- axios
- HashMap
- 클라우드아키텍처
- QueryDSL
- UserLand
- design-pattern
- constructor
- 프로그래머스
- 코딩테스트
- DevOps
- 3계층 아키텍처
- Level2
- object-creation
- 글또
- 코엑스그랜드볼룸
- Today
- Total
oguri's garage
JPA Entity에서 Lombok @EqualsAndHashCode 사용 시 주의사항 본문
들어가며
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가 바뀜
}
}
핵심 정리
- 양방향 연관관계 필드는 반드시 제외한다 - 무한 루프 방지
- @GeneratedValue ID는 신중하게 사용한다 - null 체크 필수, hashCode는 불변값 사용
- Lazy Loading 필드는 제외한다 - LazyInitializationException 방지
- 가장 안전한 방법은 수동 구현이다 - ID 기반 equals(), getClass().hashCode()
- 비즈니스 키가 있다면 적극 활용한다 - 영속화 전후 안정적
'개발하다 > Spring' 카테고리의 다른 글
| Spring Boot 외부 설정 우선순위 정리 (0) | 2025.11.01 |
|---|---|
| Spring DTO 설계 시 기본 생성자만 두는 이유 (0) | 2025.10.22 |
| Spring Data JPA Native Query에서 만난 InvalidDataAccessApiUsageException (0) | 2025.10.21 |
| DB부터 시작하는 JPA Entity 매핑 정리 (0) | 2025.10.20 |
| JPA에서 DISTINCT가 필요한 경우와 필요하지 않은 경우 (0) | 2025.10.18 |