ConcurrentHashMap과 HashMap, Collections.synchronizedMap의 차이

1. 개요

여러 thread가 동시에 Map을 읽고 쓸 때, HashMap, Collections.synchronizedMap, ConcurrentHashMap 세 가지 선택지가 있습니다. 이 세 가지는 멀티스레드 안전성, 잠금 방식, 순회 동작에서 뚜렷한 차이가 있습니다.

이 글에서는 각 유형을 실행 가능한 예제로 설명합니다. 읽은 후에는 어느 것을 선택해야 하고, 복합 작업을 atomic하게 작성하는 방법, 그리고 흔한 실수를 피하는 방법을 알게 될 것입니다.

2. 준비

예제는 Java 21을 사용합니다. 테스트 부분에는 JUnit 5와 AssertJ가 필요합니다:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Boot 3.x 프로젝트인 경우 spring-boot-starter-test에 두 라이브러리가 이미 포함되어 있습니다.

3. HashMap: 빠르지만 thread-safe하지 않음

HashMap에는 동기화 메커니즘이 없습니다. 단일 thread에서는 가장 빠른 선택입니다. 여러 thread가 동시에 쓸 때 데이터가 손실될 수 있습니다.

3.1. 동시 카운팅에서 업데이트 손실

다음 예제는 8개 thread를 사용하여 고객의 주문 수를 세고 있습니다:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HashMapRaceDemo {

    public static void main(String[] args) {
        Map<String, Integer> orderCount = new HashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
            }
        } // close()는 모든 작업이 완료될 때까지 대기합니다

        System.out.println("주문 수: " + orderCount.get("customer-1"));
    }
}

10,000개의 작업을 제출하며, 각 작업은 merge를 사용하여 1을 더합니다. Java 19부터 ExecutorService는 AutoCloseable이므로, try-with-resources는 모든 작업이 완료될 때까지 대기합니다. 실행할 때마다 결과가 달라집니다:

주문 수: 9817

이유는 HashMap의 merge가 여러 단계로 이루어져 있기 때문입니다: 기존 값을 읽고, 새 값을 계산한 후 다시 씁니다. 두 thread가 동시에 100을 읽고 101을 쓰면 한 번의 더하기가 손실됩니다. 더 나쁘게는, 두 thread가 동시에 resize를 트리거하면 내부 구조가 손상될 수 있습니다.

3.2. 순회 중 ConcurrentModificationException

HashMap의 iterator는 fail-fast입니다. Map이 iterator를 통하지 않고 수정되면 다음 next() 호출이 ConcurrentModificationException을 던질 수 있습니다. 이 오류는 한 thread 내에서도 발생합니다. 예를 들어 for-each 루프 내에서 map.remove()를 호출할 때입니다.

주의: Fail-fast 메커니즘은 “최선을 다한다”(best-effort) 방식입니다. 예외가 발생하지 않는다고 해서 코드가 올바르다는 뜻은 아닙니다.

4. Collections.synchronizedMap: 하나의 lock으로 모두 관리

Collections.synchronizedMap은 모든 Map을 래핑합니다. Wrapper의 모든 메서드는 synchronized (mutex) 내에서 실행되며, 기본적으로 mutex는 wrapper 객체 자신입니다.

Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());

섹션 3.1의 예제에서 초기화 줄을 위의 줄로 바꾸면 결과는 항상 올바릅니다:

주문 수: 10000

Java 8부터 이 wrapper는 merge, compute, putIfAbsent와 같은 기본 메서드도 재정의하므로, 각 개별 호출은 atomic합니다.

4.1. 복합 작업은 여전히 non-atomic

각 메서드는 개별적으로 동기화됩니다. 두 개의 호출을 함께 붙이면 이 시퀀스는 더 이상 atomic하지 않습니다:

Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());

// 잘못됨: 두 thread가 모두 "없음"을 보고 동시에 put할 수 있습니다
if (!customers.containsKey(id)) {
    customers.put(id, new Customer(id));
}

// 올바름: check-then-act 전체에서 lock을 유지합니다
synchronized (customers) {
    if (!customers.containsKey(id)) {
        customers.put(id, new Customer(id));
    }
}

더 간단한 방법은 customers.putIfAbsent(id, new Customer(id))를 사용하는 것입니다. 이것은 단일 호출이기 때문입니다.

4.2. 순회할 때 직접 lock을 잠가야 함

wrapper의 iterator는 동기화되지 않습니다. Javadoc에서는 Map 자체에서 lock을 잠그도록 요구합니다:

synchronized (customers) {
    for (Customer c : customers.values()) {
        System.out.println(c);
    }
}

synchronized 블록을 빠뜨리면 여전히 ConcurrentModificationException을 만날 수 있습니다. Lock을 잡으면 읽거나 쓰려는 다른 thread는 루프가 끝날 때까지 기다려야 합니다.

