본문 바로가기

Java

[JAVA] 동시성 컬렉션

0. 들어가기 전

멀티스레드 환경에서 하나의 컬렉션 인스턴스를 여러 스레드가 동시에 사용해야 할 때가 많습니다. 예를 들어, 여러 사용자의 요청을 하나의 ArrayList 에 추가하거나, 공유된 큐에서 작업을 꺼내는 구조가 있을 수 있습니다. 그런데 여기서 중요한 질문이 하나 생깁니다.

바로 "자바의 컬렉션들은 기본적으로 스레드 세이프할까?" 라는 점입니다. 특히 java.util 패키지에 포함된 대표적인 컬렉션들인 ArrayList, HashMap, LinkedList 같은 클래스들은 여러 스레드가 동시에 접근해도 문제가 없을까요?

참고로 여러 스레드가 동시에 접근해도 괜찮은 경우를 스레드 세이프(Thread Safe)하다고 합니다.

 

하지만 컬렉션 프레임워크가 제공하는 대부분의 연산은 원자적이지 않습니다. 즉, 연산 도중 다른 스레드가 개입할 여지가 있어, 멀티스레드 환경에서는 문제가 발생할 수 있습니다. 이 개념을 더 명확히 이해하기 위해, 우리가 직접 아주 단순한 형태의 컬렉션 클래스를 하나 만들어 보겠습니다.

public interface SimpleList {
    int size(); // 크기 조회
    void add(Object e); // 데이터 추가
    Object get(int index); // 데이터 조회
}
public class BasicList implements SimpleList{

    private static final int DEFAULT_CAPACITY = 5;
    private Object[] elementData;
    public int size = 0;

    public BasicList() {
        elementData = new Object[DEFAULT_CAPACITY];
    }

    @Override
    public int size() {
        return size;
    }

    @Override
    public void add(Object e) {
        elementData[size] = e;
        sleep(100); // 멀티 스레드 문제를 쉽게 확인하는 용도
        size++;
    }

    @Override
    public Object get(int index) {
        return elementData[index];
    }

    @Override
    public String toString() {
        return Arrays.toString(Arrays.copyOf(elementData, size)) +
                " size = " + size + ", capacity = " + elementData.length;
    }
}

1. 동시성 컬렉션이 필요한 이유

add( ) 메서드

@Override
public void add(Object e) {
    elementData[size] = e;
    sleep(100); // 멀티 스레드 문제를 쉽게 확인하는 용도
    size++;
}

 

겉으로 보기엔 이 메서드는 단순히 데이터를 하나 추가하는, 아주 간단한 작업처럼 보일 수 있습니다.

그래서 마치 원자적인 연산처럼 착각하기 쉽습니다. 하지만 실제로는 내부 배열에 데이터를 저장하고, size 값을 증가시키는 두 가지 작업이 필요합니다. 여기서 문제가 됩니다. 특히 size++ 연산은 size = size + 1과 같은 복합 연산으로, 원자적이지 않기 때문입니다.

이 말은, 여러 스레드가 동시에 접근하면 중간에 size 값이 꼬여서 의도하지 않은 결과가 발생할 수 있다는 뜻입니다.

이제 실제로 멀티스레드를 사용해 문제가 어떻게 발생하는지 확인해보겠습니다.

public class SimpleListMainV2 {

