CodeGym /행동 /JAVA 25 SELF /스레드 안전 컬렉션: ConcurrentHashMap 및 기타

스레드 안전 컬렉션: ConcurrentHashMap 및 기타

JAVA 25 SELF
레벨 53 , 레슨 2
사용 가능

1. 왜 일반 컬렉션은 멀티스레딩에 적합하지 않은가

우리의 메인 애플리케이션(예: 채팅 룸)에서 컬렉션을 어떻게 사용했는지 떠올려 봅시다:

List<String> messages = new ArrayList<>();
messages.add("안녕!");
messages.add("잘 지내?");

단일 스레드 프로그램에서는 모든 것이 괜찮습니다. 하지만 여러 스레드가 동시에 같은 컬렉션에 요소를 추가, 삭제 또는 읽기 시작하면 — 데이터 레이스(race conditions), 일관성 붕괴, 정체 모를 버그의 세계에 오신 것을 환영합니다.

예를 들어, 한 스레드는 요소를 추가하고, 다른 스레드는 삭제하고, 세 번째는 이터레이션을 도는 상황 — 갑자기 ConcurrentModificationException이 터지고, 때로는 ArrayIndexOutOfBoundsException까지 나거나, 아예 “깨진” 컬렉션이 되기도 합니다.

고전적인 사례:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// writer와 reader를 서로 다른 스레드에서 돌리면 — 버그가 납니다!

결론: 일반 컬렉션(ArrayList, HashMap, HashSet 등)은 thread-safe가 아닙니다. 추가적인 동기화(synchronized, 락 등) 없이 여러 스레드에서 동시에 사용하면 안 됩니다.

2. Java의 스레드 안전 컬렉션에는 무엇이 있는가

Java는 여러분을 방치하지 않습니다. 멀티스레드 작업을 위해 패키지 java.util.concurrent에는 여러 스레드에서 안전하게 사용할 수 있는 컬렉션 모음(말장난이지만 ‘컬렉션의 컬렉션’)이 준비되어 있습니다.

주요 스레드 안전 컬렉션:

컬렉션 사용처 특징
ConcurrentHashMap
Map, 캐시, 빈번한 접근 고성능, 전역 lock 없음
CopyOnWriteArrayList
List, 변경 적고 읽기 많음 읽기는 빠르고, 변경은 느림
CopyOnWriteArraySet
Set, 변경 적고 읽기 많음 Copy-On-Write 리스트와 동일한 특성
ConcurrentLinkedQueue
큐, FIFO 빠름, 비차단, 작업 큐에 적합
ConcurrentSkipListMap
정렬되는 Map (NavigableMap) TreeMap의 스레드 안전한 대체
ConcurrentSkipListSet
정렬되는 Set TreeSet의 스레드 안전한 대체
BlockingQueue
블로킹 큐(스레드 풀) 인터페이스, 구현이 다양함

중요! 오래된 Collections.synchronizedList(list) 등은 java.util.concurrent의 현대적 컬렉션과는 완전히 동일하지 않습니다. 자세한 내용은 아래에서 다룹니다.

3. ConcurrentHashMap: 멀티스레딩 세계의 든든한 친구

ConcurrentHashMap<K, V>은 본질적으로 HashMap이지만 멀티스레딩을 위해 강화된 버전입니다. 여러 스레드가 동시에 데이터를 안전하게 읽고 쓸 수 있으며, 맵 전체를 통째로 막지 않습니다.

일반 HashMap에서 접근을 스레드 안전하게 만들려면 구조 전체에 락을 걸어야 하고 — 그러면 곧바로 병목이 생깁니다. 한 스레드가 작업하는 동안 나머지는 대기해야 하죠.

ConcurrentHashMap은 이를 더 똑똑하게 해결합니다. 초기 구현에서는 맵을 여러 세그먼트로 나눠 개별 락을 사용했고, 최신 구현에서는 개별 버킷 수준에서의 경량 원자 연산(CAS)을 적극 활용합니다. 덕분에 서로 다른 데이터에 접근하는 한, 스레드들이 평행하게 안전하게 작업할 수 있습니다.

ConcurrentHashMap 사용 예시

import java.util.concurrent.ConcurrentHashMap;

