이 글에서 얻는 것

  • 동기화 컬렉션 vs 동시성 컬렉션의 차이를 이해합니다
  • ConcurrentHashMap의 내부 동작과 성능 특성을 알아봅니다
  • BlockingQueue로 생산자-소비자 패턴을 구현합니다

왜 동시성 컬렉션인가?

문제: 동기화 컬렉션의 한계

// ❌ 동기화 컬렉션 - 전체 락
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

// 모든 연산에 전체 락 →  심각한 병목
syncMap.put("key1", 1);  // 전체 맵 락
syncMap.get("key1");     // 전체 맵 락
flowchart LR
    subgraph "synchronizedMap"
        Lock["🔒 단일 락"]
        T1[Thread 1] -->|대기| Lock
        T2[Thread 2] -->|대기| Lock
        T3[Thread 3] -->|대기| Lock
    end
    
    style Lock fill:#ffebee,stroke:#c62828

해결: 동시성 컬렉션

flowchart LR
    subgraph "ConcurrentHashMap"
        S1["Segment 1 🔒"]
        S2["Segment 2 🔒"]
        S3["Segment 3 🔒"]
        
        T1[Thread 1] --> S1
        T2[Thread 2] --> S2
        T3[Thread 3] --> S3
    end
    
    style S1 fill:#e8f5e9,stroke:#2e7d32
    style S2 fill:#e8f5e9,stroke:#2e7d32
    style S3 fill:#e8f5e9,stroke:#2e7d32

**세분화된 락(Fine-grained locking)**으로 동시 접근 허용


ConcurrentHashMap

내부 구조 (Java 8+)

flowchart TB
    CHM[ConcurrentHashMap]
    
    subgraph "Node Array"
        B0["Bucket 0\n(Node)"]
        B1["Bucket 1\n(TreeBin)"]
        B2["Bucket 2\n(null)"]
        B3["Bucket 3\n(Node)"]
    end
    
    CHM --> B0
    CHM --> B1
    CHM --> B2
    CHM --> B3
    
    B0 --> N1[Node] --> N2[Node]
    B1 --> T1["TreeNode\n(Red-Black)"]

특징:

  • 버킷별 락: 각 버킷에 독립적 락
  • CAS 연산: 락 없이 원자적 업데이트
  • TreeBin 변환: 충돌이 많으면 LinkedList → Red-Black Tree

주요 연산

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

// 기본 연산 (스레드 안전)
map.put("key1", 1);
map.get("key1");
map.remove("key1");

// 원자적 복합 연산 ⭐
map.putIfAbsent("key", 100);           // 없으면 추가
map.computeIfAbsent("key", k -> 100);  // 없으면 계산 후 추가
map.computeIfPresent("key", (k, v) -> v + 1);  // 있으면 업데이트
map.merge("key", 1, Integer::sum);     // 있으면 합계, 없으면 추가

// ⚠️ 주의: 아래는 원자적이지 않음!
if (!map.containsKey("key")) {  // check
    map.put("key", value);      // then act → 경쟁 조건!
}

// ✅ 올바른 방법
map.computeIfAbsent("key", k -> expensiveComputation());

성능 비교

연산HashMapsynchronizedMapConcurrentHashMap
단일 스레드매우 빠름느림 (락 오버헤드)빠름
다중 스레드 읽기N/A (안전하지 않음)느림 (경합)매우 빠름
다중 스레드 쓰기N/A매우 느림빠름

실무 활용: 캐시 구현

public class SimpleCache<K, V> {
    private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();
    private final Function<K, V> loader;
    
    public SimpleCache(Function<K, V> loader) {
        this.loader = loader;
    }
    
    public V get(K key) {
        // 원자적으로 캐시 로드
        return cache.computeIfAbsent(key, loader);
    }
    
    public void invalidate(K key) {
        cache.remove(key);
    }
    
    public void invalidateAll() {
        cache.clear();
    }
}

CopyOnWrite 컬렉션

개념

flowchart TB
    subgraph "CopyOnWriteArrayList"
        Original["원본 배열\n[A, B, C]"]
        
        Read1[읽기 1] --> Original
        Read2[읽기 2] --> Original
        
        Write["쓰기: D 추가"]
        Write --> Copy["복사본 생성\n[A, B, C, D]"]
        Copy --> Replace["원본 교체"]
    end

동작 원리:

  • 읽기: 락 없이 현재 배열 참조
  • 쓰기: 전체 배열 복사 → 수정 → 교체

사용 사례

// ✅ 읽기가 대부분, 쓰기가 드문 경우
CopyOnWriteArrayList<EventListener> listeners = new CopyOnWriteArrayList<>();

// 읽기: 락 없이 안전한 순회
for (EventListener listener : listeners) {
    listener.onEvent(event);  // ConcurrentModificationException 없음
}

// 쓰기: 전체 복사 (비용 높음)
listeners.add(newListener);

// ✅ 적합한 경우
// - 이벤트 리스너 관리
// - 설정(Configuration) 목록
// - 화이트리스트/블랙리스트