    public static void main(String[] args) throws InterruptedException {
        test(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-1");
        Thread thread2 = new Thread(addB, "Thread-2");
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        log(list);
    }
}

SimpleListMainV2 실행 결과

 

컬렉션 프레임워크는 겉으로 보기엔 단순해 보이지만, 내부에서는 다양한 연산이 함께 이루어집니다.

ArrayList나 HashMap의 add(), put() 같은 메서드도 내부적으로는 배열 복사, 사이즈 변경, 해시 계산 등 여러 단계를 거칩니다.

이런 작업들은 원자적인 연산이 아니며, 따라서 동시에 여러 스레드가 접근하면 충돌이 발생할 수 있습니다.

즉, 우리가 자주 사용하는 ArrayList, HashMap, HashSet 같은 컬렉션들은 기본적으로 스레드 세이프하지 않습니다.

 

단일 스레드 환경에서는 문제가 없지만, 멀티스레드 환경에서는 의도치 않은 데이터 유실, 사이즈 꼬임, 예외 발생 같은 문제가 발생할 수 있습니다. 예를 들어, 두 개의 요청이 거의 동시에 특정 컬렉션에 값을 추가했는데, 한 쪽 데이터가 사라지는 현상이 발생할 수 있습니다.

이는 동기화 없이 공유 자원에 접근했기 때문입니다. 그렇다면 이런 상황에서는 어떻게 해야 할까요?

 

여러 스레드가 컬렉션에 동시에 접근해야 한다면, synchronized Lock과 같은 동기화 기법을 통해 안전한 임계 영역을 구성하여야 동시성 문제를 예방할 수 있지 않을까요? 이렇게 하면 하나의 스레드만 컬렉션을 조작할 수 있도록 제한하여 일관성과 안정성을 확보할 수 있을 것 같습니다.

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

@Override
public synchronized void add(Object e) {
    elementData[size] = e;
    sleep(100); // 멀티 스레드 문제를 쉽게 확인하는 용도
    size++;
}

@Override
public synchronized Object get(int index) {
    return elementData[index];
}

@Override
public synchronized String toString() {
    return Arrays.toString(Arrays.copyOf(elementData, size)) +
            " size = " + size + ", capacity = " + elementData.length;
}

synchronized 키워드 사용 후 실행 결과

 

실행 결과를 보면 데이터가 [A, B], size=2로 정상 수행된 것을 확인할 수 있습니다. 이는 add() 메서드에 synchronized 키워드를 사용해 안전한 임계 영역을 만들었기 때문입니다. 즉, 한 번에 하나의 스레드만 add() 메서드에 진입할 수 있어, 동시 접근으로 인한 충돌 없이 순차적으로 데이터를 추가할 수 있게 됩니다.


2. 프록시 도입

멀티스레드 환경에서 ArrayList, HashMap 같은 컬렉션을 안전하게 사용하려면 synchronized 를 적용해야 합니다.
그렇다면 매번 이 컬렉션들을 복사해서 SyncArrayList, SyncHashMap처럼 동기화 버전을 따로 만들어야 할까요?

예를 들어, 이렇게 만드는 식이죠

  • ArrayList → SyncArrayList
  • HashMap → SyncHashMap

하지만 이 방식은 구현이 변경될 때, 같은 로직을 여러 곳에서 반복해서 수정해야 한다는 단점이 있습니다.
즉, 중복과 유지보수 문제가 생깁니다. 기존 코드를 그대로 사용하면서 synchronized 기능만 살짝 추가하고 싶다면 어떻게 하면 될까요?

이럴 때 사용하는 것이 바로 프록시(proxy) 입니다.

 

🌟 프록시(proxy)

프록시는 우리말로 "대리자", 즉 무언가를 대신 처리해주는 존재를 의미합니다.
예를 들어, 당신이 피자를 먹고 싶은데 직접 전화 주문하는 게 귀찮거나 번거로워서 친구에게 대신 주문해달라고 부탁한다고 해봅시다.
친구는 당신 대신 피자 가게에 전화를 걸어 주문하고, 나중에 도착한 피자를 당신에게 전달해 줍니다. 이때 친구가 바로 "프록시", 즉 당신을 대신해 요청을 처리해주는 대리인입니다. 프로그래밍에서의 프록시도 이와 비슷합니다. 프록시는 실제 객체 앞에 서서 요청을 가로채거나, 기능을 추가하거나, 요청을 제어하는 역할을 합니다.

여기서는 프록시가 대신 동기화(synchronized) 기능을 처리해주게 만들어봅시다.

public class SyncProxyList implements SimpleList{

    private SimpleList target;

    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);
    }

    @Override
    public synchronized String toString() {
        return target.toString() + " + by " + this.getClass().getSimpleName();
    }
  • SyncProxyList 클래스는 BasicList 와 같은 SimpleList 인터페이스를 구현합니다.
  • 이 클래스는 생성자를 통해 실제 작업을 수행할 대상인 SimpleList target 을 주입받습니다.
  • SyncProxyList 의 핵심 역할은 모든 메서드에 synchronized 키워드를 적용하여 멀티스레드 상황에서도 안전하게 동작하도록 만들어 주는 것입니다.

SyncProxyList 적용 후 실행 결과
SyncProxyList - add() 호출 과정


3. 자바 동시성 컬렉션 (feat - util.Collections)

사실 갑자기 프록시 패턴을 언급한 데에는 이유가 있습니다.

자바에서 제공하는 java.util 패키지의 대부분의 컬렉션 클래스(ArrayList, LinkedList, HashSet, HashMap 등)은 기본적으로 스레드 안전하지 않습니다. 이들 클래스는 내부적으로 배열 확장, 노드 연결, 사이즈 증가 등 복합적인 연산을 포함하고 있기 때문에 단순히 add()put()과 같은 연산이 원자적이라고 보기 어렵습니다.

 

