11. 동시성 컬렉션

임대일·2025년 5월 13일

Thread

목록 보기
11/13
post-thumbnail

[1단계] 동시성 컬렉션이 필요한 이유 ① - 시작

  • ArrayList는 스레드 세이프(Thread Safe)할까요?
  • 여러 스레드가 동시에 접근할 경우 문제가 없을까요?

코드

package thread.collection.simple.list;

public interface SimpleList {
    int size();

    void add(Object e);

    Object get(int index);
}
package thread.collection.simple.list;

import java.util.Arrays;

import static util.ThreadUtils.sleep;

public class BasicList implements SimpleList {

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

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

    @Override
    public String toString() {
        return Arrays.toString(Arrays.copyOf(elementData, size)) +
                " size=" + size + ", capaity=" + elementData.length;
    }
}
package thread.collection.simple.list;

public class SimpleListMainV1 {

    public static void main(String[] args) {
        SimpleList list = new BasicList();
        list.add("A");
        list.add("B");
        System.out.println("list = " + list);
    }
}
list = [A, B] size=2, capaity=5

동시 실행 가정 하에 [A, B] 또는 [B, A] 출력 예상합니다.

  • add()는 외형상 단순해 보여서 원자적인 연산처럼 보이지만, 실제로는 그렇지 않습니다.
  • Java의 대부분의 컬렉션 연산은 원자적이지 않습니다.

[2단계] 동시성 컬렉션이 필요한 이유 ② - 동시성 문제

직접 구현한 컬렉션 BasicList

public void add(Object e) {
    elementData[size] = e;
    sleep(100); // 문제 확인을 위한 지연
    size++; // 원자적이지 않은 연산
}

동시성 문제 사례

멀티스레드에서 위 메서드를 동시에 호출하면:

  • elementData[0]에 두 스레드가 동시에 값을 넣으면서 덮어쓰기가 발생합니다.
  • size++도 원자적이지 않아서 중복된 size값이 저장됩니다.

결과 예시

size = 2, 하지만 저장된 데이터는 [B, null] 또는 [A, null]
  • size++size = size + 1 형태라 동시성 충돌 가능합니다.

[3단계] 동시성 3컬렉션이 필요한 이유 ③ - 동기화(synchronized)

해결 방법: 동기화된 컬렉션 SyncList

public synchronized void add(Object e) {
    elementData[size] = e;
    sleep(100);
    size++;
}
[A, B] size=2 → 멀티스레드 환경에서도 안전하게 작동

문제: 기존 컬렉션(BasicList) 코드를 복사해서 synchronized만 추가하는 것은 중복비효율 유발합니다.
이렇게 되면 모든 컬렉션을 다 복사해서 동기화 용으로 새로 구현해야 합니다. 이것은 매우 비효율적입니다.

[4단계] 동시성 컬렉션이 필요한 이유 ④ - 프록시 도입

Proxy(대리자) 도입

  • SyncProxyList 클래스는 SimpleList 인터페이스를 구현하면서,
    내부에 target 리스트를 주입 받아 모든 메서드에 synchronized를 걸고 호출만 위임합니다.
public synchronized void add(Object e) {
    target.add(e);
}

장점

  • 원본 코드를 전혀 수정하지 않고도 동기화 적용이 가능합니다.
  • 인터페이스 기반 설계로 확장성 뛰어납니다.
  • 스프링 AOP처럼 프록시 패턴은 실무에서도 널리 사용됩니다.

프록시(Proxy)의 개념과 활용

프록시란?

  • "대리자", 즉 실제 객체의 기능을 대신 수행하는 객체입니다.
  • 클라이언트와 실제 대상 사이에 위치하여 기능을 추가하거나 제어 역할 수행합니다.
  • 자바에서는 특정 기능(예: 동기화)을 외부에서 주입하거나 제어할 때 유용합니다.

SyncProxyList 코드 분석

package thread.collection.simple.list;

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 String toString() {
        return target.toString() + " by " + this.getClass().getSimpleName();
    }
}
  • SyncProxyList프록시 객체입니다.
  • SimpleList 인터페이스를 구현합니다.
  • 내부에 원본 객체(BasicList)를 target으로 보유합니다.
  • 모든 메서드는 synchronized를 적용한 후, target의 동일 메서드 호출합니다.

구조적 변화

기존 구조

클라이언트 ───▶ BasicList

프록시 도입 구조

클라이언트 ───▶ SyncProxyList ───▶ BasicList
  • 클라이언트 입장에서 프록시인지 실제 객체인지 알 필요 없습니다.
  • 단지 SimpleList라는 추상 타입에만 의존하기 때문에 유연하고 확장이 가능합니다.

런타임 의존 관계 (정리)

BasicList 사용 시

  • SimpleList list = new BasicList();
  • test(list)직접 BasicList 인스턴스를 사용

SyncProxyList 사용 시

  • SimpleList list = new SyncProxyList(new BasicList());

  • test(list)프록시 내부에서 BasicList 호출

  • 실제 add(), get() 등 호출 흐름:

    클라이언트 → 프록시(add) → 원본(add) → 결과 반환

프록시 패턴의 장점

핵심 장점

항목설명
기존 코드 변경 없음원본(BasicList)을 전혀 수정하지 않고 동기화 적용 가능
재사용성프록시 하나로 모든 SimpleList 구현체에 적용 가능
유지보수 용이공통 기능(동기화, 로깅 등)을 한 곳에서 관리
확장성새로운 구현체 (BasicLinkedList)가 생겨도 동일 프록시 사용 가능
AOP의 기반 개념스프링의 Aspect-Oriented Programming도 이 구조 기반

