ABOUT

성능과 운영 안정성을 함께 끌어올리는 개발자입니다.

92% Positional Error Reduction
79% p95 Latency Improvement
90%+ Long Tasks Reduction

2022.02 · 한국장학재단

우수 멘티

한국장학재단 사회 리더 대학생 멘토링 IT

2022.10 · 동작구청

우수 인재상

동작구청 우수 SW 인재

2025.05 · (주) 그랩

프로그래밍 우수상

(주) 그랩 우수 프로그램 개발

2025.05 · AWSKRUG

AWS한국사용자모임 발표

AI agent 스크립트 튜닝 관련 발표

ComputerScience

Development

Engineering

Trouble Shooting

GUESTBOOK

첫 마음부터
함께 나누는 온기

방명록 작성하러 가기

SUBSCRIBE

최신소식을
편하게 만나보세요.

HashMap

도입

자바에서 가장 널리 쓰이는 키-값 저장소이자, 빠른 조회를 가능하게 하는 대표 자료구조다

자바에서 Map을 사용할 때 가장 먼저 떠올리는 구현체는 보통 HashMap입니다. 이유는 단순합니다. 대부분의 상황에서 빠르고, 사용법이 간단하며, 실무와 알고리즘 문제에서 모두 자주 등장하기 때문입니다.

하지만 HashMap을 단순히 “키로 값을 찾는 자료구조” 정도로만 이해하면, 충돌이 왜 생기는지, equals()hashCode()가 왜 중요한지, 왜 어떤 경우에는 값이 안 찾아지는지, 왜 멀티스레드 환경에서 문제가 되는지 같은 핵심을 놓치게 됩니다.

정의

해시 기반으로 키를 저장 위치에 매핑해 평균적으로 매우 빠른 조회를 제공하는 Map 구현체다

HashMapjava.util.Map 인터페이스의 대표 구현체입니다. 데이터를 key-value 형태로 저장하며, 키의 해시값을 이용해 저장 위치를 빠르게 계산합니다.

이 구조 덕분에 조회, 삽입, 삭제가 평균적으로 매우 빠릅니다. 다만 “빠르다”는 말은 어디까지나 해시 분포가 적절하고 충돌이 과도하지 않을 때를 전제로 합니다.

핵심 요약
  • HashMap은 키-값 쌍을 저장한다.
  • 키는 중복될 수 없고, 같은 키로 put하면 기존 값이 덮어써진다.
  • 내부적으로 해시 기반 구조를 사용해 평균적으로 빠른 조회를 제공한다.
한 줄 정리
HashMap = 해시를 이용해 키를 빠르게 찾는 Map입니다.

필요성

“빠르게 찾는 저장소”가 필요할 때 가장 먼저 고려하는 기본 선택지다

List는 순서 기반으로 접근하고, Set은 중복 없는 값의 집합을 관리하는 데 강합니다. 반면 HashMap은 특정 키를 기준으로 값을 즉시 찾는 데 최적화되어 있습니다.

예를 들어 회원 ID로 회원 정보를 찾거나, 상품 코드로 가격을 찾거나, 문자열 등장 횟수를 세는 작업은 HashMap과 매우 잘 맞습니다. 실무에서는 설정값 저장, 캐시, 집계, 인덱스 테이블, 매핑 테이블 등에 자주 사용됩니다.

기본 사용법

put, get, remove, containsKey만 익혀도 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) 구조로 본다는 점입니다.

  1. 키의 hashCode()를 구한다.
  2. 해시값을 바탕으로 배열 인덱스를 계산한다.
  3. 그 인덱스에 해당하는 버킷을 확인한다.
  4. 버킷 안에서 같은 키가 있는지 비교한다.
  5. 있으면 값 갱신, 없으면 새 엔트리 추가한다.

즉, HashMap의 빠른 조회는 “전체를 다 뒤지는 것”이 아니라, 먼저 해시를 이용해 대략적인 위치를 빠르게 좁히고, 그 안에서만 추가 비교를 수행하는 구조 덕분에 가능합니다.

put 동작 과정

put은 해시 계산 → 버킷 탐색 → 키 비교 → 삽입 또는 덮어쓰기 순서로 진행된다

put(key, value)는 단순히 배열의 빈 칸에 넣는 것이 아닙니다. 먼저 키의 해시를 계산하고, 그 해시에 대응하는 버킷을 찾은 뒤, 그 안에서 이미 같은 키가 있는지 확인합니다.

put 시나리오
  • 버킷이 비어 있으면 새 엔트리 저장
  • 버킷에 다른 키들이 있으면 같은 키 여부 검사
  • 같은 키가 있으면 값만 변경
  • 같은 키가 없으면 충돌 버킷에 새 엔트리 추가

