본문 바로가기
dev/Java

[Java] Day 5 - HashMap + equals/hashCode

by kota 2026. 9. 10.

Java/Spring 백엔드 개발자라면 반드시 이해해야 하는 주제 중 하나가 HashMap과 HashSet이다.

특히 면접에서 다음과 같은 질문이 자주 등장한다.

  • HashMap은 어떻게 데이터를 저장하는가?
  • Hash collision이란 무엇인가?
  • equals()와 hashCode()는 왜 같이 구현해야 하는가?
  • HashSet은 어떻게 중복을 판단하는가?
  • hashCode()가 같으면 같은 객체인가?
  • HashMap의 Key를 수정하면 왜 문제가 발생하는가?

이번 학습에서는 단순히 HashMap을 사용하는 방법이 아니라 HashMap 내부에서 데이터가 어떻게 저장되고 검색되는지를 이해하는 것을 목표로 했다.


1. hashCode()란?

hashCode()는 객체를 식별하기 위해 사용되는 정수값을 반환하는 메서드다.

모든 Java 객체는 Object 클래스를 상속하기 때문에 기본적으로 hashCode()를 가지고 있다.

public class User {

    private String name;

    public User(String name) {
        this.name = name;
    }
}

기본적으로 다음과 같이 호출할 수 있다.

User user = new User("Kim");

System.out.println(user.hashCode());

결과는 정수값이다.

12345678

중요한 점은 hashCode() 자체가 객체의 주소를 그대로 반환하는 것은 아니라는 것이다.

JVM과 클래스의 구현에 따라 값이 결정되며, 개발자가 hashCode()를 직접 오버라이딩할 수도 있다.


2. equals()란?

equals()는 두 객체가 논리적으로 같은 객체인지 비교하는 메서드다.

예를 들어 이름이 같은 User를 같은 사용자라고 판단하고 싶다고 해보자.

User user1 = new User("Kim");
User user2 = new User("Kim");

System.out.println(user1.equals(user2));

equals()를 별도로 구현하지 않았다면 일반적으로 false가 나온다.

왜냐하면 Object의 기본 equals()는 객체 자체의 동일성을 기준으로 비교하기 때문이다.

즉,

user1 == user2

와 같은 개념으로 동작한다.


3. equals()와 hashCode()의 관계 ⭐⭐⭐

HashMap과 HashSet을 이해하기 위해 가장 중요한 부분이다.

Java에서는 다음 규칙을 반드시 기억해야 한다.

equals()가 true라면 두 객체의 hashCode()는 반드시 같아야 한다.

즉,

equals() == true
        ↓
hashCode() == same

반대로,

hashCode() == same

이라고 해서

equals() == true

인 것은 아니다.

이 차이가 매우 중요하다.


4. hashCode()가 같으면 같은 객체인가?

아니다.

두 객체가 같은 hashCode()를 가질 수 있다.

User user1 = new User("Kim");
User user2 = new User("Lee");

예를 들어 두 객체의 hashCode가 우연히 같을 수 있다.

user1.hashCode() = 100
user2.hashCode() = 100

그렇다고 두 객체가 같은 객체라는 의미는 아니다.

hashCode() 동일
      ↓
같은 bucket으로 이동할 가능성
      ↓
equals() 비교
      ↓
같은 객체인지 최종 판단

이것이 바로 Hash Collision과 연결된다.


5. HashMap이란?

HashMap은 Key와 Value를 저장하는 자료구조다.

Map<String, Integer> map = new HashMap<>();

map.put("Kim", 100);
map.put("Lee", 200);

데이터 구조를 단순하게 표현하면 다음과 같다.

Key     Value

Kim     100
Lee     200

하지만 내부적으로 단순히 Key와 Value를 나란히 저장하는 것은 아니다.

HashMap은 Hash Table 기반 구조를 사용한다.


6. HashMap.put()의 내부 동작 ⭐⭐⭐

HashMap을 이해하기 위해 가장 중요한 부분이다.

map.put(key, value);

내부적으로는 대략 다음과 같은 흐름으로 동작한다.