실무 예시: 스프링 AOP는 이런 프록시 기반 구조를 극한까지 확장한 것입니다.

예:

  • 로깅 프록시
  • 트랜잭션 프록시
  • 보안 검사 프록시
  • 동기화 프록시

→ 핵심 로직을 건드리지 않고 부가 기능을 삽입하는 데 사용됩니다.

[5단계] 자바 동시성 컬렉션 ① - synchronized

기본 컬렉션의 한계

  • ArrayList, HashMap, HashSet 등은 기본적으로 스레드 세이프하지 않습니다.
  • 모든 컬렉션에 synchronized를 적용하면 되지 않을까요? → 그렇지만 모든 메서드에 동기화 적용은 성능 저하를 유발할 수 있습니다.

대표적인 예: Vector

  • VectorArrayList처럼 생겼지만, 모든 메서드에 synchronized 적용됩니다.
  • 결과적으로 단일 스레드 환경에서 불필요한 동기화로 인해 성능이 떨어집니다.
  • 그래서 Vector는 현재는 거의 사용하지 않습니다. (하위 호환용)

[6단계] 자바 Collections.synchronizedXxx() API

자바가 제공하는 프록시 기반 동기화 컬렉션

List<String> list = Collections.synchronizedList(new ArrayList<>());
  • 내부적으로 SynchronizedRandomAccessList라는 프록시 객체가 생성됩니다.
  • 모든 동기화를 프록시에서 처리하고, 실제 ArrayList는 건드리지 않습니다.
public boolean add(E e) {
    synchronized (mutex) {
        return c.add(e);
    }
}

주요 API

메서드설명
synchronizedList()List용 동기화
synchronizedSet()Set용 동기화
synchronizedMap()Map용 동기화
...Navigable..., ...Sorted... 도 있음정렬/순서 컬렉션 지원

synchronized 프록시 방식의 단점

문제점설명
❌ 동기화 오버헤드매 호출마다 lock 획득 → 성능 저하
❌ 전체 잠금 범위컬렉션 전체에 잠금 → 병렬 처리 저하
❌ 정교한 동기화 어려움메서드별로 세밀한 제어가 어려움

따라서 단순 무식하게 모든 메서드에 lock을 거는 것은 비효율적입니다.

다음 단계 예고: java.util.concurrent 동시성 컬렉션

  • ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue
  • synchronized보다 더 정교한 잠금, 부분 동기화, CAS 등을 사용
  • 고성능 멀티스레드 환경에 최적화된 컬렉션들

[7단계] 자바 동시성 컬렉션 ② - java.util.concurrent 컬렉션

등장 배경

  • 자바 1.5부터 도입된 java.util.concurrent 패키지는 고성능 멀티스레드 환경을 위한 컬렉션을 제공했습니다.
  • 기존 synchronized 프록시 방식의 성능 저하 문제를 해결하기 위해 등장했습니다.

특징

  • 부분 잠금(Lock Striping), CAS, ReentrantLock 등 정교한 동기화 기법을 사용합니다.
  • 더 높은 성능, 더 정밀한 동기화, 스레드 안전

동시성 컬렉션 종류별 정리

인터페이스구현체설명
ListCopyOnWriteArrayList읽기 위주 환경에 적합. 변경 시 전체 복사
SetCopyOnWriteArraySet ConcurrentSkipListSet전자는 CopyOnWriteArrayList 기반 후자는 정렬 유지 (TreeSet 대안)
MapConcurrentHashMap ConcurrentSkipListMap각각 HashMap, TreeMap 대안
QueueConcurrentLinkedQueue비차단(Non-blocking) 큐
DequeConcurrentLinkedDeque양방향 비차단 큐

ㅐCopyOnWrite 계열

CopyOnWriteArrayList 예시

List<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);

데이터를 변경할 때마다 전체 배열을 복사하여 변경, 읽기 성능은 매우 우수, 쓰기가 빈번하면 부적절합니다.


ConcurrentSkipList 계열

특징

  • 정렬된 순서를 유지
  • 내부적으로 Skip List 구조 사용
  • Comparator 지정 가능
Set<Integer> set = new ConcurrentSkipListSet<>();
Map<Integer, String> map = new ConcurrentSkipListMap<>();

ConcurrentHashMap

Map<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "data1");
  • 기존 HashMap의 대안
  • 내부적으로 Segment(분할 락) 기반으로 동기화하여 성능 향상
  • Java 8 이후에는 더욱 정교한 구조로 개선됨

BlockingQueue 계열

특징

  • 스레드 간 안전한 데이터 교환을 위한 큐
  • 생산자-소비자 패턴에 적합
  • 데이터가 없으면 소비자 스레드는 자동 대기

주요 구현체

클래스설명
ArrayBlockingQueue크기 고정, 배열 기반
LinkedBlockingQueue크기 유동적, 연결 리스트 기반
PriorityBlockingQueue우선순위 기반
SynchronousQueue큐 없이 직접 교환 (핸드오프)
DelayQueue지연된 항목 처리용

[마지막] 정리 및 권장 사항

일반적인 가이드

  • 단일 스레드: 일반 컬렉션 사용 → 성능 최우선
  • 멀티 스레드: 동시성 컬렉션 필수 → 안전성 최우선
  • Collections.synchronizedXxx()보다 concurrent 컬렉션이 성능 우수

실무 적용 팁

  • 읽기 많은 환경 → CopyOnWriteArrayList
  • 동시 키-값 저장 → ConcurrentHashMap
  • 멀티스레드 큐 → BlockingQueue
profile
🤔오늘의 복습은❓

0개의 댓글