// ❌ 부적합한 경우
// - 자주 변경되는 데이터
// - 대용량 데이터

CopyOnWriteArraySet

// 중복 없는 CopyOnWrite Set
CopyOnWriteArraySet<String> allowedIps = new CopyOnWriteArraySet<>();

allowedIps.add("192.168.1.1");
allowedIps.add("192.168.1.2");

// 읽기 (락 없음)
if (allowedIps.contains(clientIp)) {
    // 허용
}

BlockingQueue

생산자-소비자 패턴

flowchart LR
    subgraph Producers
        P1[Producer 1]
        P2[Producer 2]
    end
    
    subgraph "BlockingQueue"
        Q["[Task1, Task2, Task3]"]
    end
    
    subgraph Consumers
        C1[Consumer 1]
        C2[Consumer 2]
    end
    
    P1 -->|put| Q
    P2 -->|put| Q
    Q -->|take| C1
    Q -->|take| C2

구현체 비교

구현체경계특징
ArrayBlockingQueue유한배열 기반, FIFO
LinkedBlockingQueue유한/무한링크드리스트 기반
PriorityBlockingQueue무한우선순위 정렬
SynchronousQueue0직접 전달 (버퍼 없음)
DelayQueue무한지연 후 사용 가능

사용 예시

// 작업 큐
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(100);

// 생산자
public void submitTask(Runnable task) throws InterruptedException {
    workQueue.put(task);  // 큐가 가득 차면 블로킹
}

// 소비자 (Worker Thread)
public void processLoop() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Runnable task = workQueue.take();  // 비어있으면 블로킹
            task.run();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }
}

주요 메서드

메서드블로킹타임아웃예외 발생
put()✅ 대기--
offer(timeout)--
take()✅ 대기--
poll(timeout)--
add()--✅ 예외
// 타임아웃 있는 offer
boolean success = queue.offer(task, 5, TimeUnit.SECONDS);
if (!success) {
    // 5초 내 삽입 실패 처리
    handleQueueFull();
}

// 타임아웃 있는 poll
Runnable task = queue.poll(1, TimeUnit.SECONDS);
if (task == null) {
    // 1초 내 작업 없음
    handleIdleState();
}

실무 패턴

ConcurrentHashMap 기반 카운터

public class ConcurrentCounter {
    private final ConcurrentHashMap<String, LongAdder> counters = 
        new ConcurrentHashMap<>();
    
    public void increment(String key) {
        counters.computeIfAbsent(key, k -> new LongAdder()).increment();
    }
    
    public long get(String key) {
        LongAdder adder = counters.get(key);
        return adder != null ? adder.sum() : 0;
    }
}

// 사용
ConcurrentCounter hitCounter = new ConcurrentCounter();
hitCounter.increment("/api/users");
hitCounter.increment("/api/orders");

스레드 안전 싱글톤 레지스트리

public class ServiceRegistry {
    private static final ConcurrentHashMap<Class<?>, Object> services = 
        new ConcurrentHashMap<>();
    
    @SuppressWarnings("unchecked")
    public static <T> T getService(Class<T> type, Supplier<T> factory) {
        return (T) services.computeIfAbsent(type, t -> factory.get());
    }
}

// 사용
UserService userService = ServiceRegistry.getService(
    UserService.class, 
    UserServiceImpl::new
);

선택 가이드

flowchart TD
    Start[컬렉션 선택] --> Q1{스레드 안전 필요?}
    
    Q1 -->|No| Regular[일반 컬렉션]
    Q1 -->|Yes| Q2{Map/List/Queue?}
    
    Q2 -->|Map| Q3{읽기:쓰기 비율?}
    Q3 -->|읽기 >> 쓰기| CHM[ConcurrentHashMap]
    Q3 -->|쓰기 많음| CHM
    
    Q2 -->|List| Q4{쓰기 빈도?}
    Q4 -->|드물게| COWAL[CopyOnWriteArrayList]
    Q4 -->|자주| Sync["synchronized 또는\nCollections.synchronizedList"]
    
    Q2 -->|Queue| Q5{블로킹 필요?}
    Q5 -->|Yes| BQ[BlockingQueue]
    Q5 -->|No| CQ[ConcurrentLinkedQueue]
    
    style CHM fill:#e8f5e9,stroke:#2e7d32
    style COWAL fill:#e8f5e9,stroke:#2e7d32
    style BQ fill:#e8f5e9,stroke:#2e7d32

요약

동시성 컬렉션 체크리스트

요구사항추천
스레드 안전 MapConcurrentHashMap
읽기 위주 ListCopyOnWriteArrayList
생산자-소비자BlockingQueue
스레드 안전 SetConcurrentSkipListSet
정렬된 MapConcurrentSkipListMap

핵심 원칙

  1. synchronized 대신 동시성 컬렉션: 더 나은 성능
  2. 원자적 복합 연산 사용: computeIfAbsent, merge
  3. 적절한 구현체 선택: 읽기/쓰기 패턴 고려
  4. 블로킹 vs 논블로킹: 요구사항에 맞게