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()는 외형상 단순해 보여서 원자적인 연산처럼 보이지만, 실제로는 그렇지 않습니다.직접 구현한 컬렉션 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 형태라 동시성 충돌 가능합니다.해결 방법: 동기화된 컬렉션 SyncList
public synchronized void add(Object e) {
elementData[size] = e;
sleep(100);
size++;
}
[A, B] size=2 → 멀티스레드 환경에서도 안전하게 작동
문제: 기존 컬렉션(BasicList) 코드를 복사해서 synchronized만 추가하는 것은 중복과 비효율 유발합니다.
이렇게 되면 모든 컬렉션을 다 복사해서 동기화 용으로 새로 구현해야 합니다. 이것은 매우 비효율적입니다.
Proxy(대리자) 도입
SyncProxyList 클래스는 SimpleList 인터페이스를 구현하면서,target 리스트를 주입 받아 모든 메서드에 synchronized를 걸고 호출만 위임합니다.public synchronized void add(Object e) {
target.add(e);
}
장점
프록시란?
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는 이런 프록시 기반 구조를 극한까지 확장한 것입니다.
예:
→ 핵심 로직을 건드리지 않고 부가 기능을 삽입하는 데 사용됩니다.
synchronized기본 컬렉션의 한계
ArrayList, HashMap, HashSet 등은 기본적으로 스레드 세이프하지 않습니다.synchronized를 적용하면 되지 않을까요? → 그렇지만 모든 메서드에 동기화 적용은 성능 저하를 유발할 수 있습니다.대표적인 예: Vector
Vector는 ArrayList처럼 생겼지만, 모든 메서드에 synchronized 적용됩니다.Vector는 현재는 거의 사용하지 않습니다. (하위 호환용)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 등을 사용java.util.concurrent 컬렉션등장 배경
java.util.concurrent 패키지는 고성능 멀티스레드 환경을 위한 컬렉션을 제공했습니다.synchronized 프록시 방식의 성능 저하 문제를 해결하기 위해 등장했습니다.특징
동시성 컬렉션 종류별 정리
| 인터페이스 | 구현체 | 설명 |
|---|---|---|
| List | CopyOnWriteArrayList | 읽기 위주 환경에 적합. 변경 시 전체 복사 |
| Set | CopyOnWriteArraySet ConcurrentSkipListSet | 전자는 CopyOnWriteArrayList 기반 후자는 정렬 유지 (TreeSet 대안) |
| Map | ConcurrentHashMap ConcurrentSkipListMap | 각각 HashMap, TreeMap 대안 |
| Queue | ConcurrentLinkedQueue | 비차단(Non-blocking) 큐 |
| Deque | ConcurrentLinkedDeque | 양방향 비차단 큐 |
ㅐCopyOnWrite 계열
CopyOnWriteArrayList 예시
List<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
데이터를 변경할 때마다 전체 배열을 복사하여 변경, 읽기 성능은 매우 우수, 쓰기가 빈번하면 부적절합니다.
ConcurrentSkipList 계열
특징
Comparator 지정 가능Set<Integer> set = new ConcurrentSkipListSet<>();
Map<Integer, String> map = new ConcurrentSkipListMap<>();
ConcurrentHashMap
Map<Integer, String> map = new ConcurrentHashMap<>();
map.put(1, "data1");
HashMap의 대안BlockingQueue 계열
특징
주요 구현체
| 클래스 | 설명 |
|---|---|
ArrayBlockingQueue | 크기 고정, 배열 기반 |
LinkedBlockingQueue | 크기 유동적, 연결 리스트 기반 |
PriorityBlockingQueue | 우선순위 기반 |
SynchronousQueue | 큐 없이 직접 교환 (핸드오프) |
DelayQueue | 지연된 항목 처리용 |
[마지막] 정리 및 권장 사항
일반적인 가이드
Collections.synchronizedXxx()보다 concurrent 컬렉션이 성능 우수실무 적용 팁
CopyOnWriteArrayListConcurrentHashMapBlockingQueue