동시성 컬렉션의 원자성
`synchronized` 래퍼의 복합 연산 틈을 재현하고 ConcurrentHashMap의 `compute`·putIfAbsent, CopyOnWriteArrayList의 스냅샷 반복을 규칙별로 선택합니다.
동시성 컬렉션은 개별 메서드를 여러 스레드가 안전하게 호출하도록 설계되지만 호출 두 개를 자동으로 한 트랜잭션으로 묶지는 않습니다.
containsKey 다음 put은 사이에 다른 스레드가 들어올 수 있습니다.
필요한 복합 의미를 제공하는 putIfAbsent, compute, merge 같은 원자적 메서드를 선택해야 합니다.
반복 규칙도 컬렉션마다 다릅니다.
CopyOnWriteArrayList 반복자는 생성 시점 배열 스냅샷을 보며 이후 추가를 반영하지 않습니다.
ConcurrentHashMap 반복은 진행 중 변경과 공존하지만 한 순간의 완전한 스냅샷을 약속하지 않습니다.
synchronizedMap의 복합 연산
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
public final class SynchronizedMapCheckThenAct {
public static void main(String[] args) throws InterruptedException {
Map<String, String> cache = Collections.synchronizedMap(new HashMap<>());
CountDownLatch checked = new CountDownLatch(2);
CountDownLatch proceed = new CountDownLatch(1);
Runnable task = () -> {
if (!cache.containsKey("config")) {
checked.countDown();
try { proceed.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
cache.put("config", Thread.currentThread().getName());
}
};
Thread a = Thread.ofPlatform().name("loader-a").start(task);
Thread b = Thread.ofPlatform().name("loader-b").start(task);
checked.await();
proceed.countDown();
a.join(); b.join();
System.out.println(cache);
}
}각 메서드는 안전하지만 두 로더 모두 부재를 보고 계산을 수행합니다.
값 하나만 남아도 중복 외부 호출이 이미 발생할 수 있습니다.
래퍼 전체를 직접 synchronized 블록으로 묶거나 원자 메서드를 사용합니다.
컬렉션 선택 전에 정할 규칙
- 개별
get과put안전성만 필요한지 복합 계산 원자성이 필요한지 구분한다. compute함수는 충돌과 구현 전략에 따라 재호출 가능성을 고려해 부수 효과를 피한다.- ConcurrentHashMap은
null키와 값을 허용하지 않아 부재 의미를 명확히 한다. - CopyOnWriteArrayList는 읽기 반복이 많고 쓰기가 매우 드문 경우에 적합하다.
- 동시 반복 결과는 컬렉션이 약속한 스냅샷 또는 약한 일관성으로 해석한다.
- 정확한 전체 스냅샷이 필요하면 별도 잠금이나 불변 자료 전체 교체를 사용한다.
merge 원자 갱신
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public final class ConcurrentWordCounter {
private final ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
void record(String word) { counts.merge(word, 1, Math::addExact); }
public static void main(String[] args) throws InterruptedException {
ConcurrentWordCounter counter = new ConcurrentWordCounter();
List<String> words = List.of("cas", "lock", "cas", "map");
Thread[] workers = new Thread[4];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().start(() -> {
for (int round = 0; round < 10_000; round++) {
for (String word : words) {
counter.record(word);
}
}
});
}
for (Thread worker : workers) {
worker.join();
}
System.out.println(counter.counts);
}
}cas는 80,000, 락과 map은 각각 40,000입니다.
get과 put을 분리하지 않아 같은 키의 증가 손실을 막습니다.
스냅샷 반복과 최신 쓰기 분리
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
public final class ListenerRegistry {
private final CopyOnWriteArrayList<Consumer<String>> listeners = new CopyOnWriteArrayList<>();
void add(Consumer<String> listener) { listeners.add(listener); }
void publish(String event) {
for (Consumer<String> listener : listeners) {
listener.accept(event);
}
}
public static void main(String[] args) throws InterruptedException {
ListenerRegistry registry = new ListenerRegistry();
registry.add(event -> System.out.println("first=" + event));
Thread publisher = Thread.ofPlatform().start(() -> registry.publish("ready"));
registry.add(event -> System.out.println("second=" + event));
publisher.join();
registry.publish("next");
}
}첫 publish에서 두 번째 리스너가 보이는지는 반복자 생성 시점에 따라 달라질 수 있지만 반복 중 구조 변경 예외는 없습니다.
두 번째 publish에서는 둘 다 호출됩니다.
쓰기마다 배열을 복사하므로 등록 변경이 빈번하면 다른 구조를 선택합니다.
접근 패턴별 동시성 컬렉션 선택
| 업무 패턴 | 컬렉션 | 주의점 |
|---|---|---|
| 키별 빈번한 갱신 | ConcurrentHashMap | 원자 메서드 사용 |
| 읽기 위주 리스너 | CopyOnWriteArrayList | 쓰기 복사 비용 |
| 정렬된 동시 키 | ConcurrentSkipListMap | 비교 비용 |
| 생산자 소비자 전달 | BlockingQueue | 용량과 대기 정책 |
Set·Deque·지연 작업 비교
동시성 컬렉션은 같은 인터페이스를 구현해도 복사 비용, 정렬, 대기 방식이 다릅니다.
CopyOnWriteArraySet은 내부 배열을 복사해 변경하므로 원소가 적고 읽기와 순회가 압도적으로 많은 고유 리스너 집합에 적합합니다.
ConcurrentSkipListSet은 정렬된 집합을 동시 접근으로 유지하며, 탐색과 변경에 비교 비용이 듭니다.
ConcurrentLinkedQueue와 ConcurrentLinkedDeque는 대기하지 않는 연결 구조입니다.
작업이 없을 때 소비자를 재우거나 생산량을 제한하지 않으므로 생산자·소비자 간 역압력이 필요하면 용량 있는 BlockingQueue를 선택합니다.
DelayQueue는 각 원소의 지연 시간이 끝난 뒤에만 take할 수 있지만 자체 용량 제한이 없는 큐이므로 예약 작업이 무한히 쌓이지 않도록 별도 상한을 둬야 합니다.
| 요구 | 구현 | 선택할 때 확인할 점 |
|---|---|---|
| 읽기 위주의 고유 원소 | CopyOnWriteArraySet | 변경마다 배열 복사 |
| 정렬된 동시 집합 | ConcurrentSkipListSet | Comparator와 동등성 일치 |
| 양끝 비차단 작업 | ConcurrentLinkedDeque | 용량·대기·역압력 없음 |
| 만료 뒤 꺼내는 작업 | DelayQueue | 무제한 저장과 시간 기준 |
정렬된 동시 Map | ConcurrentSkipListMap | 로그 시간 비교 비용 |
import java.util.List;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CopyOnWriteArraySet;
public final class ConcurrentSetAndDequeChoices {
public static void main(String[] args) {
var subscribers = new CopyOnWriteArraySet<>(List.of("audit", "metrics"));
subscribers.add("audit");
var scores = new ConcurrentSkipListSet<>(List.of(30, 10, 20));
var jobs = new ConcurrentLinkedDeque<String>();
jobs.offerLast("normal-report");
jobs.offerFirst("urgent-alert");
System.out.println("subscribers=" + subscribers);
System.out.println("scores=" + scores);
System.out.println("firstJob=" + jobs.pollFirst());
}
}subscribers=[audit, metrics]
scores=[10, 20, 30]
firstJob=urgent-alertCopyOnWriteArraySet의 반복자는 안정적인 스냅샷을 제공하지만 최신 추가를 즉시 보여 준다고 가정하면 안 됩니다.
ConcurrentSkipListSet에서는 비교 결과가 0이면 집합 관점에서 같은 원소이므로 equals와 모순되는 Comparator를 피합니다.
표준 라이브러리에는 삽입 순서를 유지하는 전용 동시성 LinkedHashSet·LinkedHashMap 구현이 없습니다.
그런 규칙이 꼭 필요하면 잠금으로 감싼 구조나 단일 소유자 처리처럼 일관성 구분을 별도로 설계합니다.
연습 문제
여러 노드 이벤트가 한 프로세스에 들어옵니다.
사용자별 가장 이른 시각만 남기되 중복 사용자 초기화가 안전해야 합니다.
compute로 기존값과 새 값을 비교하세요.
정답과 해설
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
public final class FirstLoginRegistrySolution {
private final ConcurrentHashMap<String, Instant> first = new ConcurrentHashMap<>();
void record(String userId, Instant at) {
first.compute(userId, (key, old) -> old == null || at.isBefore(old) ? at : old);
}
public static void main(String[] args) throws InterruptedException {
FirstLoginRegistrySolution registry = new FirstLoginRegistrySolution();
Instant early = Instant.parse("2026-01-01T00:00:00Z");
Instant late = Instant.parse("2026-01-02T00:00:00Z");
Thread a = Thread.ofPlatform().start(() -> registry.record("u1", late));
Thread b = Thread.ofPlatform().start(() -> registry.record("u1", early));
a.join(); b.join();
System.out.println(registry.first.get("u1"));
}
}결과는 조기입니다.
compute 함수에는 외부 저장을 넣지 않고 순수 비교만 수행합니다.
컬렉션 원자성의 최종 경계
동시성이라는 이름만 보고 여러 메서드 조합이 원자적이라고 가정하지 않습니다. 업무 동작 하나에 대응하는 원자 API가 있는지 먼저 찾고, 반복 결과가 스냅샷인지 약한 일관성인지 문서로 확인하세요. 정확한 전체 상태가 필요하면 별도 구분을 설계합니다.
컬렉션 교체 전후에는 동일한 총량과 키별 결과를 비교하고, 반복 중 쓰기가 보이는 범위를 회귀 기준으로 남깁니다. 성능 향상만 확인하고 관찰 규칙의 변화를 놓치면 소비 코드가 조용히 다른 스냅샷을 해석하게 됩니다.