동시성 컬렉션

동시성 컬렉션이 필요한 이유 – 스레드 안전성 문제

java.util 패키지의 ArrayList, LinkedList, HashSet, HashMap과 같은 일반적인 컬렉션들은 스레드 세이프(Thread Safe)하지 않다. 즉, 여러 스레드가 동시에 접근할 때 데이터 손상이나 예상치 못한 결과를 초래할 수 있다

SimpleList

public interface SimpleList {
    int size();

    void add(Object e);

    Object get(int index);
}

SimpleListMain

public class SimpleListMain {
    public static void main(String[] args) throws InterruptedException {
//        test(new BasicList());
//        test(new SyncList());
        test(new SyncProxyList(new BasicList()));
    }

    private static void test(SimpleList list) throws InterruptedException {
        log(list.getClass().getSimpleName());

        // A를 리스트에 저장하는 코드
        Runnable addA = new Runnable() {
            @Override
            public void run() {
                list.add("A");
                log("Thread-1: list.add(A)");
            }
        };

        // B를 리스트에 저장하는 코드
        Runnable addB = new Runnable() {
            @Override
            public void run() {
                list.add("B");
                log("Thread-2: list.add(B)");
            }
        };

        Thread thread1 = new Thread(addA);
        Thread thread2 = new Thread(addB);

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
        log(list);
    }
}

BasicList의 add() 메서드 문제

public class BasicList implements SimpleList {
    private Object[] elementData;
    private int size = 0;

    // ... (생략)

    @Override
    public void add(Object e) {
        elementData[size] = e; // (1)
        sleep(100); // 멀티스레드 문제 확인을 위한 지연
        size++;       // (2)
    }
    // ... (생략)
}

두 스레드가 동시에 add()를 호출할 경우, 아래와 같은 문제가 발생할 수 있다

  • 데이터 손실
    • 스레드1이 elementData[size] = e; (1)를 실행하여 elementData[0] = “A”를 대입한다
    • 스레드2가 거의 동시에 elementData[size] = e; (1)를 실행한다. 이때 size는 아직 0이므로 elementData[0] = “B”를 대입하여 “A”를 덮어쓴다
    • 결과적으로 리스트에는 “B”만 남고 “A”는 사라진다
  • size 값의 불일치
    • 두 스레드가 동시에 size++; (2)를 실행할 때, 각 스레드가 size의 현재 값을 읽고 1을 더하는 과정에서 경합이 발생할 수 있다
    • 예를 들어, 스레드1이 size를 0으로 읽고, 스레드2도 size를 0으로 읽은 뒤 각각 1을 더하고 최종적으로 size에 1을 대입하면, 실제로는 두 번의 추가가 있었음에도 size는 1이 될 수 있다

이러한 문제는 다음 두 가지 근본 원인으로 발생한다

원자성(Atomicity) 문제
  • add() 메서드가 elementData[size] = e;와 size++; 두 개의 연산으로 이루어져 있어 중간에 다른 스레드가 끼어들 수 있다
  • size++ 자체도 실제로는 3단계 연산이다
    • size 값 읽기 → 1 증가 → 결과 쓰기
  • volatile로는 이 복합 연산의 원자성을 보장할 수 없다
가시성(Visibility) 문제
  • CPU 캐시로 인해 한 스레드가 변경한 size 값을 다른 스레드가 즉시 볼 수 없을 수 있다.
  • volatile 키워드는 가시성 문제만 해결한다 (메인 메모리에서 직접 읽기 / 쓰기)
  • 하지만 이 경우는 원자성 문제도 있어서 volatile만으로는 부족하다

해결책 비교

  • volatile: 가시성만 보장 → 단순 읽기 / 쓰기에만 적합 (예: boolean flag)
  • synchronized: 가시성 + 원자성 보장 → 복합 연산에 필요
  • AtomicInteger: 원자적 증감 연상 제공 → size++를 원자적으로 처리 가능

동시성 문제 해결 – 동기화

멀티스레드 환경에서 컬렉션을 안전하게 사용하려면 synchronized 키워드나 Lock 등을 사용하여 동기화(Synchronization)를 적용해야 한다. 이는 한 번에 하나의 스레드만 특정 코드 블록(임계 영역)에 접근하도록 하여 데이터 일관성을 보장한다

synchronized 키워드의 두 가지 역할

  • 상호 배제(Mutual Exclusion): 한 번에 하나의 스레드만 락을 획득하여 임계 영역에 진입할 수 있다
  • 메모리 가시성 보장: synchronized 블록을 진입할 때는 메인 메모리에서 최신 값을 읽고 나갈 때는 변경된 값을 메인 메모리에 기록한다. 이로써 CPU 캐시로 인한 가시성 문제도 해결된다

