Categories
웹개발/Java
Java equals hashCode 차이, HashMap과 Set에서 문제가 되는 이유
HashMap에 값을 넣었는데 같은 값으로 조회해도 null이 나오거나, HashSet이 같은 객체처럼 보이는 데이터를 중복으로 담는다면 먼저 equals()와 hashCode()를 확인해야 한다. 데이터가 사라진 것이 아니라, 객체를 비교하고 찾는 기준이 컬렉션이 기대하는 방식과 맞지 않는 경우가 많다.
이 문제의 중심에 equals()와 hashCode()가 있다. 둘은 단순히 객체 비교 문법이 아니라, HashMap, HashSet, HashSet 기반 중복 제거, 캐시 키, DTO 비교에서 실제 동작을 바꾸는 기준이다. 한쪽만 구현하거나, 비교 기준이 바뀌는 필드를 키로 쓰면 실무에서 원인 찾기 어려운 버그가 된다.
핵심 결론: 객체를 값 기준으로 비교하려면 equals와 hashCode를 같은 기준으로 함께 구현해야 한다.
확인 순서: == 비교 여부 → equals 오버라이드 여부 → hashCode 오버라이드 여부 → 비교에 쓰는 필드 변경 가능성 → HashMap/HashSet 사용 위치.
==, equals, hashCode는 역할이 다르다
먼저 세 가지를 분리해서 봐야 한다. ==는 두 변수가 같은 객체를 가리키는지를 본다. equals()는 개발자가 정의한 값 비교 기준이다. hashCode()는 해시 기반 컬렉션이 객체를 빠르게 찾기 위해 사용하는 정수값이다.
문자열에서는 equals()를 쓰라는 이야기를 많이 듣지만, 직접 만든 객체에서는 더 헷갈린다. 클래스에 아무것도 구현하지 않으면 equals()도 결국 Object의 기본 동작을 따른다. Object의 기본 equals()는 참조 비교와 비슷하게 동작한다. 즉, 필드 값이 같아도 서로 다른 객체면 같다고 보지 않는다.
실무에서는 이 차이가 DTO 비교, Map 키, Set 중복 제거에서 바로 드러난다. 화면에서 넘어온 사용자 ID와 DB에서 조회한 사용자 ID가 문자열이면 내용 비교가 쉽지만, 여러 필드를 가진 객체를 키로 쓰는 순간 "어떤 필드가 같으면 같은 객체인가"를 직접 정해야 한다.
equals만 구현하면 왜 부족한가
값은 같은데 HashSet에서 중복 제거가 안 되는 경우
아래 코드는 사용자 ID가 같으면 같은 사용자라고 보고 싶어서 equals()만 구현한 예다.
public class UserKey {
private final String userId;
public UserKey(String userId) {
this.userId = userId;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof UserKey)) return false;
UserKey other = (UserKey) obj;
return userId.equals(other.userId);
}
}
이 클래스는 equals()로 보면 같은 값처럼 보인다. 하지만 hashCode()를 같이 구현하지 않았기 때문에 HashSet에서는 기대와 다르게 동작할 수 있다.
Set<UserKey> set = new HashSet<>();
set.add(new UserKey("u100"));
set.add(new UserKey("u100"));
System.out.println(set.size()); // 1이 아닐 수 있음
HashSet은 먼저 해시값으로 위치를 찾고, 그 안에서 equals()로 같은지 확인한다. 그런데 같은 값이라고 판단되는 객체들이 서로 다른 해시값을 내면 같은 위치에서 비교되지 않을 수 있다. 그래서 equals만 구현하면 "값은 같은데 중복 제거가 안 되는" 현상이 생긴다.
HashMap에서 put한 값을 get하지 못하는 경우
HashMap에서도 같은 문제가 나온다. 키 객체를 새로 만들어 조회했는데 값이 안 나오는 상황이다.
Map<UserKey, String> names = new HashMap<>();
names.put(new UserKey("u100"), "Kim");
String name = names.get(new UserKey("u100"));
System.out.println(name); // null이 나올 수 있음
코드를 보는 사람은 userId가 같으니 "Kim"이 나와야 한다고 생각한다. 하지만 HashMap은 같은 키인지 판단할 때 해시값과 equals를 함께 사용한다. equals와 hashCode가 같은 기준으로 구현되어 있지 않으면 put할 때와 get할 때 서로 다른 위치를 보게 된다.
이 문제는 캐시 키, 권한 키, 복합 검색 조건 객체에서 자주 나온다. 문자열 하나를 키로 쓰면 문제가 잘 드러나지 않지만, 여러 필드를 묶은 객체를 키로 쓰는 순간 equals와 hashCode 구현이 중요해진다.
자주 놓치는 원인: equals는 "같다"는 업무 기준이고, hashCode는 해시 컬렉션이 그 객체를 찾는 위치 기준이다. 둘이 같은 필드를 기준으로 움직이지 않으면 컬렉션이 객체를 찾지 못한다.
equals와 hashCode의 기본 계약
실무에서 외워야 할 규칙은 길지 않다. 아래 세 가지 정도만 손에 익혀도 대부분의 버그를 피할 수 있다.
- 두 객체가
equals()로 같다면hashCode()값도 같아야 한다. - 두 객체의
hashCode()가 같다고 해서 반드시equals()가 true일 필요는 없다. - equals와 hashCode에 사용하는 필드는 같은 기준이어야 한다.
첫 번째가 가장 중요하다. equals로 같은 객체인데 hashCode가 다르면 HashMap과 HashSet이 깨진다. 반대로 hashCode가 같아도 equals가 false일 수는 있다. 해시 충돌이 가능하기 때문이다. 이 경우 컬렉션은 같은 버킷 안에서 equals로 다시 비교한다.
| 상황 | equals 결과 | hashCode 기준 | 컬렉션 영향 |
|---|---|---|---|
| 둘 다 같은 필드 사용 | 같음 | 같음 | HashMap/HashSet 정상 동작 |
| equals만 구현 | 같음 | 다를 수 있음 | get 실패, 중복 제거 실패 가능 |
| hashCode만 구현 | 기본 참조 비교 | 같을 수 있음 | 값 기준 비교가 되지 않음 |
| 변경 가능한 필드 사용 | 중간에 바뀜 | 중간에 바뀜 | put 후 get 실패 가능 |
| 불변 식별자 사용 | 안정적 | 안정적 | 키 객체로 쓰기 적합 |
정상 구현 예제
Objects.equals와 Objects.hash 사용
직접 구현할 때는 Objects.equals()와 Objects.hash()를 쓰면 null 처리까지 포함해 비교 기준을 맞추기 쉽다.
import java.util.Objects;
public class UserKey {
private final String userId;
private final String systemCode;
public UserKey(String userId, String systemCode) {
this.userId = userId;
this.systemCode = systemCode;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof UserKey)) return false;
UserKey other = (UserKey) obj;
return Objects.equals(userId, other.userId)
&& Objects.equals(systemCode, other.systemCode);
}
@Override
public int hashCode() {
return Objects.hash(userId, systemCode);
}
}
여기서 중요한 것은 equals와 hashCode가 같은 필드인 userId, systemCode를 사용한다는 점이다. equals에는 두 필드를 쓰고 hashCode에는 한 필드만 쓰면 동작은 가능할 수 있지만, 해시 분포가 나빠지거나 의도를 읽기 어려워진다.
HashMap에서 확인하기
위처럼 구현하면 같은 값으로 새 키 객체를 만들어도 조회가 된다.
Map<UserKey, String> names = new HashMap<>();
names.put(new UserKey("u100", "ADMIN"), "Kim");
String name = names.get(new UserKey("u100", "ADMIN"));
System.out.println(name); // Kim
이 차이는 작은 예제에서는 단순해 보이지만, 실무에서는 캐시 조회 실패나 중복 데이터 생성으로 이어질 수 있다. 특히 배치에서 사용자별 합계, 도시별 집계처럼 키를 만들어 그룹핑하는 코드는 키 객체의 비교 기준이 중요하다. 대량 데이터 집계 흐름은 자바 MapReduce 집계 예제처럼 키 기준이 결과를 좌우하는 코드에서 더 쉽게 감이 잡힌다.
변경 가능한 필드를 키로 쓰면 위험하다
put 이후 필드가 바뀌는 경우
equals와 hashCode를 잘 구현했더라도, 키 객체에 사용한 필드가 중간에 바뀌면 문제가 생긴다.
public class UserKey {
private String userId;
public UserKey(String userId) {
this.userId = userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof UserKey)) return false;
UserKey other = (UserKey) obj;
return Objects.equals(userId, other.userId);
}
@Override
public int hashCode() {
return Objects.hash(userId);
}
}
이 객체를 HashMap 키로 넣은 뒤 userId를 바꾸면 어떻게 될까.
UserKey key = new UserKey("u100");
Map<UserKey, String> map = new HashMap<>();
map.put(key, "Kim");
key.setUserId("u200");
System.out.println(map.get(key)); // null이 나올 수 있음
HashMap은 put할 때의 해시값을 기준으로 저장 위치를 잡는다. 그런데 이후 키 객체의 hashCode가 바뀌면 get할 때 다른 위치를 찾게 된다. 객체는 map 안에 있지만 찾지 못하는 이상한 상태가 된다.
그래서 HashMap 키로 쓰는 객체는 가능하면 불변으로 만든다. 필드는 final로 두고 setter를 만들지 않는 방식이 안전하다. DTO처럼 값이 계속 바뀌는 객체를 그대로 키로 쓰는 것은 피하는 편이 낫다.
주의할 점: equals와 hashCode에 포함된 필드는 컬렉션에 넣은 뒤 바뀌지 않아야 한다. 바뀔 수 있는 필드를 키 기준으로 쓰면 저장한 값을 다시 찾지 못할 수 있다.
Lombok을 사용할 때 확인할 부분
Lombok의 @EqualsAndHashCode나 @Data를 쓰면 equals와 hashCode를 자동으로 만들 수 있다. 편하지만, 어떤 필드가 비교 기준에 들어가는지 확인하지 않고 쓰면 의도와 다른 비교가 된다.
@EqualsAndHashCode
public class UserDto {
private String userId;
private String name;
private String lastLoginTime;
}
위 코드에서 모든 필드가 비교 기준에 들어가면, userId가 같아도 이름이나 마지막 로그인 시간이 다르면 다른 객체로 판단될 수 있다. 업무적으로 같은 사용자를 판단하는 기준이 userId라면 비교 기준을 제한해야 한다.
@EqualsAndHashCode(of = "userId")
public class UserDto {
private String userId;
private String name;
private String lastLoginTime;
}
다만 DTO 전체에 무조건 equals와 hashCode를 넣는 것도 좋은 습관은 아니다. DTO는 데이터 전달용이고, 비교 기준은 상황마다 다를 수 있다. 컬렉션 키나 중복 제거 기준으로 실제 사용할 때만 필요한 기준을 명확히 정하는 편이 안전하다.
DB 조회 객체와 equals/hashCode
MyBatis로 조회한 객체를 Set에 넣거나 Map 키로 쓰는 경우도 있다. 이때 DB 컬럼과 Java 필드 매핑이 맞지 않으면 비교 기준 필드가 null로 들어와 예상과 다른 결과가 나온다.
UserDto user = userMapper.selectUser("u100");
Set<UserDto> users = new HashSet<>();
users.add(user);
만약 equals/hashCode 기준이 userId인데, SQL alias나 resultMap 설정 문제로 userId가 null이라면 중복 제거 기준도 흔들린다. 객체 비교 문제처럼 보이지만 실제 원인은 매핑일 수 있다. 이 경우는 MyBatis 매핑 차이에서 resultType과 resultMap 기준을 먼저 확인하는 것이 빠르다.
반대로 Mapper 인터페이스 자체가 주입되지 않거나 조회가 실행되지 않는 상황이라면 객체 비교 이전 단계다. 이런 경우는 MapperScan과 MyBatis 연동 설정을 먼저 확인해야 한다.
실무에서 바로 쓰는 점검 순서
- HashMap에서 get이 안 되면 키 객체의 equals와 hashCode가 둘 다 구현되어 있는지 확인한다.
- HashSet 중복 제거가 안 되면 equals 기준과 hashCode 기준이 같은 필드인지 확인한다.
- 컬렉션에 넣은 뒤 비교 기준 필드가 바뀌는지 확인한다.
- Lombok을 쓴다면 어떤 필드가 비교 기준에 들어갔는지 확인한다.
- DB 조회 객체라면 비교 기준 필드가 실제로 매핑되어 들어왔는지 확인한다.
- 키 객체는 가능하면 불변으로 만든다.
실무에서 바로 쓰는 팁
- Map 키 객체는 불변으로 만든다. 키 기준 필드가 바뀌면 저장한 값을 다시 찾지 못할 수 있다.
- equals와 hashCode는 같은 필드 기준으로 만든다. 둘의 기준이 다르면 컬렉션에서 버그가 난다.
- DTO에 자동 생성된 equals를 맹신하지 않는다. 모든 필드가 비교 기준에 들어가면 업무 기준과 달라질 수 있다.
- Set 중복 제거가 안 되면 데이터보다 비교 기준을 먼저 본다. 같은 값처럼 보여도 객체 기준으로는 다를 수 있다.
- DB 조회 객체는 매핑 필드를 먼저 확인한다. 비교 기준 필드가 null이면 equals/hashCode도 기대대로 동작하지 않는다.
자주 묻는 질문
- Q. equals만 오버라이드하면 안 되나요? A. HashMap이나 HashSet처럼 해시 기반 컬렉션에서 사용할 수 있다면 hashCode도 같이 오버라이드해야 합니다. equals로 같으면 hashCode도 같아야 하기 때문입니다.
- Q. hashCode가 같으면 equals도 true인가요? A. 아닙니다. hashCode가 같아도 다른 객체일 수 있습니다. 해시 충돌이 가능하므로 최종 비교는 equals로 판단합니다.
- Q. HashMap에서 같은 값으로 get했는데 null이 나오는 이유는 무엇인가요? A. 키 객체의 equals와 hashCode가 같은 기준으로 구현되지 않았거나, put 이후 키 필드가 바뀌었을 가능성이 있습니다.
- Q. Lombok @Data를 쓰면 equals와 hashCode를 신경 쓰지 않아도 되나요? A. 아닙니다. 자동 생성은 편하지만 어떤 필드가 비교 기준에 들어가는지 확인해야 합니다. 업무 기준과 다르면 직접 제한해야 합니다.
- Q. DTO마다 equals와 hashCode를 만들어야 하나요? A. 무조건 만들 필요는 없습니다. DTO를 Set에 넣거나 Map 키로 쓰거나 값 기준 비교가 필요한 경우에만 기준을 정해 구현하는 편이 좋습니다.
- Q. equals와 hashCode에 어떤 필드를 넣어야 하나요? A. 객체를 같다고 볼 업무 기준에 해당하는 안정적인 필드를 넣어야 합니다. 중간에 바뀌는 필드는 피하는 것이 안전합니다.
정리
equals()와 hashCode()는 단순한 문법 문제가 아니라 Java 컬렉션이 객체를 찾고 중복을 판단하는 기준이다. 값이 같은데 HashMap에서 조회가 안 되거나 HashSet에서 중복 제거가 안 된다면 데이터보다 비교 기준을 먼저 확인해야 한다.
둘을 구현할 때는 같은 필드를 기준으로 삼고, 그 필드는 가능하면 바뀌지 않아야 한다. Lombok을 쓰더라도 어떤 필드가 비교 기준에 포함되는지 확인해야 하며, DB 조회 객체라면 매핑된 값이 실제로 들어왔는지도 같이 봐야 한다.
HashMap과 HashSet에서 이상한 동작을 만났다면, 먼저 객체가 같은지 묻는 기준과 객체를 찾는 기준이 같은 방향을 보고 있는지 확인한다.
'웹개발 > Java' 카테고리의 다른 글
| JSP pagination page link 만들기, 게시판 페이지 번호 처리 기준 (0) | 2026.07.11 |
|---|---|
| Java Optional 사용 기준, null 처리를 더 복잡하게 만들지 않는 방법 (0) | 2026.07.07 |
| Java NullPointerException 원인과 해결 방법, 실무에서 먼저 확인할 부분 (0) | 2026.07.06 |
| Java 기초 핵심 개념, 실무에서 자주 헷갈리는 부분 정리 (1) | 2026.07.06 |
| 자바 MapReduce로 대용량 데이터 추출하기: 날짜·도시 필터 + 사용자 합계 실전 예제 (1) | 2025.12.03 |