본문으로 건너뛰기
김신건의 로그

[Java] CopyOnWriteArraySet

· 수정 · 📖 약 2분 · 739자/단어 #java #concurrent #set #copy-on-write #thread-safe
CopyOnWriteArraySet, java.util.concurrent.CopyOnWriteArraySet, CoW Set, copy-on-write set

정의

java.util.concurrent.CopyOnWriteArraySet<E>CopyOnWriteArrayList 를 백킹으로 사용하는 thread-safe Set. 중복 검사를 위해 매 add 마다 선형 검색 (O(n)) 을 수행한다.

CopyOnWriteArrayList 의 모든 특성을 물려받는다.

  • 읽기 lock-free: contains, iterator 는 락 없이 동작
  • 쓰기 시 전체 복사: add, remove 는 내부 배열 전체를 복사한 뒤 교체
  • snapshot iterator: iterator 생성 시점의 배열 스냅샷을 순회, 이후 변경 반영 안 됨

언제 쓰나

  • 이벤트 리스너 집합: 리스너 등록/해제는 드물고, 이벤트 발생 시 전체 순회가 잦을 때
  • 옵저버 패턴 구독자 목록: 구독자 변경보다 알림 발송이 훨씬 많을 때
  • 설정/메타데이터 집합: 애플리케이션 시작 시 한 번 구성, 이후 읽기만
  • 원소 수가 수십 이하: O(n) add 비용이 허용되는 소규모 집합

CAUTION

원소 수가 많거나 쓰기가 빈번하면 절대 사용하지 말 것. add 가 O(n) + 전체 배열 복사이므로 n 이 커질수록 GC 압박과 지연이 급증한다.

시각화: 쓰기 시 복사 흐름

flowchart TD
    Add["add(e) 호출"] --> Lock["ReentrantLock 획득"]
    Lock --> Contains{"배열에 e 존재?"}
    Contains -->|"예"| Unlock1["락 해제, false 반환"]
    Contains -->|"아니오"| Copy["배열 전체 복사 (n+1 크기)"]
    Copy --> Append["새 배열 끝에 e 추가"]
    Append --> Swap["array 참조 교체 (volatile write)"]
    Swap --> Unlock2["락 해제, true 반환"]

읽기 스레드는 array 참조를 volatile 로 읽으므로 락 없이 항상 일관된 스냅샷을 본다.

시각화: snapshot iterator 동작

sequenceDiagram
    participant T1 as "스레드 A (순회)"
    participant T2 as "스레드 B (쓰기)"
    participant Arr as "내부 배열"

    T1->>Arr: iterator() - 스냅샷 [a, b, c] 획득
    T2->>Arr: add(d) - 새 배열 [a, b, c, d] 로 교체
    T1->>T1: next() = a (스냅샷 기준)
    T1->>T1: next() = b
    T1->>T1: next() = c
    Note over T1: d 는 보이지 않음 (snapshot)

내부 구조

public class CopyOnWriteArraySet<E> extends AbstractSet<E>
        implements Serializable {

    private final CopyOnWriteArrayList<E> al;

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<>();
    }

    public boolean add(E e) {
        return al.addIfAbsent(e);   // 선형 검색 후 없으면 추가
    }

    public boolean contains(Object o) {
        return al.contains(o);      // lock-free 선형 검색
    }

    public boolean remove(Object o) {
        return al.remove(o);        // 락 + 복사
    }

    public Iterator<E> iterator() {
        return al.iterator();       // snapshot iterator
    }
}

CopyOnWriteArrayList.addIfAbsent(e) 핵심 로직:

// CopyOnWriteArrayList (단순화)
public boolean addIfAbsent(E e) {
    Object[] snapshot = getArray();
    // 스냅샷에 이미 있으면 락 없이 false 반환 (fast path)
    return indexOf(e, snapshot, 0, snapshot.length) < 0
        && addIfAbsent(e, snapshot);
}