4.3. 성능 병목

전체 Map에 대해 하나의 lock만 있습니다. 두 thread가 서로 다른 두 key를 읽더라도 큐에 들어가야 합니다. Thread 수가 증가하면 lock 경합(contention)도 증가하고, throughput은 급격히 떨어집니다.

5. ConcurrentHashMap: 멀티스레드를 위해 설계됨

ConcurrentHashMap(java.util.concurrent 패키지)은 동시 접근을 위해 특별히 설계되었습니다. Java 8부터의 구현은 다음과 같습니다:

  • Lock 없는 읽기. get은 volatile 읽기를 사용하므로 거의 차단되지 않습니다.
  • Bucket별 lock. Bucket이 비어 있으면 CAS(compare-and-swap)를 사용하여 삽입합니다. Bucket에 요소가 있으면 bucket의 헤드 노드에서만 synchronized를 합니다. 다른 bucket에 쓰는 thread는 병렬로 실행됩니다.
  • 협력적 resize. 여러 thread가 새 테이블로 데이터를 이동하는 데 참여하며, 전체 map을 잠그지 않습니다.

5.1. Atomic 복합 작업

메서드 putIfAbsent, computeIfAbsent, computeIfPresent, compute, merge는 모두 각 key에 대해 atomic합니다:

import java.util.concurrent.ConcurrentHashMap;

public class InventoryService {

    private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

    public void addStock(String product, int quantity) {
        stock.merge(product, quantity, Integer::sum);
    }

    public boolean reserve(String product) {
        Integer result = stock.computeIfPresent(product,
                (key, current) -> current > 0 ? current - 1 : current);
        return result != null && result >= 0;
    }

    public int available(String product) {
        return stock.getOrDefault(product, 0);
    }
}

addStock은 merge를 사용하여 수량을 누적합니다. Key가 없으면 merge는 quantity를 초기 값으로 사용합니다. reserve는 computeIfPresent에서 재고를 1 감소시킵니다. 읽기, 확인, 쓰기 작업 전체가 해당 key에 대해 한 번에 atomic하게 일어나므로, 두 thread가 동시에 마지막 단위를 가져갈 수 없습니다.

매우 높은 빈도의 카운팅을 위해 LongAdder와 결합하면 경합을 더 줄일 수 있습니다:

ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();

5.2. Weakly consistent iterator

ConcurrentHashMap의 iterator는 절대 ConcurrentModificationException을 던지지 않습니다. 순회 중에 map을 수정할 수 있습니다:

ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
        Map.of("apple", 10, "banana", 0, "cherry", 3));

for (String product : stock.keySet()) {
    if (stock.get(product) == 0) {
        stock.remove(product);
    }
}

System.out.println(stock);
{apple=10, cherry=3}

Iterator는 생성된 어느 시점의 map 상태를 반영합니다. Iterator는 생성 후 일어난 변경 사항을 볼 수도, 보지 못할 수도 있습니다. 각 요소는 최대 한 번 반환됩니다. 이것은 의도된 트레이드오프입니다: 차단되지 않고 예외도 받지 않지만, 정확한 스냅샷도 얻지 못합니다.

5.3. null을 허용하지 않음

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1);       // NullPointerException

HashMap에서 get(key)가 null을 반환하는 것은 두 가지 의미를 가질 수 있습니다: key가 없거나 key가 null로 매핑됩니다. 단일 thread에서는 containsKey를 추가로 호출하여 구분합니다. 동시 환경에서는 두 호출 사이에 map이 변경될 수 있습니다. 그래서 ConcurrentHashMap은 null을 금지하여 get이 null을 반환할 때 key가 존재하지 않는다는 유일한 의미를 가지도록 합니다.

5.4. size()는 근사값일 뿐

다른 thread가 쓰는 동안 size()와 isEmpty()는 근사적인 상태만 반영합니다. Map이 Integer.MAX_VALUE 요소를 초과할 수 있다면 long을 반환하는 mappingCount()를 사용하세요. 특정 시점에 size()가 정확해야 한다는 것에 비즈니스 로직을 기반하지 마세요.

6. 동시 동작 테스트

다음 테스트는 ConcurrentHashMap이 높은 동시 부하에서 정확하게 카운팅하고 null 값을 거부함을 확인합니다:

import org.junit.jupiter.api.Test;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ConcurrentMapTest {

    @Test
    void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
        Map<String, Integer> orderCount = new ConcurrentHashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            IntStream.range(0, 10_000).forEach(i ->
                    executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
        }

        assertThat(orderCount).containsEntry("customer-1", 10_000);
    }

    @Test
    void givenNullValue_whenPut_thenThrowNpe() {
        Map<String, Integer> stock = new ConcurrentHashMap<>();

        assertThatThrownBy(() -> stock.put("apple", null))
                .isInstanceOf(NullPointerException.class);
    }
}