HashMap.put(key, value)

        ↓

key.hashCode()

        ↓

hash 계산

        ↓

bucket 위치 결정

        ↓

해당 bucket 확인

        ↓

collision 확인

        ↓

equals() 비교

        ↓

새로운 데이터 저장
또는
기존 Value 변경

즉, hashCode()와 equals()가 HashMap의 핵심이다.


7. Bucket이란?

HashMap 내부에는 데이터를 저장하기 위한 배열 구조가 존재한다.

개념적으로 단순화하면 다음과 같다.

Bucket

[0]
[1]
[2]
[3]
[4]
[5]
[6]
[7]

Key의 hash를 이용해서 어느 bucket에 저장할지 결정한다.

예를 들어,

"Kim"
   ↓
hashCode()
   ↓
12345
   ↓
bucket 계산
   ↓
Bucket [5]

이런 식으로 특정 bucket 위치를 찾아간다.

실제 Java의 HashMap 구현은 단순히 hashCode() 값을 그대로 배열 인덱스로 사용하는 것은 아니며, hash 값을 추가로 처리하여 bucket 위치를 계산한다.


8. Hash Collision이란?

서로 다른 Key가 같은 bucket에 들어가는 상황을 Hash Collision이라고 한다.

예를 들어,

Key A
hash → 100
        ↓
      Bucket 3

Key B
hash → 200
        ↓
      Bucket 3

서로 다른 Key지만 같은 bucket에 위치할 수 있다.

이를 Hash Collision이라고 한다.


9. Collision이 발생하면 어떻게 될까?

HashMap은 collision이 발생했다고 해서 데이터를 잃어버리지 않는다.

같은 bucket에 여러 Entry를 저장할 수 있다.

개념적으로 다음과 같은 구조가 될 수 있다.

Bucket 3

    ↓

Entry A
   ↓
Entry B
   ↓
Entry C

Java 8 이후에는 collision이 많아지는 경우 성능 개선을 위해 연결 리스트 구조뿐만 아니라 Tree 구조를 사용할 수 있다.


10. Java 8 이후 Tree 구조

Java 8 이후 HashMap에서는 특정 bucket에 데이터가 지나치게 많이 몰리는 경우 내부 구조가 Tree 형태로 변경될 수 있다.

개념적으로 다음과 같다.

기존

Bucket
  ↓
A → B → C → D → E


Tree 구조

       C
      / \
     A   E
        /
       D

이를 통해 collision이 심한 상황에서 검색 성능을 개선할 수 있다.

일반적인 경우 HashMap의 조회 성능은 평균적으로 O(1)을 기대하지만, collision이 많아지면 성능이 저하될 수 있다.

Java 8 이후에는 일정 조건을 만족하면 bucket의 연결 리스트가 Tree 구조로 변환되어 최악의 경우 성능을 개선한다.


11. HashMap.get()의 동작

데이터를 저장할 때뿐만 아니라 데이터를 조회할 때도 hashCode()와 equals()가 사용된다.

Map<String, Integer> map = new HashMap<>();

map.put("Kim", 100);

Integer value = map.get("Kim");

개념적인 흐름은 다음과 같다.

map.get("Kim")

      ↓

"Kim".hashCode()

      ↓

hash 계산

      ↓

bucket 위치 찾기

      ↓

해당 bucket 검색

      ↓

equals() 비교

      ↓

Value 반환

따라서 hashCode()와 equals()가 제대로 구현되어 있어야 HashMap이 Key를 정상적으로 찾을 수 있다.


12. equals()만 구현하면 어떻게 될까?

다음과 같은 User 클래스가 있다고 해보자.

public class User {

    private String name;

    public User(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }

        if (!(obj instanceof User)) {
            return false;
        }

        User user = (User) obj;

        return name.equals(user.name);
    }
}

이제 다음과 같이 사용한다.

User user1 = new User("Kim");
User user2 = new User("Kim");

System.out.println(user1.equals(user2));

결과:

true

하지만 hashCode()를 구현하지 않았다.

