동시성 컬렉션
동시성 컬렉션
동시성 컬렉션: 멀티스레드 환경에서 여러 스레드가 동시에 접근하더라도 데이터의 일관성과 안정성이 보장되는 컬렉션.
내부적으로 동기화 메커니즘을 포함하여 개발자가 직접 synchronized나 Lock을 걸 필요가 없다.
Thread safe
- 여러 스레드가 동시에 접근하더라도 데이터의 일관성이 깨지지 않고 항상 의도한 결과가 보장되는 상태
핵심 요소
| 요소 |
설명 |
| 원자성 (Atomicity) |
연산이 중간에 끊기지 않음 |
| 가시성 (Visibility) |
한 스레드의 변경이 다른 스레드에 보임 |
| 순서 보장 (Ordering) |
실행 순서가 예측 가능 |
Proxy
- 우리말로 대리자, 대신 처리해주는 자라는 뜻이다.
- 실제 객체(Real Subject)를 직접 호출하지 않고, 중간에서 대신 처리하거나 부가 기능을 제공
목적
정적 의존 관계 VS 런타임 의존 관계
| 구분 |
정적 의존 관계 (Compile-Time) |
런타임 의존 관계 (Runtime) |
| 의존성 결정 시점 |
컴파일 시점 |
실행 시점 |
| 의존 대상 |
구체 클래스 |
인터페이스 / 추상 타입 |
| 코드 예시 |
new ArrayList() |
new ArrayList()를 감싼 Proxy |
| 유연성 |
낮음 |
높음 |
| 확장성 |
변경에 취약 |
변경에 강함 |
| 테스트 용이성 |
낮음 (Mock 어려움) |
높음 (Mock 주입 가능) |
| OCP(Open–Closed) |
위반 가능성 높음 |
준수하기 쉬움 |
| 대표 사례 |
직접 객체 생성 |
Proxy, DI, AOP |
Proxy Pattern
- 실제 객체를 감싸는 대리 객체를 두고 클라이언트는 실제 객체가 아닌 Proxy를 통해 접근
Client → Proxy → Real Object
예시 코드
public class Main {
public static void main(String[] args) throws InterruptedException {
Bank bank = new ProxyBank(new DefaultBank());
Runnable job = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
bank.deposit(1000);
bank.withdraw(1500);
}
};
Thread thread1 = new Thread(job, "Thread1");
Thread thread2 = new Thread(job, "Thread2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
bank.printMoney();
}
private interface Bank {
public void deposit(int money);
public void withdraw(int money);
public void printMoney();
}
private static class DefaultBank implements Bank {
private int money;
@Override
public void deposit(int money) {
System.out.println("[입금] " + money + "원");
this.money += money;
System.out.println("[현재잔액] " + this.money + "원");
}
@Override
public void withdraw(int money) {
if (this.money - money < 0) {
System.out.println("[출금실패] 잔액: " + this.money + "원, 출금액: " + money + "원 [" + (money - this.money) + "원 부족]");
return;
}
System.out.println("[출금] " + money + "원");
this.money -= money;
System.out.println("[현재잔액] " + this.money);
}
@Override
public void printMoney() {
System.out.println("[잔액] " + money + "원");
}
}
private static class ProxyBank implements Bank {
private final Bank bank;
ProxyBank(Bank bank) {
this.bank = bank;
}
@Override
public synchronized void deposit(int money) {
bank.deposit(money);
}
@Override
public synchronized void withdraw(int money) {
bank.withdraw(money);
}
@Override
public synchronized void printMoney() {
bank.printMoney();
}
}
}
주요 목적
- 기능 확장
- 기존 코드 수정 없이 부가 기능 추가 (OCP)
- 관심사 분리
- 비즈니스 로직과 부가 기능 분리
- 제어 기능 제공
- 접근 제어, 동기화, 캐싱, 지연 로딩
Synchronized Proxy 단점
- 성능 저하
- 모든 메서드가 하나의 락을 공유
- 경쟁 심화 시 병목 발생
- 확장성 부족
- 스레드 수 증가에 비례해 처리량 감소
- 불필요한 동기화
- 읽기 작업도 락을 획득해야 함
동시성 컬렉션 종류
| 분류 |
컬렉션 |
특징 |
사용 시점 |
| List |
CopyOnWriteArrayList |
읽기 시 락 없음, 쓰기 시 전체 복사 |
읽기 ≫ 쓰기 환경 |
| Set |
CopyOnWriteArraySet |
중복 제거, 내부적으로 CopyOnWriteArrayList 사용 |
데이터 수 적고 읽기 위주 |
| Set |
ConcurrentSkipListSet |
정렬 보장, O(log n) |
정렬 + 동시성 필요 시 |
| Map |
ConcurrentHashMap |
락 분할 + CAS, 고성능 |
일반적인 멀티스레드 Map |
| Map |
ConcurrentSkipListMap |
정렬 보장, 범위 검색 가능 |
정렬된 Map 필요 시 |
| Queue |
ConcurrentLinkedQueue |
Lock-Free, FIFO |
높은 처리량 필요 시 |
| Queue |
ConcurrentLinkedDeque |
Lock-Free, 양방향 Deque |
앞/뒤 삽입·삭제 빈번 |
스레드를 차단하는 블로킹 큐 Blocking Queue
- 큐가 비어있거나 가득 차면 스레드를 자동으로 차단(Block) 시키는 큐
- “생산자–소비자 패턴”을 쉽게 구현하기 위해 만들어진 스레드 안전 큐입니다.
특징
- 큐가 비어 있으면 소비자 스레드는 기다려야 함
- 큐가 가득 차면 생산자 스레드는 기다려야 함
- 여기서 ‘차단(block)’이란, 스레드를 CPU에서 제거하고, 조건이 충족될 때까지 실행을 멈추는 것을 의미합니다.
- 실제 대기 상태는 WAITING 또는 TIMED_WAITING
종류
| 컬렉션 |
내부 구조 |
크기 제한 |
정렬/우선순위 |
주요 특징 |
사용 시점 |
| ArrayBlockingQueue |
배열 기반 |
고정 |
없음 (FIFO) |
단일 락 사용, 예측 가능한 성능 |
큐 크기를 엄격히 제한해야 할 때 |
| LinkedBlockingQueue |
링크 기반 |
고정 또는 무제한 |
없음 (FIFO) |
put/take 분리 락 사용 |
처리량이 크고 안정성 필요 시 |
| LinkedBlockingDeque |
링크 기반 |
고정 또는 무제한 |
없음 |
양방향 삽입·삭제 가능 |
앞/뒤 작업이 모두 필요한 경우 |
| PriorityBlockingQueue |
힙 기반 |
무제한 |
있음 |
우선순위에 따라 처리 |
작업 중요도 기반 처리 |
| DelayQueue |
힙 기반 |
무제한 |
지연 시간 기반 |
지정 시간 이후에만 꺼냄 |
스케줄링, 캐시 만료 처리 |