주의 사항

  • synchronized 메서드는 인스턴스의 모니터 락(this)를 사용한다
  • 같은 인스턴스의 다른 synchronized 메서드들도 동일한 락을 공유하므로, 한 메서드가 실행 중이면 다른 synchronized 메서드들도 대기해야 한다

SyncList를 통한 동기화

public class SyncList implements SimpleList {
    // ... (생략)

    @Override
    public synchronized int size() { // synchronized 추가
        return size;
    }

    @Override
    public synchronized void add(Object e) { // synchronized 추가
        elementData[size] = e;
        sleep(100);
        size++;
    }

    @Override
    public synchronized Object get(int index) { // synchronized 추가
        return elementData[index];
    }
    // ... (생략)
}

이 코드를 사용하면 두 스레드가 동시에 add()를 호출하더라도, 한 스레드가 락을 획득하여 add()를 완료한 후 락을 반납하면 다른 스레드가 락을 획득하여 add()를 실핸하게 된다. 이로 인해 데이터 손실 없이 [A, B]와 같이 정확한 결과가 보장된다

동기화의 효율적인 적용 – 프록시 패턴

모든 컬렉션 클래스(ArrayList, LinkedList 등)를 복사하여 synchronized 버전을 만드는 것은 비효율적이다. 기존 코드를 변경하지 않으면서 동기화 기능을 추가하는 효율적인 방법은 프록시(Proxy) 패턴을 사용하는 것이다

프록시 패턴의 원리

프록시 패턴은 실제 객체 (BasicList)에 대한 접근을 제어하기 위해 대리자 객체 (SyncProxyList)를 두는 디자인 패턴이다. 클라이언트 프록시 객체를 통해 실제 객체에 접근하며, 프록시 객체는 실제 객체의 메서드를 호출하기 전에 추가적인 로직 (여기서는 동기화)을 처리한다. Spring, JPA 강의 등에서 많이 들어본 프록시이다

SyncProxyList 프록시 클래스

public class SyncProxyList implements SimpleList {
    private SimpleList target; // 실제 객체 (예: BasicList)

    public SyncProxyList(SimpleList target) {
        this.target = target;
    }

    @Override
    public synchronized int size() { // 동기화 처리 후 실제 객체 메서드 호출
        return target.size();
    }

    @Override
    public synchronized void add(Object e) { // 동기화 처리 후 실제 객체 메서드 호출
        target.add(e);
    }

    @Override
    public synchronized Object get(int index) { // 동기화 처리 후 실제 객체 메서드 호출
        return target.get(index);
    }
    // ... (생략)
}

클라이언트는 SyncProxyList 인스턴스를 사용하며, SyncProxyList는 synchronized를 통해 동기화를 처리한 후 주입받은 BasicList의 메서드를 호출한다

프록시 패턴의 장점

  • 원본 코드 변경 없음: BasicList와 같은 원본 컬렉션 코드를 전혀 수정하지 않고 동기화 기능을 추가할 수 있다
  • 재사용성: SimpleList 인터페이스를 구현하는 다른 컬렉션(예: BasicLinkedList)에도 SyncProxyList를 그대로 활용하여 동기화를 적용할 수 있다
  • 유연성: 클라이언트는 SimpleList 인터페이스만 의존하므로, 어떤 구현체가 사용되는지 알 필요 없이 유연하게 코드를 작성할 수 있다

synchronized 기반 동기화의 한계

  • 성능 저하: 모든 메서드 호출마다 락 획득 / 해제 오버헤드가 발생한다. 특히 읽기 작업이 많은 경우에도 락을 획득해야 하므로 비효율적이다
  • 동시성 제한: 여러 스레드가 동시에 읽기 작업을 수행할 수 있음에도 불구하고 synchronized는 한 번에 하나의 스레드만 허용한다
  • 복합 연산의 문제: 복합 연산의 경우, 외부에서 명시적으로 동기화해야 한다
  • 데드락 위험: 여러 락을 사용하는 경우 락 획득 순서에 따라 데드락이 발생할 수 있다

일반적인 java.util 컬렉션은 멀티스레드 환경에서 스레드 세이프하지 않아 데이터 정합성 문제가 발생할 수 있다. 이를 해결하기 위해서는 synchronized나 Lock을 통한 동기화가 필수적이다. 프록시 패턴을 활용하면 기존 컬렉션 코드를 변경하지 않고 효율적으로 동시화 기능을 적용하여 스레드 안전한 동시성 컬렉션처럼 스레드 안전한 구현할 수 있다. 실제 자바에서는 java.util.concurrent 패키지에 이러한 동시성 문제를 해결한 다양한 컬렉션 클래스(예: ConcurrentHashMap, CopyOnWriteArrayList 등)를 제공한다

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