
개요
HashMap은 Java 개발자가 가장 많이 사용하는 자료구조 중 하나입니다. 그러나 기본값(default capacity: 16, load factor: 0.75)을 그대로 사용하면, 데이터 규모가 커질수록 불필요한 rehashing이 발생하여 응답 시간이 급격히 늘어날 수 있습니다. 실제 프로젝트에서 수십만 건의 데이터를 처리하는 서비스라면, HashMap 초기화 방식 하나만 바꿔도 GC 부담과 실행 속도를 의미 있게 개선할 수 있습니다. 이 글에서는 HashMap 내부 동작 원리를 짚고, 초기 용량·로드 팩터 설정 및 putIfAbsent 같은 API 활용까지 단계별로 설명합니다.
HashMap 내부 동작: Rehashing이란 무엇인가
HashMap은 내부적으로 버킷(bucket) 배열과 연결 리스트(또는 Red-Black Tree)로 구성됩니다. 항목이 추가될 때마다 size / capacity 비율이 로드 팩터(load factor) 임계값을 초과하면, 내부 배열 크기를 두 배로 늘리고 모든 항목을 재배치하는 rehashing이 발생합니다.
- 기본 capacity: 16
- 기본 load factor: 0.75
- → 13번째 항목 삽입 시 최초 rehashing 발생 (16 × 0.75 = 12)
rehashing은 O(n) 비용이 발생하며, 데이터 규모에 따라 여러 번 반복될 수 있습니다. 결과적으로 메모리 재할당과 GC 압력이 증가합니다.
초기 용량 설정으로 Rehashing 줄이기
예상 항목 수를 미리 알고 있다면, 초기 용량을 명시적으로 지정해 rehashing을 방지할 수 있습니다. 권장 공식은 다음과 같습니다.
초기 capacity = (예상 항목 수 / load factor) + 1아래 코드는 100만 건의 데이터를 처리할 때 기본값 대비 개선된 초기화 방식을 보여줍니다.
// 변경전: 기본 초기화 — rehashing 수십 회 발생
Map<String, Integer> defaultMap = new HashMap<>();
// 변경후: 100만 건을 수용하는 초기 용량 설정
// (1_000_000 / 0.75) + 1 ≈ 1_333_334
Map<String, Integer> optimizedMap = new HashMap<>(1_333_334, 0.75f);
// 데이터 삽입 비교 (단순 벤치마크)
long start = System.currentTimeMillis();
for (int i = 0; i < 1_000_000; i++) {
optimizedMap.put("key" + i, i); // rehashing 없이 삽입
}
long elapsed = System.currentTimeMillis() - start;
System.out.println("최적화 삽입 시간: " + elapsed + "ms");// 출력 예시 (환경에 따라 다를 수 있음)
최적화 삽입 시간: 312ms // 기본값 대비 약 30~40% 단축핵심: 예상 데이터 크기를 알고 있다면 반드시 초기 용량을 지정해야 합니다. JDK 19 이상에서는
HashMap.newHashMap(int numMappings)팩터리 메서드로 더 간결하게 작성할 수 있습니다.
로드 팩터 튜닝: 메모리 vs 속도 트레이드오프
로드 팩터는 메모리 효율과 조회 성능 사이의 균형을 결정합니다.
| 0.5 이하 | 높음 | 낮음 | 읽기 집중, 캐시 |
| 0.75 (기본) | 보통 | 보통 | 범용 |
| 0.9 이상 | 낮음 | 높음 | 메모리 제약 환경 |
읽기 요청이 쓰기보다 압도적으로 많은 캐시 용도라면 로드 팩터를 0.5~0.6으로 낮추면 해시 충돌을 줄여 get() 성능을 개선할 수 있습니다.
// 캐시 용도: 낮은 로드 팩터로 조회 성능 우선
Map<String, UserProfile> userCache = new HashMap<>(512, 0.5f);
// 메모리 절약이 필요한 배치 처리: 로드 팩터 상향
Map<Long, String> batchMap = new HashMap<>(256, 0.9f);현업에서 자주 쓰이는 성능 모범 사례
단순한 용량 설정 외에도, 다음 API와 패턴을 활용하면 불필요한 연산을 줄일 수 있습니다.
putIfAbsent vs computeIfAbsent
putIfAbsent는 값이 없을 때만 삽입하지만, 값 객체를 매번 생성합니다. 반면 computeIfAbsent는 람다가 필요할 때만 실행되므로 객체 생성 비용이 큰 경우 더 효율적입니다.
Map<String, List<String>> groupMap = new HashMap<>();
// 변경전: 항상 new ArrayList<>() 생성
groupMap.putIfAbsent("team-a", new ArrayList<>());
groupMap.get("team-a").add("Alice");
// 변경후: 키가 없을 때만 리스트 생성 (메모리 효율 개선)
groupMap.computeIfAbsent("team-a", k -> new ArrayList<>()).add("Alice");동시성 환경에서의 선택
멀티스레드 환경에서는 HashMap 대신 ConcurrentHashMap을 사용해야 합니다. 읽기 잠금이 없는 구조 덕분에 Collections.synchronizedMap() 래퍼보다 처리량(throughput)이 현저히 높습니다.
// 동시성 환경 — ConcurrentHashMap 권장
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>(1024, 0.75f, 16);
// 세 번째 인자 concurrencyLevel(16): 예상 동시 쓰기 스레드 수맺음말
HashMap 성능 최적화는 단순한 세부 조정처럼 보이지만, 대규모 데이터를 처리하는 운영 환경에서 GC 횟수 감소와 응답 속도 개선으로 직결됩니다. 핵심 지침을 정리하면 다음과 같습니다.
- 예상 항목 수를 알고 있다면 항상 초기 용량을 명시적으로 계산해 지정합니다.
- 읽기 집중 워크로드라면 로드 팩터를 0.5~0.6으로 낮춰 충돌을 줄입니다.
- 값 생성 비용이 클 때는
putIfAbsent대신computeIfAbsent를 선택합니다. - 멀티스레드 환경에서는
ConcurrentHashMap으로 교체합니다.
다음 단계로는 Java ConcurrentHashMap 심화 가이드, JVM GC 튜닝 모범 사례를 참고하면 더 넓은 맥락에서 성능 최적화 전략을 세울 수 있습니다. 공식 레퍼런스로는 OpenJDK HashMap 소스코드와 Java SE 21 Collections 문서를 권장합니다.
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| [JAVA] JVM GC 튜닝으로 자연시간 줄이기 (0) | 2026.03.09 |
|---|---|
| Spring Boot 테스트 전략: @SpringBootTest vs 슬라이스 테스트 선택 (0) | 2026.03.09 |
| Java Virtual Threads로 고성능 서버 구현하기 스레드 병목을 해소하는 방법 (0) | 2026.03.06 |
| [JAVA/SPRING] Java Virtual Threads로 고성능 서버 구축하기 스레드 전환 비용 줄이는 방법 (0) | 2026.03.06 |
| Spring AI로 Java 애플리케이션에 LLM 연동하기 (0) | 2026.03.05 |