get 동작 과정

get은 같은 해시 위치를 찾은 뒤 버킷 내부에서 equals 비교로 정확한 키를 판별한다

get(key) 역시 먼저 해시를 계산해 버킷 위치를 찾고, 그 버킷 안에 들어 있는 엔트리들 중 정확히 같은 키를 찾습니다. 이때 중요한 것은 해시값만 같다고 같은 키가 아니라는 점입니다.

최종 판별은 equals()를 통해 이루어집니다. 그래서 HashMap에서는 hashCode()와 equals()가 함께 중요합니다.

해시 충돌

서로 다른 키가 같은 버킷으로 들어오는 현상을 해시 충돌이라고 하며, HashMap은 이를 내부 구조로 해결한다

해시 함수는 무한한 입력을 유한한 공간으로 매핑하기 때문에, 서로 다른 키가 같은 위치로 들어가는 해시 충돌은 피할 수 없습니다. 따라서 좋은 HashMap은 충돌이 “생기지 않게” 만드는 구조가 아니라, 생겨도 효율적으로 처리하는 구조입니다.

버킷 안의 엔트리 수가 적을 때는 단순 연결 구조로도 충분하지만, 충돌이 많아질수록 성능이 떨어집니다. 그래서 현대 자바의 HashMap은 충돌이 지나치게 많아지면 버킷 내부 구조를 더 효율적인 형태로 바꿔 성능 저하를 완화합니다.

핵심 오해 방지
해시 충돌이 생긴다고 HashMap이 틀리는 것은 아닙니다. 충돌은 자연스러운 현상이고, 문제는 충돌을 얼마나 잘 분산하고 처리하느냐입니다.

equals와 hashCode

사용자 정의 객체를 키로 쓸 때는 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는 성능을 유지하는 데 필수적이지만, 순간적으로는 비용이 드는 작업입니다.

실무 포인트
저장할 데이터 양이 대략 예측된다면, 처음부터 적절한 크기로 HashMap을 잡아 불필요한 resize 횟수를 줄이는 것이 성능에 도움이 될 수 있습니다.

null 처리

null 키와 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)”이라고 외우기보다, 평균적으로 빠르지만 해시 품질과 충돌 처리 성능에 의존한다고 이해하는 편이 정확합니다.

실전 활용

카운팅, 캐싱, 인덱싱, 그룹핑 같은 패턴에서 매우 자주 등장한다
1. 빈도 수 계산
Map<String, Integer> countMap = new HashMap<>();

for (String word : words) {
    countMap.put(word, countMap.getOrDefault(word, 0) + 1);
}
2. ID 기반 인덱싱

사용자 ID → User 객체, 상품 코드 → Product 객체 형태로 저장해 빠른 조회 테이블을 만들 수 있습니다.

3. 그룹핑
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);
}

자주 하는 실수

오류의 대부분은 키 설계, equals/hashCode 누락, 순서 오해, null 혼동에서 발생한다
  • 같은 키 put인데 새 데이터가 추가된다고 착각
  • equals/hashCode를 함께 구현하지 않음
  • 가변 객체를 키로 사용함
  • HashMap 순서를 믿고 로직을 짬
  • get()null을 “키 없음”으로 단정
  • 멀티스레드 환경에서 일반 HashMap을 공유

디버깅

이상하게 동작할 때는 키, hashCode, equals, null, 동시성 순서로 점검하면 빠르다
1
키가 정말 같은지, 혹은 의도치 않게 덮어쓴 것은 아닌지 확인한다.
2
사용자 정의 키라면 equals()hashCode()가 함께 올바른지 확인한다.
3
키를 넣은 뒤 내부 상태가 바뀌는 가변 객체는 아닌지 점검한다.
4
get()이 null일 때는 containsKey()로 의미를 구분한다.
5
멀티스레드 환경이라면 ConcurrentHashMap이 필요한지 확인한다.

요약

제대로 이해하려면 단순 사용법보다 해시, 충돌, equals/hashCode, 키 설계까지 함께 봐야 한다
  • ✅ HashMap은 해시 기반 Map 구현체다.
  • ✅ 평균적으로 매우 빠른 조회/삽입/삭제를 제공한다.
  • ✅ 해시 충돌은 자연스러운 현상이며, 구조적으로 처리된다.
  • ✅ 사용자 정의 키를 쓸 때는 equals와 hashCode를 함께 올바르게 구현해야 한다.
  • ✅ HashMap은 순서를 보장하지 않으며, 멀티스레드 환경에서는 기본적으로 안전하지 않다.
728x90