이 상태에서 HashMap을 사용하면 문제가 발생할 수 있다.

Map<User, String> map = new HashMap<>();

map.put(user1, "Developer");

System.out.println(map.get(user2));

논리적으로는 같은 User이므로 "Developer"가 나올 것 같지만 정상적으로 찾지 못할 수 있다.

왜 그럴까?


13. HashMap에서 hashCode()가 중요한 이유

HashMap은 먼저 bucket을 찾는다.

user2.hashCode()
      ↓
bucket 위치 계산
      ↓
해당 bucket 검색
      ↓
equals()

그런데 user1과 user2의 hashCode가 서로 다르면 애초에 다른 bucket으로 이동할 수 있다.

user1
  ↓
hashCode = 100
  ↓
Bucket 2


user2
  ↓
hashCode = 200
  ↓
Bucket 5

그러면 equals()를 비교할 기회조차 생기지 않는다.

따라서 equals()가 true인 객체들은 반드시 같은 hashCode를 반환해야 한다.


14. equals()와 hashCode()를 함께 구현하기

다음과 같이 구현할 수 있다.

public class User {

    private String name;

    public User(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }

        if (!(obj instanceof User)) {
            return false;
        }

        User user = (User) obj;

        return name.equals(user.name);
    }

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

이제 다음 조건이 만족된다.

user1.name = "Kim"
user2.name = "Kim"

user1.equals(user2)
→ true

user1.hashCode()
→ 동일

user2.hashCode()
→ 동일

따라서 HashMap에서 동일한 Key로 정상적으로 인식할 수 있다.


15. HashSet은 어떻게 중복을 판단할까?

HashSet은 중복된 데이터를 저장하지 않는 Collection이다.

Set<User> users = new HashSet<>();

users.add(new User("Kim"));
users.add(new User("Kim"));

두 객체의 이름이 같다고 해서 자동으로 중복 처리되는 것은 아니다.

HashSet 역시 내부적으로 HashMap을 활용하는 방식으로 동작하기 때문에 hashCode()와 equals()가 중요하다.

개념적인 흐름은 다음과 같다.

HashSet.add(user)

      ↓

hashCode()

      ↓

bucket 찾기

      ↓

같은 bucket에 객체가 있는지 확인

      ↓

equals()

      ↓

같으면 중복으로 판단

따라서 다음과 같이 생각하면 된다.

hashCode()
    ↓
어디를 찾아갈지 결정

equals()
    ↓
실제로 같은 객체인지 확인

16. Hash Collision과 equals()의 관계

Hash Collision이 발생했다고 해서 두 객체가 같은 것은 아니다.

예를 들어:

User A
hashCode = 100

User B
hashCode = 100

두 객체의 hashCode가 같다.

하지만

userA.equals(userB)

가 false라면 서로 다른 객체다.

따라서 같은 bucket에 저장될 수 있다.

Bucket 3

A → B

HashMap은 이 상황에서 equals()를 이용해 실제 Key가 같은지 확인한다.


17. Mutable Key 문제 ⭐⭐⭐

HashMap에서 매우 중요한 주제다.

Key로 사용하는 객체의 상태를 변경하면 문제가 발생할 수 있다.

예를 들어:

User user = new User("Kim");

Map<User, String> map = new HashMap<>();

map.put(user, "Developer");

이후 User의 이름을 변경한다고 해보자.

user.setName("Lee");

이제 User의 hashCode()가 이름을 기반으로 구현되어 있다면 hashCode도 변경된다.

저장할 당시

name = Kim
hashCode = 100
Bucket 2


이름 변경

name = Lee
hashCode = 200
Bucket 5

그런데 실제 데이터는 여전히 기존 bucket에 존재한다.

Bucket 2
   ↓
User("Lee")

조회할 때는 변경된 hashCode를 기준으로 다른 bucket을 찾게 된다.

map.get(user)

   ↓

현재 hashCode = 200

   ↓

Bucket 5 검색

   ↓

데이터 없음

결과적으로 Key를 가지고 있는데도 데이터를 찾지 못하는 문제가 발생할 수 있다.