public class ChatStats {
    private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();

    public void increment(String user) {
        // 값을 원자적으로 증가
        userMessageCount.merge(user, 1, Integer::sum);
    }

    public int getCount(String user) {
        return userMessageCount.getOrDefault(user, 0);
    }
}

중요 포인트:

  • 여러 스레드에서 메서드를 호출해도 — 모두 올바르게 동작합니다.
  • merge는 원자적입니다. 여러 스레드가 동시에 카운터를 증가시켜도 결과가 정확합니다.
  • 읽기에는 추가 동기화가 필요 없습니다.

ConcurrentHashMapsynchronizedMap보다 좋은 이유

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

synchronizedMap을 쓰면 읽기, 쓰기, 삭제 같은 모든 연산이 맵 전체를 블로킹합니다. 한 스레드가 데이터를 다루는 동안 다른 스레드들은 차례를 기다려야 합니다.

ConcurrentHashMap은 훨씬 세련되게 설계되어, 동일한 버킷을 건드리지 않는 한 여러 스레드가 동시에 읽고 심지어 변경까지 수행할 수 있습니다. 실제 멀티스레드 시스템에서 성능이 크게 향상되며 — 때로는 수십 배까지 차이가 나기도 합니다.

4. CopyOnWriteArrayList와 CopyOnWriteArraySet

CopyOnWriteArrayListCopyOnWriteArraySet은 매번 변경(예: add() 또는 remove())이 일어날 때 전체 배열의 새 복사본을 만드는 특별한 컬렉션입니다. 대신 읽기는 어떤 동기화 없이도 완전히 안전합니다.

파티의 게스트 명단을 떠올려 보세요. 누가 오거나 떠날 때마다 리스트를 새로 작성해서 모두에게 최신 복사본을 나눠줍니다. 다소 비효율적이지만, 지금 누가 있는지 혼동할 일은 없습니다.

이 방식이 특히 유용한 경우

  • 읽기는 자주, 변경은 드물게 일어납니다.
  • 전형적인 케이스 — 이벤트 리스너 목록: 핸들러 추가/제거는 드물지만 이벤트는 계속 발생합니다.

예시: 채팅 리스너

import java.util.concurrent.CopyOnWriteArrayList;

public class ChatRoom {
    private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(ChatListener listener) {
        listeners.add(listener);
    }

    public void removeListener(ChatListener listener) {
        listeners.remove(listener);
    }

