본문 바로가기
인프런/김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성

[인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 10. 동시성 컬렉션

by hxxyeoniii 2025. 3. 31.

동시성 컬렉션이 필요한 이유 : 시작

java.util 패키지의 컬렉션 프레임워크는 원자적 연산을 제공할까?

예를 들어 하나의 ArrayList 인스턴스에 여러 스레드가 동시에 접근해도 괜찮을까? (= 스레드 세이프 할까?)

 

이를 이해하기 위해 간단한 컬렉션을 직접 만들어보자.

 

SimpleList : 인터페이스

package thread.collection.simple.list;

public interface SimpliList {

    int size();

    void add(Object e);

    Object get(int index);
}

 

BasicList.java

package thread.collection.simple.list;

import java.util.Arrays;

import static util.ThreadUtils.sleep;

public class BasicList implements SimpliList {

    private static final int DEFAULT_CAPACITY = 5;

    private Object[] elementData;
    private 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++;이 너무 빨리 호출되지 않도록
        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;
    }
}

-> 여기서 add 메서드는 단순히 데이터를 하나 추가하는 기능을 제공하기에 원자적인 것처럼 보이나 원자적이지 않다.

-> 내부의 배열에 데이터를 추가해야 하고, size도 함께 증가시켜야 하고 size++ 연산 자체도 원자적이지 않다.

 

 

 

멀티스레드를 사용해 실제 어떤 문제가 발생하는지 확인해보자.

SimpleListMainV2.java

package thread.collection.simple.list;

import static util.MyLogger.log;

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        test(new BasicList());
    }

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

        Runnable addA = new Runnable() {
            @Override
            public void run() {
                list.add("A");
                log("Thread-1 : list.add(A)");
            }
        };

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

 

실행 결과

-> size는 2인데, 데이터는 B 하나만 입력되어 있음

-> 스레드 1, 2가 add 메서드의 elementData[size] = 0을 동시에 수행해 elementData[0] = A -> elementData[0] = B가 되어 최종적으로 B가 된다.

 

 

=> 컬렉션 프레임워크 대부분은 스레드 세이프 하지 않다.

=> 겉으로는 원자적 연산처럼 느껴지나 내부에서 수 많은 연산들이 함께 사용되고 있음


동시성 컬렉션이 필요한 이유 : 프록시 도입

SyncProxyList.java : 모든 메서드에 synchronized를 걸어주는 역할, target과 같은 기능 호출

package thread.collection.simple.list;

public class SyncProxyList implements SimpliList {

    private SimpliList target;

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

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

    @Override
    public synchronized void add(Object e) {
        target.add(e);
    }

    @Override
    public Object get(int index) {
        return target.get(index);
    }

    @Override
    public String toString() {
        return target.toString() + " + by " + this.getClass().getSimpleName();
    }
}

 

실행 결과

main 코드 변경

 

 

 

프록시 구조 분석

1. 정적 의존 관계

 

> test()가 클라이언트라고 가정하면 test()는 SimpleList라는 인터페이스에만 의존

> SimpleList 인터페이스 구현체인 BasicList, SyncList, SyncProxyList 중 어떤 것을 사용하던 클라이언트 코드는 변경하지 않아도 됨

 

2. 런타임 의존 관계

 

 

 

프록시 정리

1. 프록시인 SyncProxyList는 원본인 BasicList와 똑같은 SimpleList 구현

2. 클라이언트 입장에서는 프록시는 원본과 똑같이 생겼고, 호출할 메서드도 똑같음

3. 프록시는 내부에 원본을 가지고 있음 -> 필요한 일부의 일을 처리하고, 그 다음 원본을 호출하는 구조

4. 프록시가 동기화를 적용하고 원본을 호출하기에 원본 코드도 동기화가 적용된 상태로 호출

 

 

 

프록시 패턴(Proxy Pattern)

객체지향 디자인 패턴 중 하나로, 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인 또는 인터페이스 역할을 하는 객체를 제공하는 패턴

   > 접근 제어

   > 성능 향상

   > 부가 기능 제공


자바 동시성 컬렉션 1 - synchronized