18. Mutable Key를 피해야 하는 이유

HashMap의 Key는 가능하면 변경되지 않는 객체(immutable object)를 사용하는 것이 좋다.

대표적인 예가 String이다.

Map<String, User> users = new HashMap<>();

String은 immutable이기 때문에 HashMap의 Key로 사용하기 적합하다.

String key = "user:100";

map.put(key, user);

Key 자체의 값이 변경되지 않기 때문에 hashCode가 갑자기 변경될 가능성이 없다.


19. HashMap 전체 동작 정리

지금까지 배운 내용을 하나의 흐름으로 정리하면 다음과 같다.

HashMap.put(key, value)

        ↓

key.hashCode()

        ↓

hash 계산

        ↓

bucket 위치 계산

        ↓

해당 bucket 확인

        ↓

Collision 발생?

    ┌───────┴───────┐
    │               │
   No              Yes
    │               │
    ↓               ↓
 저장          기존 Entry 탐색
                    │
                    ↓
                 equals()
                    │
             ┌──────┴──────┐
             │             │
            true          false
             │             │
             ↓             ↓
        기존 Value 변경   새로운 Entry 저장

이 흐름을 이해하면 HashMap의 대부분의 질문에 대응할 수 있다.


20. HashMap 성능

HashMap은 일반적으로 다음과 같은 성능을 기대한다.

작업 평균
put() O(1)
get() O(1)
remove() O(1)

하지만 이것은 평균적인 경우다.

Hash Collision이 지나치게 많이 발생하면 성능이 저하될 수 있다.

Java 8 이후에는 collision이 심한 bucket을 Tree 구조로 변경하여 최악의 상황에서 검색 성능을 개선한다.


21. 실습 — equals()와 hashCode()

이번 학습에서는 직접 객체를 만들어 테스트했다.

User user1 = new User("Kim");
User user2 = new User("Kim");

System.out.println(user1.equals(user2));
System.out.println(user1.hashCode());
System.out.println(user2.hashCode());

equals()를 구현하지 않은 경우

false

두 객체의 내용이 같더라도 서로 다른 객체로 판단될 수 있다.

equals()를 구현한 경우

true

논리적으로 같은 객체라고 판단할 수 있다.

그리고 hashCode()도 같은 값을 반환하도록 구현해야 한다.


22. 실습 — HashSet 중복 처리

Set<User> users = new HashSet<>();

users.add(new User("Kim"));
users.add(new User("Kim"));

System.out.println(users.size());

equals()와 hashCode()가 올바르게 구현되어 있다면 결과는:

1

두 객체가 논리적으로 동일하기 때문에 HashSet에서는 하나만 저장된다.


23. 실습 — 동일한 hashCode를 갖는 객체

서로 다른 객체가 같은 hashCode를 반환하도록 만들어 collision을 실험할 수도 있다.

@Override
public int hashCode() {
    return 1;
}

이렇게 하면 모든 객체가 동일한 hashCode를 갖게 된다.

User A → hashCode 1
User B → hashCode 1
User C → hashCode 1
User D → hashCode 1

모두 같은 bucket에 들어갈 가능성이 높아진다.

하지만 equals()가 다르다면 서로 다른 객체로 처리된다.

즉,

hashCode() 동일
≠
같은 객체

라는 것을 직접 확인할 수 있다.


24. 오늘의 핵심 정리 ⭐⭐⭐

① hashCode()

객체를 Hash 기반 자료구조에서 빠르게 찾기 위한 정수값을 제공한다.

int hashCode()

② equals()

두 객체가 논리적으로 같은지를 비교한다.

boolean equals(Object obj)

③ equals()와 hashCode()

가장 중요한 규칙:

equals()가 true
        ↓
hashCode()도 반드시 같아야 함

하지만 반대는 성립하지 않는다.

hashCode()가 같음
        ↓
equals()가 true라는 보장은 없음

④ HashMap

다음과 같은 흐름으로 Key를 찾는다.

Key
 ↓
hashCode()
 ↓
hash
 ↓
bucket
 ↓
