도입
자바에서 Map을 사용할 때 가장 먼저 떠올리는 구현체는 보통 HashMap입니다. 이유는 단순합니다. 대부분의 상황에서 빠르고, 사용법이 간단하며, 실무와 알고리즘 문제에서 모두 자주 등장하기 때문입니다.
하지만 HashMap을 단순히 “키로 값을 찾는 자료구조” 정도로만 이해하면, 충돌이 왜 생기는지, equals()와 hashCode()가 왜 중요한지, 왜 어떤 경우에는 값이 안 찾아지는지, 왜 멀티스레드 환경에서 문제가 되는지 같은 핵심을 놓치게 됩니다.
정의
HashMap은 java.util.Map 인터페이스의 대표 구현체입니다. 데이터를 key-value 형태로 저장하며, 키의 해시값을 이용해 저장 위치를 빠르게 계산합니다.
이 구조 덕분에 조회, 삽입, 삭제가 평균적으로 매우 빠릅니다. 다만 “빠르다”는 말은 어디까지나 해시 분포가 적절하고 충돌이 과도하지 않을 때를 전제로 합니다.
- HashMap은 키-값 쌍을 저장한다.
- 키는 중복될 수 없고, 같은 키로 put하면 기존 값이 덮어써진다.
- 내부적으로 해시 기반 구조를 사용해 평균적으로 빠른 조회를 제공한다.
필요성
List는 순서 기반으로 접근하고, Set은 중복 없는 값의 집합을 관리하는 데 강합니다. 반면 HashMap은 특정 키를 기준으로 값을 즉시 찾는 데 최적화되어 있습니다.
예를 들어 회원 ID로 회원 정보를 찾거나, 상품 코드로 가격을 찾거나, 문자열 등장 횟수를 세는 작업은 HashMap과 매우 잘 맞습니다. 실무에서는 설정값 저장, 캐시, 집계, 인덱스 테이블, 매핑 테이블 등에 자주 사용됩니다.
기본 사용법
import java.util.HashMap;
import java.util.Map;
Map<String, Integer> map = new HashMap<>();
map.put("apple", 3);
map.put("banana", 5);
map.put("apple", 10); // 같은 키 -> 값 덮어쓰기
System.out.println(map.get("apple")); // 10
System.out.println(map.containsKey("banana")); // true
map.remove("banana");
System.out.println(map.size()); // 1
put(key, value): 저장 또는 덮어쓰기get(key): 값 조회remove(key): 삭제containsKey(key): 키 존재 여부 확인getOrDefault(key, defaultValue): 없으면 기본값 반환
내부 구조
HashMap을 이해할 때 핵심은 “배열 하나에 모든 걸 저장한다”가 아니라, 배열 + 버킷(bucket) + 엔트리(entry) 구조로 본다는 점입니다.
- 키의 hashCode()를 구한다.
- 해시값을 바탕으로 배열 인덱스를 계산한다.
- 그 인덱스에 해당하는 버킷을 확인한다.
- 버킷 안에서 같은 키가 있는지 비교한다.
- 있으면 값 갱신, 없으면 새 엔트리 추가한다.
즉, HashMap의 빠른 조회는 “전체를 다 뒤지는 것”이 아니라, 먼저 해시를 이용해 대략적인 위치를 빠르게 좁히고, 그 안에서만 추가 비교를 수행하는 구조 덕분에 가능합니다.
put 동작 과정
put(key, value)는 단순히 배열의 빈 칸에 넣는 것이 아닙니다. 먼저 키의 해시를 계산하고, 그 해시에 대응하는 버킷을 찾은 뒤, 그 안에서 이미 같은 키가 있는지 확인합니다.
- 버킷이 비어 있으면 새 엔트리 저장
- 버킷에 다른 키들이 있으면 같은 키 여부 검사
- 같은 키가 있으면 값만 변경
- 같은 키가 없으면 충돌 버킷에 새 엔트리 추가
get 동작 과정
get(key) 역시 먼저 해시를 계산해 버킷 위치를 찾고, 그 버킷 안에 들어 있는 엔트리들 중 정확히 같은 키를 찾습니다. 이때 중요한 것은 해시값만 같다고 같은 키가 아니라는 점입니다.
최종 판별은 equals()를 통해 이루어집니다. 그래서 HashMap에서는 hashCode()와 equals()가 함께 중요합니다.
해시 충돌
해시 함수는 무한한 입력을 유한한 공간으로 매핑하기 때문에, 서로 다른 키가 같은 위치로 들어가는 해시 충돌은 피할 수 없습니다. 따라서 좋은 HashMap은 충돌이 “생기지 않게” 만드는 구조가 아니라, 생겨도 효율적으로 처리하는 구조입니다.
버킷 안의 엔트리 수가 적을 때는 단순 연결 구조로도 충분하지만, 충돌이 많아질수록 성능이 떨어집니다. 그래서 현대 자바의 HashMap은 충돌이 지나치게 많아지면 버킷 내부 구조를 더 효율적인 형태로 바꿔 성능 저하를 완화합니다.
equals와 hashCode
HashMap은 먼저 hashCode()로 버킷 위치를 찾고, 그 다음 equals()로 진짜 같은 키인지 판단합니다. 따라서 두 메서드는 따로 생각하면 안 됩니다.
| 상황 | 문제 |
|---|---|
| equals만 재정의 | 같은 키인데 다른 버킷으로 가서 조회 실패 가능 |
| hashCode만 재정의 | 버킷은 같아도 진짜 같은 키 판별이 꼬일 수 있음 |
| 둘 다 올바르게 구현 | 정상적인 저장/조회 가능 |
import java.util.Objects;
public class ProductKey {
private final String code;
public ProductKey(String code) {
this.code = code;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ProductKey)) return false;
ProductKey that = (ProductKey) o;
return Objects.equals(code, that.code);
}
@Override
public int hashCode() {
return Objects.hash(code);
}
}
리사이징과 load factor
HashMap은 저장되는 엔트리가 많아질수록 충돌 가능성도 높아집니다. 그래서 일정 기준 이상으로 채워지면 내부 저장 공간을 늘리는 resize가 발생합니다.
이때 기존 엔트리들은 새 배열 크기에 맞게 다시 배치됩니다. 따라서 resize는 성능을 유지하는 데 필수적이지만, 순간적으로는 비용이 드는 작업입니다.
null 처리
HashMap은 null 키도 허용하고, null 값도 허용합니다. 이 점은 매우 편리할 수 있지만, 동시에 디버깅 포인트가 되기도 합니다.
Map<String, String> map = new HashMap<>();
map.put("name", null);
System.out.println(map.get("name")); // null
System.out.println(map.get("unknown")); // null
위 두 경우는 결과가 똑같이 null이지만 의미는 다릅니다. 하나는 키는 있는데 값이 null이고, 다른 하나는 키 자체가 없는 경우입니다. 따라서 이런 상황에서는 containsKey()로 함께 확인해야 안전합니다.
순서와 정렬
HashMap은 빠른 조회를 위한 구조이지, 순서 보장을 위한 구조가 아닙니다. 따라서 입력한 순서대로 꺼내질 것이라고 기대하면 안 됩니다.
| 구현체 | 삽입 순서 보장 | 정렬 보장 |
|---|---|---|
| HashMap | 아니오 | 아니오 |
| LinkedHashMap | 예 | 아니오 |
| TreeMap | 정렬 순서 | 예 |
멀티스레드와 안전성
HashMap은 단일 스레드 환경에서는 매우 유용하지만, 여러 스레드가 동시에 수정하는 환경에서는 안전하지 않습니다. 이런 경우에는 ConcurrentHashMap 같은 동시성 대응 구현체를 고려해야 합니다.
단순히 “공유 Map이 하나 있다”는 이유만으로 HashMap을 사용하면, 예기치 않은 조회 실패, 데이터 유실, 상태 꼬임 같은 문제가 생길 수 있습니다.
성능 특성
| 연산 | 평균 | 비고 |
|---|---|---|
| put | O(1) | 충돌/resize 상황에 따라 더 느려질 수 있음 |
| get | O(1) | 좋은 해시 분포가 전제됨 |
| remove | O(1) | 충돌 구조에 따라 영향 받음 |
그래서 HashMap의 성능을 올바르게 이해하려면 “무조건 O(1)”이라고 외우기보다, 평균적으로 빠르지만 해시 품질과 충돌 처리 성능에 의존한다고 이해하는 편이 정확합니다.
실전 활용
Map<String, Integer> countMap = new HashMap<>();
for (String word : words) {
countMap.put(word, countMap.getOrDefault(word, 0) + 1);
}
사용자 ID → User 객체, 상품 코드 → Product 객체 형태로 저장해 빠른 조회 테이블을 만들 수 있습니다.
Map<String, java.util.List<String>> groupMap = new HashMap<>();
for (String name : names) {
String first = name.substring(0, 1);
groupMap.computeIfAbsent(first, k -> new java.util.ArrayList<>()).add(name);
}
자주 하는 실수
- 같은 키 put인데 새 데이터가 추가된다고 착각
- equals/hashCode를 함께 구현하지 않음
- 가변 객체를 키로 사용함
- HashMap 순서를 믿고 로직을 짬
get()의null을 “키 없음”으로 단정- 멀티스레드 환경에서 일반 HashMap을 공유
디버깅
get()이 null일 때는 containsKey()로 의미를 구분한다.요약
- ✅ HashMap은 해시 기반 Map 구현체다.
- ✅ 평균적으로 매우 빠른 조회/삽입/삭제를 제공한다.
- ✅ 해시 충돌은 자연스러운 현상이며, 구조적으로 처리된다.
- ✅ 사용자 정의 키를 쓸 때는 equals와 hashCode를 함께 올바르게 구현해야 한다.
- ✅ HashMap은 순서를 보장하지 않으며, 멀티스레드 환경에서는 기본적으로 안전하지 않다.