"오늘의 문제를, 내일의 기록으로 남깁니다."

막연한 이론보다, 구체적인 코드가 필요할 때. 직접 겪고 해결한 문제들을 기록합니다. 실무에서 부딪히는 진짜 이슈와, 내가 이해한 방식 그대로 정리한 가이드입니다.

웹개발/Java

Java HashMap vs Hashtable vs ConcurrentHashMap: 동시성과 성능의 완벽 가이드

자바를잡아 2025. 7. 21. 20:45
반응형

Java HashMap vs Hashtable vs ConcurrentHashMap: 동시성과 성능의 완벽 가이드

내부 구조와 동작 원리

HashMap: 단일 스레드 최적화 설계

HashMap은 배열과 연결 리스트(또는 레드-블랙 트리)를 조합한 해시 테이블로 구현됩니다. 키의 해시코드를 배열 인덱스로 변환하여 O(1) 평균 시간 복잡도로 접근합니다.

// HashMap 내부 구조 개념
Node<K,V>[] table; // 버킷 배열
static class Node<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 충돌 시 체이닝
}

Java 8부터 동일 버킷에 8개 이상의 노드가 충돌하면 연결 리스트를 레드-블랙 트리로 변환하여 최악의 경우 O(log n) 성능을 보장합니다.

Hashtable: 전역 동기화 메커니즘

Hashtable은 모든 메서드에 synchronized 키워드를 적용하여 스레드 안전성을 확보합니다. 이는 메서드 레벨 동기화로, 전체 테이블에 대한 독점적 접근을 의미합니다.

public synchronized V put(K key, V value) {
    // 전체 테이블 락
}

public synchronized V get(Object key) {
    // 읽기 작업도 락 필요
}

ConcurrentHashMap: 세그먼트 기반 락 분산

ConcurrentHashMap은 테이블을 여러 세그먼트로 분할하여 락 경합을 최소화합니다. Java 8부터는 CAS(Compare-And-Swap) 연산과 더 세밀한 락 메커니즘을 사용합니다.

// Java 8+ ConcurrentHashMap의 CAS 기반 삽입
final V putVal(K key, V value, boolean onlyIfAbsent) {
    for (Node<K,V>[] tab = table;;) {
        if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
            break; // CAS 성공 시 락 없이 삽입
        // 충돌 시에만 세밀한 락 사용
    }
}

동시성 처리 방식의 본질적 차이

구분 HashMap Hashtable ConcurrentHashMap
동기화 레벨 없음 메서드 전체 버킷/세그먼트 단위
읽기 성능 최고 (락 없음) 낮음 (독점 락) 높음 (락 없는 읽기)
쓰기 성능 최고 (단일 스레드) 낮음 (전역 락) 중간 (세밀한 락)
null 허용 키/값 모두 허용 둘 다 금지 둘 다 금지

성능 특성과 메모리 오버헤드

메모리 사용량 분석

HashMap은 가장 적은 메모리를 사용하며, ConcurrentHashMap은 동시성 제어를 위한 추가 메타데이터로 약 20-30% 더 많은 메모리를 소비합니다. Hashtable은 중간 수준의 메모리를 사용합니다.

처리량(Throughput) 비교

// 단일 스레드 환경 (1M 연산)
HashMap:           ~1000ms
Hashtable:         ~1200ms  
ConcurrentHashMap: ~1150ms

// 멀티 스레드 환경 (8 스레드, 1M 연산)
HashMap:           데이터 손실 발생
Hashtable:         ~8000ms (병목)
ConcurrentHashMap: ~2500ms (최적)

실전 동시성 패턴과 함정

HashMap의 Race Condition

멀티스레드 환경에서 HashMap은 리사이징 과정에서 무한 루프를 발생시킬 수 있습니다. 이는 다음과 같은 시나리오에서 발생합니다:

// 위험한 패턴 - Race Condition 발생 가능
Map<String, Integer> map = new HashMap<>();

// 여러 스레드가 동시에 실행
Runnable task = () -> {
    for (int i = 0; i < 1000; i++) {
        map.put("key" + i, i); // 리사이징 중 무한루프 가능
    }
};

ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
    executor.submit(task); // 10개 스레드 동시 실행
}

ConcurrentHashMap의 원자성 보장 범위

ConcurrentHashMap은 개별 연산의 스레드 안전성을 보장하지만, 복합 연산은 별도의 동기화가 필요합니다:

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 안전하지 않은 복합 연산
if (map.get("counter") == null) {
    map.put("counter", 1); // 다른 스레드가 중간에 삽입 가능
} else {
    map.put("counter", map.get("counter") + 1); // 원자성 보장 안됨
}

// 올바른 원자적 연산 사용
map.compute("counter", (key, val) -> val == null ? 1 : val + 1);

특수 상황별 최적화 전략

대용량 데이터 처리 시나리오

백만 개 이상의 엔트리를 다루는 경우, ConcurrentHashMap의 초기 용량과 동시성 레벨을 조정하면 성능을 크게 개선할 수 있습니다:

// 대용량 데이터 최적화
ConcurrentHashMap<String, Object> bigMap = new ConcurrentHashMap<>(
    1_000_000,  // 초기 용량
    0.75f,      // 로드 팩터
    32          // 동시성 레벨 (CPU 코어 수의 배수)
);

읽기 집약적 vs 쓰기 집약적 워크로드

읽기 집약적 (읽기:쓰기 = 9:1 이상): ConcurrentHashMap이 Hashtable보다 5-10배 빠른 성능을 보입니다.

쓰기 집약적 (쓰기:읽기 = 7:3 이상): 여전히 ConcurrentHashMap이 우수하지만, 성능 차이가 줄어듭니다.

Java 버전별 진화와 최적화

Java 8 이전 vs 이후 성능 차이

Java 8에서 ConcurrentHashMap은 내부 구조가 완전히 개편되었습니다. 세그먼트 기반에서 노드 기반 락킹으로 변경되어 더 세밀한 동시성 제어가 가능해졌습니다.

// Java 8+ 의 병렬 연산 지원
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 병렬 스트림을 통한 고성능 집계
long sum = map.values().parallelStream()
              .mapToLong(Integer::longValue)
              .sum();

// 원자적 집계 연산
map.reduceValues(1, Integer::sum); // 병렬성 임계값 = 1

JIT 컴파일러 최적화 고려사항

HashMap은 JIT 컴파일러의 최적화를 가장 잘 받는 구조입니다. 반면 ConcurrentHashMap의 복잡한 동시성 제어 로직은 최적화에 제약이 있어, 웜업 시간이 더 길어집니다.

개발자를 위한 실전 선택 기준

HashMap은 단일 스레드 환경이나 불변 데이터, 초기화 후 읽기만 하는 경우에 사용하세요. 성능이 가장 뛰어나고 메모리 사용량도 최소입니다.

ConcurrentHashMap은 멀티스레드 환경에서 첫 번째 선택지입니다. 특히 읽기가 많은 워크로드에서 탁월한 성능을 보입니다. null 값이 필요하다면 Optional로 래핑하는 것이 좋습니다.

Hashtable은 레거시 코드 호환성이 필요한 경우를 제외하고는 피하는 것이 좋습니다. 동일한 스레드 안전성을 원한다면 Collections.synchronizedMap()을 사용하거나, 더 나은 선택인 ConcurrentHashMap을 사용하세요.

실제 운영 환경에서는 프로파일링을 통해 병목 지점을 확인하고, 동시성 레벨과 초기 용량을 워크로드에 맞게 튜닝하는 것이 중요합니다.

반응형