자바는 컬렉션을 위한 프록시 기능을 제공한다.

 

SynchronizedListMain.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) {

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

 

실행 결과

 

 

 

Collections.synchronizedList(target)

: synchronized를 추가하는 프록시 역할

Collections.synchronizedList(new ArrayList<>()

 

 

안에 구현된 add() 메서드를 보면 synchronized 코드 블록을 적용하고 원본 대상의 add()를 호출하는 것을 볼 수 있다.

 

Collections는 다양한 synchronized 동기화 메서드를 지원한다.

synchronizedList()

> synchronizedCollection()`

> synchronizedMap()

> synchronizedSet()`

> synchronizedNavigableMap()

> synchronizedNavigableSet()

> synchronizedSortedMap()

> synchronizedSortedSet()

 

 

 

synchronized 프록시 방식의 단점

1. 동기화 오버헤드 발생 

   : 메서드 호출 시마다 동기화 비용이 추가됨 

2. 자바 컬렉션에 대해 동기화가 이뤄지기 때문에 잠금 범위가 넓어짐

   : 모든 메서드에 동기화를 적용하다 보면, 특정 스레드가 컬렉션을 사용하고 있을 때 다른 스레드들이 대기해야 하는 상황이 빈번해짐

3. 정교한 동기화가 불가능

   : 특정 부분이나 메서드에 대한 선택적 동기화가 어려움

 

=> 이 방식은 단순 무식하게 모든 메서드에 synchronized를 걸어버리는 것

=> 동기화 최적화가 이뤄지지 않음

=> 자바는 이를 보완하기 위해 java.util.concurrent 패키지에 대해 동시성 컬렉션을 제공한다.


자바 동시성 컬렉션 2 - 동시성 컬렉션

java.util.concurrent 패키지에는 고성능 멀티스레드 환경을 지원하는 다양한 동시성 컬렉션 클래스들을 제공한다.

더 정교한 잠금 메커니즘을 사용해 동시 접근을 효율적으로 처리하며, 필요한 경우 일부 메서드에 대해서만 동기화를 적용하는 등 유연한 동기화 전략을 제공한다.

 

 

동시성 컬렉션 종류

1. List

   > CopyOnWriteArrayList -> ArrayList 대안

 

2. Set

   > CopyOnWriteArraySet -> HashSet 대안

   > ConcurrentSkipLisstSet -> TreeSet 대안

 

3. Map

   > ConcurrentHashMap : HashMap 대안

   > ConcurrentSkipListMap : TreeMap 대안

 

4. Queue

   > ConcurrentLinkedQueue : 동시성 큐, 비 차단 큐

 

5. Deque

   > ConcurrentLinkedDeque : 동시성 데크, 비 차단 큐

 

 

 

스레드를 차단하는 블로킹 큐(BlockingQueue)

1. ArrayBlockingQueue

   > 크기가 고정된 블로킹 큐

   > 공정 모드를 사용할 수 있음, 공정 모드 사용 시 성능이 저하될 수 있음

 

2. LinkedBlockingQueue

   > 크기가 무한하거나 고정된 블로킹 큐

 

3. PriorityBlockingQueue

   > 우선순위가 높은 요소를 먼저 처리하는 블로킹 큐

 

4. SynchronousQueue

   > 데이터를 저장하지 않는 블로킹 큐

   > 생산자가 데이터를 추가하면 소비자가 그 데이터를 받을 때까지 대기

   > 생산자 - 소비자 간 직접적인 핸드오프 메커니즘 제공

 

5. DelayQueue

   > 지연된 요소를 처리하는 블로킹 큐

   > 각 요소는 지정된 지연 시간이 지난 후에야 소비될 수 있음

   > 일정 시간이 지난 후 작업을 처리해야 하는 스케줄링 작업에 사용

 

 

 

=> 자바에서 제공하는 동시성 컬렉션은 멀티스레드 상황에 최적의 성능을 낼 수 있도록 다양한 최적화 기법이 적용되어 있다.

=> 단 동시성은 결국 성능과 트레이드 오프가 있기에, 단일 스레드가 컬렉션을 사용하는 경우에는 일반 컬렉션을 사용해야 한다.