private boolean addIfAbsent(E e, Object[] snapshot) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] current = getArray();
        int len = current.length;
        if (snapshot != current) {
            // 락 획득 사이에 다른 스레드가 변경 → 재검사
            int common = Math.min(snapshot.length, len);
            for (int i = 0; i < common; i++)
                if (current[i] != snapshot[i] && eq(e, current[i]))
                    return false;
            if (indexOf(e, current, common, len) >= 0)
                return false;
        }
        Object[] newElements = Arrays.copyOf(current, len + 1);
        newElements[len] = e;
        setArray(newElements);   // volatile write
        return true;
    } finally {
        lock.unlock();
    }
}

복잡도

작업시간비고
add(e)O(n)선형 검색 + 전체 배열 복사
contains(o)O(n)선형 검색 (락 없음)
remove(o)O(n)선형 검색 + 전체 배열 복사
size()O(1)배열 길이
순회 (iterator)O(n)snapshot 순회

스레드 안전성

  • 읽기 (contains, iterator, size): 완전 lock-free. volatile 배열 참조를 읽기만 하므로 여러 스레드가 동시에 읽어도 안전.
  • 쓰기 (add, remove): ReentrantLock 으로 직렬화. 동시에 두 스레드가 add 를 호출하면 하나씩 순서대로 처리.
  • iterator 는 fail-fast iterator 가 아님: 생성 시점 스냅샷을 순회하므로 순회 중 다른 스레드가 add/remove 해도 ConcurrentModificationException 이 발생하지 않는다.
CopyOnWriteArraySet<String> listeners = new CopyOnWriteArraySet<>();

// 순회 중 안전하게 수정 가능 (다른 스레드에서)
for (String listener : listeners) {
    notify(listener);
    // 다른 스레드가 listeners.add("new") 해도 이 순회에는 영향 없음
}

Java 17+ 실전: 이벤트 리스너 관리

import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Consumer;

sealed interface AppEvent permits UserLoginEvent, DataChangedEvent {}
record UserLoginEvent(String userId) implements AppEvent {}
record DataChangedEvent(String entity, long id) implements AppEvent {}

class EventBus {
    private final CopyOnWriteArraySet<Consumer<AppEvent>> listeners =
        new CopyOnWriteArraySet<>();

    // 리스너 등록/해제는 드물게 발생
    public void subscribe(Consumer<AppEvent> listener) {
        listeners.add(listener);
    }

    public void unsubscribe(Consumer<AppEvent> listener) {
        listeners.remove(listener);
    }

    // 이벤트 발행은 자주 발생, 순회 중 리스너 변경 안전
    public void publish(AppEvent event) {
        for (Consumer<AppEvent> listener : listeners) {
            try {
                listener.accept(event);
            } catch (Exception e) {
                // 한 리스너 실패가 다른 리스너에 영향 없도록
                System.err.println("Listener error: " + e.getMessage());
            }
        }
    }
}

Java 17+ 실전: 옵저버 패턴

import java.util.concurrent.CopyOnWriteArraySet;

interface StockObserver {
    void onPriceChange(String ticker, double price);
}

class StockTicker {
    private final String ticker;
    private final CopyOnWriteArraySet<StockObserver> observers =
        new CopyOnWriteArraySet<>();
    private volatile double price;

    StockTicker(String ticker) { this.ticker = ticker; }

    public void addObserver(StockObserver o) { observers.add(o); }
    public void removeObserver(StockObserver o) { observers.remove(o); }

    public void updatePrice(double newPrice) {
        this.price = newPrice;
        // 순회 중 observer 추가/제거 가능 (snapshot 순회)
        observers.forEach(o -> o.onPriceChange(ticker, newPrice));
    }
}

동시성 Set 비교

옵션addcontainsiterator정렬
CopyOnWriteArraySetO(n) + 복사O(n) lock-freesnapshot삽입 순서
ConcurrentHashMap.newKeySet()O(1) avgO(1) avgweakly consistent
Collections.synchronizedSet(HashSet)O(1) + 락O(1) + 락fail-fast (외부 동기화 필요)
ConcurrentSkipListSetO(log n)O(log n)weakly consistent정렬

함정

1. 대용량 집합에 사용