그렇다면 아예 처음부터 모든 컬렉션에 synchronized 를 적용해 동기화를 해두면 어떨까요? 하지만 synchronized, Lock, CAS 등 어떤 동기화 방식이든 성능이라는 대가를 치러야 합니다. 실제로 대부분의 컬렉션은 항상 멀티스레드 환경에서 사용되는 것이 아니기 때문에, 불필요한 동기화는 오히려 성능 저하로 이어질 수 있습니다.

실제로 자바는 이런 선택으로 인해 뼈아픈 경험을 한 적이 있습니다. 바로 Vector 클래스입니다. Vector는 현재의 ArrayList와 유사한 기능을 제공하지만, 모든 메서드에 synchronized가 적용되어 있어 단일 스레드 환경에서도 불필요하게 느린 성능을 보였습니다. 이 때문에 Vector는 점점 사용되지 않게 되었고, 지금은 하위 호환성 유지를 위해 남아 있을 뿐입니다.

 

이런 문제를 해결하는 좋은 대안이 바로 프록시 방식의 동기화 적용입니다. 기존 컬렉션 구현체를 수정하지 않고도, 필요할 때만 synchronized 기능을 부여하는 프록시 객체를 사용하는 방식입니다. 실제로 자바에서는 이를 지원하기 위해Collections.synchronizedList(), Collections.synchronizedMap() 같은 유틸리티 메서드를 제공합니다.

 

이 방법을 사용하면 다음과 같은 장점이 있습니다.

  • 단일 스레드 환경에서는 기존 컬렉션을 그대로 사용하여 빠른 성능을 유지하고,
  • 멀티스레드 환경에서는 프록시를 통해 필요한 시점에만 동기화를 적용할 수 있습니다.

즉, 필요한 시점에만 안전하게 동기화를 적용할 수 있는 유연하고 실용적인 방식이라는 것입니다.

public class SynchronizedListMain {
    public static void main(String[] args) throws InterruptedException {
        List<String> list = Collections.synchronizedList(new ArrayList<>());

        Runnable task1 = new Runnable() {
            @Override
            public void run() {
                log("동기화 컬렉션에 데이터 추가 중");
                list.add("A");
                log("동기화 컬렉션에 데이터 추가 완료");
            }
        };

        Runnable task2 = new Runnable() {
            @Override
            public void run() {
                log("동기화 컬렉션에 데이터 추가 중");
                list.add("B");
                log("동기화 컬렉션에 데이터 추가 완료");
            }
        };

        Thread threadA = new Thread(task1, "ThreadA");
        Thread threadB = new Thread(task2, "ThreadB");
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
        System.out.println(list.getClass());
        log(list);
    }
}

SynchronizedListMain 실행 결과

  • SynchronizedRandomAccessList는 기존 컬렉션인 ArrayList에 synchronized 기능을 추가해주는 프록시 역할을 합니다.
  • 즉, 실제 데이터를 보관하고 있는 ArrayList는 그대로 유지하되, 이 리스트에 대한 접근을 대신 처리해주는 중간 계층이 SynchronizedRandomAccessList입니다.

이처럼 Collections 클래스가 제공하는 동기화 프록시 기능 덕분에, 기본적으로 스레드에 안전하지 않은 다양한 컬렉션들도 아주 간단하게 스레드 안전하게 사용할 수 있습니다.

 

하지만 synchronized 프록시를 사용하는 방식에는 몇 가지 한계점이 있습니다.

  1. 동기화 오버헤드가 발생: synchronized 는 멀티스레드 환경에서 안전한 접근을 보장하지만, 모든 메서드 호출마다 락을 획득하고 반납하는 비용이 발생하기 때문에 성능 저하가 일어날 수 있습니다.
  2. 잠금 범위가 넓다: 컬렉션 전체에 대해 락이 걸리기 때문에, 여러 스레드가 동시에 작업을 시도하면 락 경쟁(lock contention)이 생기고, 병렬 처리의 효율이 크게 떨어질 수 있습니다.
  3. 세밀한 동기화가 어렵다: synchronized 프록시는 모든 메서드에 일괄적으로 락을 걸기 때문에, 실제로는 동기화가 필요 없는 부분까지도 불필요하게 락이 걸립니다. 결과적으로 과도한 락 사용으로 인해 오히려 성능에 악영향을 줄 수 있습니다.