첫 번째 테스트는 항상 통과합니다. new HashMap<>()으로 바꾸면 대부분의 실행에서 10,000보다 작은 실제 값으로 테스트가 실패합니다.

주의: 멀티스레드 테스트가 통과한다고 해서 코드가 올바르다는 것을 증명하지 않습니다. Race condition은 실제 부하에서만 나타날 수 있습니다. API의 보장(예: merge의 atomicity)에 의존하세요. 단순히 테스트가 초록색이라는 것에만 의존하지 마세요.

7. ConcurrentHashMap의 흔한 실수

get 다음에 put. ConcurrentHashMap을 사용하더라도 자동으로 코드가 안전해지지는 않습니다:

// 잘못됨: get과 put 사이의 race condition
Integer current = stock.get("apple");
stock.put("apple", current - 1);

// 올바름
stock.computeIfPresent("apple", (k, v) -> v - 1);

Mapping 함수가 오래 걸리거나 부수 효과가 있음. Mapping 함수는 bucket이 lock된 상태에서 실행됩니다. computeIfAbsent 내에서 HTTP 호출이나 데이터베이스 쿼리를 하면 해당 bucket에 쓰려는 다른 모든 thread가 차단됩니다. Mapping 함수를 짧고 부수 효과 없이 유지하세요.

compute* 내에서 같은 map을 수정. Map의 mapping 함수 내에서 map.put 또는 map.computeIfAbsent를 호출하면 IllegalStateException: Recursive update를 던질 수 있거나 deadlock을 일으킬 수 있습니다. 전형적인 예는 computeIfAbsent를 사용한 recursive Fibonacci memoization cache입니다.

여러 key에 대해 atomic이 필요. ConcurrentHashMap은 각 key에 대해서만 atomicity를 보장합니다. 예를 들어 창고 A에서 창고 B로 재고를 이전하는 것은 두 key에 대한 작업입니다. 별도의 lock이 필요하거나 데이터 설계를 변경하여 하나의 key로 통합해야 합니다.

순서가 필요. ConcurrentHashMap은 삽입 순서를 유지하지 않습니다. 정렬된 key가 필요하면 ConcurrentSkipListMap을 사용하세요.

8. 종합 비교

기준                | HashMap         | synchronizedMap         | ConcurrentHashMap
--------------------|-----------------|-------------------------|--------------------------
Thread-safe         | 아니오          | 예                      | 예
Lock 메커니즘       | 없음            | 전체 map을 위한 단일 lock | CAS + bucket별 lock
동시 읽기            | 안전하지 않음   | 순차적(차단됨)          | Lock 없음
복합 작업           | Atomic 아님     | 외부 synchronized 필요  | compute/merge atomic
Iterator            | Fail-fast       | Fail-fast, 직접 lock 필요 | Weakly consistent
Key/value null      | 허용됨          | Wrapped map에 따라      | 허용 안 함
멀티스레드 성능     | —               | 경합 높을 때 낮음       | 우수

그러면 어느 것을 선택해야 할까요?

  • HashMap: map을 하나의 thread에서만 접근하거나, 생성 후 안전하게 publish되고 읽기만 수행합니다. 읽기 전용인 경우 Map.copyOf가 더 좋습니다.
  • Collections.synchronizedMap: LinkedHashMap이나 TreeMap 같은 기존 Map을 래핑하여 순서나 특수 동작을 유지해야 하고, 동시 부하가 낮거나, 긴 일련의 작업에 대해 전체 map을 lock해야 합니다.
  • ConcurrentHashMap: 캐시, 카운터, registry 및 여러 thread 간에 공유되는 모든 map의 기본 선택입니다.

9. 결론

HashMap은 빠르지만 여러 thread가 동시에 쓸 때 데이터가 손실됩니다. Collections.synchronizedMap은 단일 lock 덕분에 안전합니다. 대신 병목이 생기고, 순회하거나 여러 작업을 결합할 때 직접 lock을 잠가야 합니다. ConcurrentHashMap은 lock 없는 읽기와 bucket별 병렬 쓰기를 허용합니다. merge나 computeIfAbsent 같은 atomic 작업을 제공하며, iterator는 map이 변경되어도 예외를 던지지 않습니다.

두 가지 원칙을 기억하세요. 첫째, thread-safe collection이 자동으로 일련의 작업을 atomic하게 만들지는 않으므로 get 다음에 put 대신 compute*와 merge를 사용하세요. 둘째, mapping 함수를 짧고 부수 효과 없게 유지하고 내부에서 map을 수정하지 마세요.

javaconcurrencycollectionsconcurrenthashmapmultithreading