Java 동시성 컬렉션 (1) – Collections.synchronized와 한계

Java 컬렉션의 근본적인 특징

java.util 패키지의 컬렉션 프레임워크는 기본적으로 Thread-safe하지 않다. 그렇다면 왜 처음부터 모든 컬렉션에 synchronized를 걸어두지 않았을까? 그 이유는 동기화는 성능 비용을 수반하기 때문이다

  • synchronized, Lock, CAS 등 모든 동기화 기법은 성능과 트레이드오프 관계
  • 동기화를 하지 않는 것이 가장 빠름
  • 컬렉션이 항상 멀티스레드 환경에서 사용되는 것은 아님
  • 단일 스레드 환경에서도 동기화 비용이 발생하면 불필요한 성능 저하

Vector의 교훈

Java는 과거 이런 실수를 했다

// java.util.Vector - 모든 메서드가 synchronized
public class Vector<E> {
    public synchronized boolean add(E e) { ... }
    public synchronized E get(int index) { ... }
    public synchronized E remove(int index) { ... }
    // ... 모든 메서드에 synchronized
}
결과
  • 단일 스레드 환경에서도 불필요한 동기화 비용 발생
  • ArrayList에 비해 성능 저하
  • 현재는 하위 호환성을 위해서만 유지
  • 사용을 권장하지 않는다

교훈: 동기화의 필요성을 정확히 판단하고 필요한 경우에만 적용해야 한다

Collections.synchronizedXxx() – 프록시 방식의 동기화

Java가 제공하는 프록시 솔루션

package thread.collection.java;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class SynchronizedListMain {
    public static void main(String[] args) {
        // 일반 ArrayList를 동기화된 리스트로 변환
        List<String> list = Collections.synchronizedList(new ArrayList<>());
        
        list.add("data1");
        list.add("data2");
        list.add("data3");
        
        System.out.println(list.getClass());
        System.out.println("list = " + list);
    }
}
실행 결과
class java.util.Collections$SynchronizedRandomAccessList
list = [data1, data2, data3]

Collections.synchronizedList()의 동작

public static <T> List<T> synchronizedList(List<T> list) {
    return new SynchronizedRandomAccessList<>(list);
}

이는 다음과 같은 구조이다

new SynchronizedRandomAccessList<>(new ArrayList<>())

프록시 패턴 적용

Client (사용자 코드)
   ↓
SynchronizedRandomAccessList (프록시)
   ↓ synchronized 적용 후 호출
ArrayList (원본)

실제 구현 예시

// SynchronizedList의 add() 메서드
public boolean add(E e) {
    synchronized (mutex) {  // 1. lock 획득
        return c.add(e);    // 2. 원본 호출
    }                       // 3. lock 반납
}

구현했던 SyncProxyList와 완전히 동일한 패턴이다

제공되는 동기화 메서드들

// List
Collections.synchronizedList(new ArrayList<>());
Collections.synchronizedList(new LinkedList<>());

// Set
Collections.synchronizedSet(new HashSet<>());
Collections.synchronizedSet(new TreeSet<>());

// Map
Collections.synchronizedMap(new HashMap<>());
Collections.synchronizedMap(new TreeMap<>());

// Collection
Collections.synchronizedCollection(collection);

// 정렬된 컬렉션
Collections.synchronizedSortedSet(new TreeSet<>());
Collections.synchronizedSortedMap(new TreeMap<>());
Collections.synchronizedNavigableSet(new TreeSet<>());
Collections.synchronizedNavigableMap(new TreeMap<>());
장점
  • 코드 한 줄로 해결: 기존 코드를 전혀 수정하지 않고 동기화 적용
  • 유연성: 필요할 때만 동기화 적용 가능
  • 호환성: 모든 컬렉션 타입 지원
// 단일 스레드 환경
List<String> singleThreadList = new ArrayList<>();

// 멀티스레드 환경으로 전환 필요 시
List<String> multiThreadList = Collections.synchronizedList(singleThreadList);
실제 사용 예시
public class UserCache {
    // 멀티스레드 환경에서 안전하게 사용
    private final Map<String, User> userMap = 
        Collections.synchronizedMap(new HashMap<>());
    
    public void addUser(User user) {
        userMap.put(user.getId(), user);
    }
    
    public User getUser(String id) {
        return userMap.get(id);
    }
}

synchronized 프록시 방식의 한계

하지만 이 방식에는 치명적인 단점들이 있다. 실무에서는 이러한 이유로 다른 방법을 선호한다

동기화 오버헤드
public synchronized void add(Object e) {
    // 모든 호출마다 lock 획득/반납 비용 발생
    target.add(e);
}
문제점
  • 각 메서드 호출마다 동기화 비용 추가
  • 단순한 작업에도 오버헤드 발생
  • 누적되면 상당한 성능 저하