equals()
 ↓
Value

⑤ Hash Collision

서로 다른 Key가 같은 bucket에 들어가는 현상이다.

Key A ──┐
        ├──→ Bucket 3
Key B ──┘

Collision이 발생해도 equals()를 이용해 실제 Key가 같은지 구분한다.


⑥ HashSet

HashSet은 중복을 허용하지 않으며, Hash 기반으로 객체를 관리하기 때문에 hashCode()와 equals()가 중요하다.

hashCode()
    ↓
bucket 탐색

equals()
    ↓
동일 객체인지 확인

⑦ Mutable Key

HashMap의 Key 상태가 변경되어 hashCode()가 달라지면 기존 데이터를 찾지 못하는 문제가 발생할 수 있다.

따라서 HashMap의 Key에는 가능하면 immutable 객체를 사용하는 것이 안전하다.


🎤 면접 체크

Q1. HashMap의 동작 원리를 설명해 주세요.

HashMap은 Key의 hashCode()를 기반으로 hash를 계산하고 저장할 bucket을 결정합니다. 해당 bucket에 기존 Entry가 있으면 equals()를 이용해 Key가 동일한지 확인합니다. 동일한 Key라면 Value를 변경하고, 다른 Key라면 새로운 Entry를 저장합니다.


Q2. Hash Collision이 무엇인가요?

서로 다른 Key가 같은 bucket에 저장되는 현상입니다. Collision이 발생하면 같은 bucket에 있는 Entry들을 대상으로 equals()를 비교하여 실제로 같은 Key인지 확인합니다.


Q3. equals()와 hashCode()의 관계를 설명해 주세요.

equals()가 true인 두 객체는 반드시 같은 hashCode()를 반환해야 합니다. HashMap이나 HashSet에서는 먼저 hashCode()를 이용해 bucket을 찾고 이후 equals()를 통해 실제 동일 여부를 판단하기 때문입니다. 반대로 hashCode()가 같다고 해서 equals()가 true인 것은 아닙니다.


Q4. HashSet은 중복을 어떻게 판단하나요?

HashSet은 객체의 hashCode()를 이용해 bucket을 찾은 다음 equals()를 통해 기존 객체와 동일한지 확인합니다. hashCode()가 다르면 다른 객체로 판단하고, hashCode()가 같으면 equals()를 비교하여 중복 여부를 판단합니다.


Q5. hashCode()만 같으면 같은 객체인가요?

아닙니다. 서로 다른 객체도 같은 hashCode()를 가질 수 있습니다. 이것을 Hash Collision이라고 합니다. 실제 동일 여부는 equals()를 통해 판단합니다.


📌 Day 5 한 줄 정리

HashMap은 hashCode()로 빠르게 찾아갈 위치를 결정하고, equals()로 실제 같은 Key인지 확인한다.

그리고 이것만큼 중요한 한 문장을 기억하자.

equals()가 true라면 hashCode()는 반드시 같아야 한다.


다음 학습

Day 6에서는 Java 8에서 추가된 중요한 문법인 Lambda와 Functional Interface를 학습한다.

Day 5
HashMap
  ↓
hashCode()
  ↓
equals()
  ↓
HashSet
  ↓
Hash Collision
  ↓
Bucket
  ↓
Mutable Key

        ↓

Day 6
Lambda
  ↓
Functional Interface
  ↓
Predicate
  ↓
Function
  ↓
Consumer
  ↓
Supplier
  ↓
Method Reference

특히 Lambda와 Functional Interface는 이후 Stream API를 이해하기 위한 기초가 되기 때문에 함께 이해하는 것이 중요하다.

반응형

'dev > Java' 카테고리의 다른 글

[Java] Day 8 - Exception  (0) 2026.09.14
[Java] Day 7 - Stream API 정리  (0) 2026.09.12
[Java] Day 6 - Lambda + Functional Interface  (0) 2026.09.11
[Java] Day 4 - Collection Framework  (0) 2026.09.09
[Java] Day 3 - Generic, Enum, Record 정리  (0) 2026.09.07

댓글