// 위험: 원소가 수천 개면 add 마다 수천 개 배열 복사
CopyOnWriteArraySet<String> bigSet = new CopyOnWriteArraySet<>();
for (int i = 0; i < 10_000; i++) {
    bigSet.add("item" + i);   // 매번 O(n) 검색 + O(n) 복사 → 총 O(n^2)
}

// 올바름: 대용량은 ConcurrentHashMap.newKeySet()
Set<String> bigConcurrent = ConcurrentHashMap.newKeySet();

2. iterator 결과가 최신이 아닐 수 있음

CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>();
set.add("a");
Iterator<String> it = set.iterator();   // 스냅샷: ["a"]
set.add("b");                           // 다른 스레드에서 추가
it.next();   // "a" (스냅샷 기준, "b" 는 보이지 않음)

snapshot iterator 는 약한 일관성 (weakly consistent) 을 제공한다. 최신 상태가 필요하면 contains() 를 직접 호출.

3. iterator.remove() 미지원

Iterator<String> it = set.iterator();
it.next();
it.remove();   // UnsupportedOperationException

순회 중 제거는 set.remove(element) 를 직접 호출해야 한다.

4. equals 기반 중복 검사

addequals 로 중복 판단. equals/hashCode 를 올바르게 구현하지 않은 객체는 중복이 허용될 수 있다.

// 위험: equals/hashCode 미구현 클래스
class Tag {
    String name;
    Tag(String name) { this.name = name; }
    // equals, hashCode 없음 → 참조 동등성으로 비교
}

CopyOnWriteArraySet<Tag> tags = new CopyOnWriteArraySet<>();
tags.add(new Tag("java"));
tags.add(new Tag("java"));   // 다른 객체 참조 → 중복 허용됨
tags.size();   // 2 (기대: 1)

관련 위키

이 글의 용어 (9개)
[Java] Collectionjava
정의 는 그룹으로 묶인 객체들을 표현하는 최상위 인터페이스. JCF (Java Collections Framework) 의 입구이자, / / / 모두 이를 확장한다. 자체는 직접…
[Java] ConcurrentHashMapjava
정의 는 고동시성 환경에서 사용 가능한 구현. JSR-166 (Java 5) 도입, Java 8 에서 내부 구조가 크게 재작성됐다. 과 같은 인터페이스를 제공하면서 thread-…
[Java] ConcurrentModificationExceptionjava
정의 (CME) 는 fail-fast iterator 가 컬렉션의 구조 변경을 탐지했을 때 던지는 unchecked 예외 ( ). 이름과 달리 반드시 동시성 (멀티스레드) 문제일…
[Java] ConcurrentSkipListSetjava
정의 는 을 백킹으로 사용하는 정렬된 thread-safe . 인터페이스를 구현하며 의 동시성 버전에 해당한다. - JDK 1.6 추가 - 내부는 skip list (확률적 자료…
[Java] CopyOnWriteArrayListjava
정의 는 쓰기 시 배열 전체를 복사 하는 thread-safe 구현. 읽기에는 lock 이 전혀 없고, 쓰기에는 으로 직렬화한다. (JSR-166) 의 컬렉션. 읽기 압도적 다,…
[Java] Fail-Fast Iteratorjava
정의 Fail-Fast Iterator 는 순회 도중 컬렉션의 구조가 변경되면 즉시 을 던지는 iterator. , , 등 대부분의 컬렉션의 iterator 가 이 방식. 목적은…
[Java] Iterablejava
정의 는 루프로 순회 가능한 모든 타입의 최상위 인터페이스. 단 하나의 추상 메서드, 를 정의한다. 인터페이스가 을 extends 하므로 , , , 등 모든 컬렉션이 자동으로 대…
[Java] Objectjava
정의 는 Java 의 모든 클래스의 최상위 부모 (root) 클래스. 가 명시되지 않은 클래스는 컴파일러가 자동으로 를 붙인다. 인터페이스는 클래스가 아니라 를 직접 상속하지는 …
집합과 맵 (Set and Map)algorithm
정의 집합 (Set) 은 고유한 원소들의 모임을 나타내는 추상 자료구조로, 멤버십 검사, 삽입, 삭제를 지원한다. 맵 (Map, Dictionary) 은 키-값 쌍을 저장하며, …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기