쉽게 말해, 이 방식은 모든 메서드에 무조건 락을 거는 단순한 방식입니다. 동기화를 정교하게 제어할 수 없기 때문에 최적화된 동시성 처리에는 적합하지 않습니다. 이런 단점을 보완하기 위해 자바는 java.util.concurrent 패키지를 통해 보다 정교하고 효율적인 동시성 컬렉션(Concurrent Collection)을 제공합니다.


4. 자바 동시성 컬렉션 (feat - util.concurrent)

Java 1.5부터 자바는 동시성(concurrency)을 효과적으로 지원하기 위한 다양한 기능들을 도입했습니다.

그중 하나가 바로 동시성 컬렉션(Concurrent Collection)입니다. java.util.concurrent 패키지에는 멀티스레드 환경에서도 전하고 효율적으로 사용할 수 있는 컬렉션 클래스들이 포함되어 있습니다. 대표적으로 ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue 등이 있으며, 이 컬렉션들은 단순히 synchronized를 사용하는 방식보다 훨씬 더 정교하고 성능 최적화된 동기화 방식을 사용합니다.

 

예를 들어, 일부 연산에만 선택적으로 락을 적용하거나, CAS(Compare-And-Swap), ReentrantLock, 분할 락(Segment Lock) 같은 기술을 조합하여 최소한의 락만 사용하면서도 데이터 일관성을 보장합니다. 이러한 구현은 내부적으로 매우 복잡하지만, 개발자는 이를 몰라도 상황에 맞는 동시성 컬렉션을 올바르게 선택해 사용하는 것만으로도 멀티스레드 환경에서 안전하고 성능 좋은 코드를 만들 수 있습니다.

 

🌟 동시성 컬렉션의 종류

컬렉션 종류 동시성 컬렉션 기존 컬렉션 특징 및 비고
List CopyOnWriteArrayList ArrayList 읽기 위주 환경에 적합. 쓰기 성능은 낮음
Set CopyOnWriteArraySet HashSet 내부적으로 CopyOnWriteArrayList 사용
Set (정렬) ConcurrentSkipListSet TreeSet 정렬 순서 유지. Comparator 사용 가능
Map ConcurrentHashMap HashMap 세분화된 락(Segment Lock) 사용으로 성능 우수
Map (정렬) ConcurrentSkipListMap TreeMap 정렬 순서 유지. 범위 검색에 유리
Queue ConcurrentLinkedQueue LinkedList (Queue 용도) 비차단(non-blocking) 큐. FIFO 보장
Deque ConcurrentLinkedDeque LinkedList (Deque 용도) 비차단 이중 큐. 양방향 삽입/삭제 가능

 

⚠️ 주의
입력 순서를 유지하는 LinkedHashSet, LinkedHashMap 에 대한 동시성 컬렉션은 별도로 제공되지 않으며, 필요시Collections.synchronizedXxx()를 사용해야 합니다.

 

🌟 블로킹 큐 종류

큐 이름 큐 특징 비고
ArrayBlockingQueue 고정 크기 배열 기반의 블로킹 큐 공정(FIFO) 모드 지원. 성능 저하 가능
LinkedBlockingQueue 링크드 리스트 기반, 크기 제한 가능 기본은 무제한 크기
PriorityBlockingQueue 우선순위에 따라 요소를 처리 Comparable 또는 Comparator 필요
SynchronousQueue 데이터를 저장하지 않음 생산자-소비자 직접 핸드오프
DelayQueue 지연된 작업을 처리하는 큐 스케줄링 작업에 적합

 

멀티스레드 환경에서는 동시성 문제가 발생하기 쉽고, 이는 디버깅이 매우 어려운 버그로 이어질 수 있습니다.

자바의 동시성 컬렉션은 이런 문제를 방지하면서도 성능을 최적화할 수 있도록 설계되어 있습니다.

Collections.synchronizedXxx() 같은 단순 동기화 방식보다 더 정교한 잠금 전략과 성능 최적화 기법이 적용되어 있어, 멀티스레드 상황에 훨씬 적합합니다. 하지만 동시성 컬렉션도 결국 락을 사용하므로 단일 스레드 환경에서는 오히려 성능이 떨어질 수 있습니다.

 

👉 따라서

  • 단일 스레드라면 일반 컬렉션을,
  • 멀티 스레드라면 동시성 컬렉션을 사용하는 것이 안전하고 효율적입니다.

 

'Java' 카테고리의 다른 글

[JAVA] ExecutorService - graceful shutdown  (0) 2025.07.03
[JAVA] Executor 프레임워크  (3) 2025.07.01
[JAVA] 원자적 연산  (0) 2025.06.26
[JAVA] ReentrantLock  (1) 2025.06.24
[JAVA] synchronized 동기화  (0) 2025.06.21