성능 측정 예시
// 벤치마크 (100만 번 add 작업)
ArrayList:           50ms
SynchronizedList:    150ms (3배 느림)
잠금 범위가 너무 넓다 – 가장 큰 문제
// 프록시 방식 - 메서드 전체가 임계 영역
public synchronized void complexOperation() {
    synchronized (mutex) {
        // 동기화가 필요 없는 부분
        prepare();           // ← 여기도 lock
        
        // 실제로 동기화가 필요한 부분
        criticalSection();   // ← 여기만 필요
        
        // 동기화가 필요 없는 부분
        cleanup();           // ← 여기도 lock
    }
}
문제점
  • 메서드 전체에 lock이 걸림
  • 불필요한 부분까지 동기화됨
  • 잠금 경합 (Lock Contention)증가
  • 병렬 처리 효율성 저하
실제 영향
// Thread-1이 synchronized 메서드 실행 중
Thread-1: [====== 10초 작업 중 ======]

// Thread-2, 3, 4는 모두 대기
Thread-2: [......대기......][실행]
Thread-3: [......대기............][실행]
Thread-4: [......대기..................][실행]
정교한 동기화 불가능

프록시는 메서드 단위로만 동기화를 적용하기 때문에 내부 최적화가 불가능하다

// 최적화된 구현을 하고 싶지만...
public void optimizedAdd(E e) {
    // 동기화 불필요한 전처리
    E element = validate(e);
    E transformed = transform(element);
    
    synchronized (this) {
        // 꼭 필요한 부분만 동기화
        internalArray[size++] = transformed;
    }
    
    // 동기화 불필요한 후처리
    notifyListeners(transformed);
}

// 하지만 프록시 방식은...
public synchronized void add(E e) {
    target.add(e);  // 원본의 모든 작업이 lock 안에서 실행
}
한계
  • 내부 최적화 불가능
  • 특정 부분만 선택적으로 동기화할 수 없음
  • 과도한 동기화로 성능 저하
복합 연산의 동기화 문제

개별 메서드는 동기화되어도, 복합 연산은 여전히 위험하다

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

// 잘못된 예 - 이 전체가 원자적이지 않음
if (!map.containsKey("count")) {     // ← synchronized
    map.put("count", 1);             // ← synchronized
} else {
    int count = map.get("count");    // ← synchronized
    map.put("count", count + 1);     // ← synchronized
}
// 각 메서드는 동기화되지만, 전체 로직은 동기화되지 않음

// 올바른 해결책 (추가 동기화 필요)
synchronized (map) {
    if (!map.containsKey("count")) {
        map.put("count", 1);
    } else {
        int count = map.get("count");
        map.put("count", count + 1);
    }
}
실제 성능 비교
// 벤치마크: 4개 스레드, 각 100만 번 작업
환경                    소요 시간    처리량
─────────────────────────────────────────
단일 스레드 ArrayList        100ms     10M ops/s
SynchronizedList          800ms      1.25M ops/s  (8배 느림)
ConcurrentHashMap         200ms      5M ops/s     (4배 빠름)

핵심 문제: 단순무식하게 모든 메서드에 synchronized를 거는 방식이라 동기화 최적화가 전혀 이루어지지 않는다

반복자(Iterator) 문제
List<String> list = Collections.synchronizedList(new ArrayList<>());

// 위험한 코드
for (String item : list) {
    // 반복 중에는 명시적으로 동기화 필요
    list.remove(item);  // ConcurrentModificationException 발생 가능
}

// 올바른 코드
synchronized (list) {
    for (String item : list) {
        list.remove(item);
    }
}

고성능 동시성 컬렉션

synchronized 방식의 한계를 극복하려면?

Java는 이러한 한계를 극복하기 위해 정교하게 최적화된 동시성 컬렉션을 제공한다

java.util.concurrent 패키지의 특징

정교한 잠금 메커니즘

// ConcurrentHashMap: 세그먼트별 락
   전체 맵을 16개 세그먼트로 분할
   ┌──┬──┬──┬──┐
   │S1│S2│S3│S4│  각 세그먼트는 독립적으로 락
   └──┴──┴──┴──┘
   → 최대 16개 스레드가 동시 작업 가능!

다양한 최적화 기법 조합

  • synchronized
  • ReentrantLock
  • CAS (Compare-And-Swap)
  • Volatile 변수
  • 분할 잠금 (Segment Lock)
  • 락-프리 (Lock-Free) 알고리즘

선택적 동기화

  • 필요한 부분만 동기화
  • 읽기 작업은 동기화 없이 수행 (일부)
  • 최소한의 잠금 범위

성능 차이 체감하기

// Collections.synchronizedMap
Map<String, String> syncMap = 
    Collections.synchronizedMap(new HashMap<>());
// 전체 맵에 하나의 lock
// 한 번에 한 스레드만 접근 가능

// ConcurrentHashMap
Map<String, String> concurrentMap = 
    new ConcurrentHashMap<>();
// 세그먼트별 lock
// 여러 스레드가 동시에 서로 다른 세그먼트 접근 가능
// 4~8배 빠른 성능
Java 동시성 컬렉션 (2) – Concurrent Collection

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 1편, 멀티스레드와 동시성 중 일부