    public void sendMessage(String message) {
        // 누군가가 지금 구독/해지 중이더라도 멀티스레드에서 안전
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

중요한 특징:

  • CopyOnWriteArrayList에 대한 이터레이션은 절대 ConcurrentModificationException을 던지지 않습니다.
  • 변경(add/remove)은 시간·메모리 비용이 큽니다(배열 전체 복사!).
  • 큰 컬렉션에 빈번한 변경이 있는 경우에는 사용하지 않는 것이 좋습니다.

5. 다른 thread-safe 컬렉션들

ConcurrentLinkedQueue

ConcurrentLinkedQueue는 FIFO 원칙으로 동작하는 비차단 큐입니다. 명시적인 락 없이도 여러 스레드가 동시에 안전하게 요소를 추가·가져갈 수 있습니다. 스레드 간 작업 전달에 자주 사용되며 — 빠르고 병목이 적습니다.

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // 큐가 비어 있으면 null 반환

ConcurrentSkipListMap과 ConcurrentSkipListSet

  • TreeMapTreeSet의 스레드 안전한 대응물입니다.
  • 항상 정렬된 순서를 유지합니다.
  • 키의 순서를 유지하는 것이 중요한 경우에 사용합니다.
import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b

BlockingQueue와 그 구현들

  • 공간이 생기거나 요소가 나타날 때까지 대기하는 블로킹 연산을 지원하는 큐 인터페이스입니다.
  • 구현: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue 등.
  • 스레드 풀, 프로듀서-컨슈머 패턴에서 사용됩니다.
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // 큐가 가득 차면 블로킹
String t = blockingQueue.take(); // 큐가 비어 있으면 블로킹

6. 예제: 컬렉션에서의 안전한 연산

예제 1: 메시지 카운트를 위한 스레드 안전한 Map

import java.util.concurrent.ConcurrentHashMap;

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

// 스레드 1
messageCount.put("안나", 1);
// 스레드 2
messageCount.put("안나", messageCount.getOrDefault("안나", 0) + 1); // 원자적이지 않음!

// 올바른 방법(원자적):
messageCount.merge("안나", 1, Integer::sum);

예제 2: CopyOnWriteArrayList 이터레이션

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("안톤");
users.add("마리야");

for (String user : users) {
    System.out.println(user);
    users.remove(user); // ConcurrentModificationException을 던지지 않음!
}
System.out.println(users); // []

예제 3: 스레드 간 작업 큐

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// 프로듀서 스레드
queue.add("task-1");

// 컨슈머 스레드
String task = queue.poll(); // 비어 있으면 null

7. 유용한 팁

스레드 안전 컬렉션을 언제(그리고 왜) 사용할까

다음과 같은 경우 스레드 안전 컬렉션 사용이 타당합니다:

  • 하나의 컬렉션을 여러 스레드가 공유할 때.
  • 각 연산을 일일이 수동 동기화하고 싶지 않을 때.
  • 데이터 레이스와 일관성 오류를 피하는 것이 중요할 때.

전형적인 시나리오:

  • 멀티스레드 시스템의 캐시(예: 사용자 세션 저장을 위한 ConcurrentHashMap).
  • 스레드 간 작업 큐(ConcurrentLinkedQueue, BlockingQueue).
  • 이벤트 리스너 목록(CopyOnWriteArrayList).
  • 데이터의 멀티스레드 처리(예: MapReduce 스타일).

제약과 함정

  • 여러 요소에 걸친 연산은 원자적이지 않습니다. if (!map.containsKey(k)) map.put(k, v) 같은 패턴은 원자적이지 않습니다. putIfAbsent, computeIfAbsent, merge를 사용하세요.
  • CopyOnWriteArrayList는 빈번한 변경에 비효율적입니다. 큰 크기에서 잦은 add/remove가 발생하면 오버헤드가 폭증합니다.
  • ConcurrentHashMap 이터레이션은 ‘약한’ 일관성입니다. 순회는 약한 일관성 스냅샷을 제공합니다. 병렬 변경의 일부를 보지 못할 수 있습니다.
  • 스레드 안전 컬렉션이 동기화 문제를 모두 해결하지는 않습니다. 로직이 여러 컬렉션/변수를 동시에 다루면 외부 동기화가 필요합니다(synchronized, locks, 원자적 클래스 등).

8. 스레드 안전 컬렉션 사용 시 흔한 실수

오류 1: 스레드 안전 컬렉션에 마법을 기대함. “컬렉션이 thread-safe니까 뭐든 해도 동기화를 신경 쓸 필요가 없다.” 유감스럽게도, 여러 연산을 묶은 시퀀스(검사 + 추가)는 원자적이지 않습니다. putIfAbsent, compute, merge 같은 전용 메서드를 사용하세요.

오류 2: 큰 컬렉션에 잦은 변경이 있는데도 CopyOnWriteArrayList 사용. 리스너 목록에는 적합하지만, 10,000+ 요소에 자주 변경이 일어나면 메모리와 시간 비용이 커집니다.

오류 3: 일반 컬렉션 사용 중 ConcurrentModificationException. ArrayListHashMap을 이터레이션하는 동안 다른 스레드가 컬렉션을 변경하면 ConcurrentModificationException을 맞닥뜨립니다. 전용 컬렉션을 사용하거나 수동으로 접근을 블로킹하세요.

오류 4: 복잡한 연산의 원자성을 잊음. 여러 컬렉션을 한꺼번에 변경하거나 관련된 동작을 연속으로 수행해야 한다면 — 스레드 안전 컬렉션만으로는 부족합니다. 외부 동기화나 트랜잭션성 로직을 적용하세요.

오류 5: ConcurrentHashMap 이터레이션에서의 실수. 이터레이션은 약한 일관성이므로, 이터레이터를 맵 상태의 “스냅샷”으로 간주할 수 없습니다. 일관된 스냅샷이 필요하면 데이터를 별도 구조로 복사하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION