동시성 컬렉션의 원자성
동기화 래퍼의 복합 연산 틈을 재현하고 ConcurrentHashMap의 원자 연산과 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);
}
}checked 래치는 두 로더가 모두 containsKey에서 부재를 본 뒤에야 proceed를 해제하게 합니다. 이후 각 로더가 자신의 이름을 put하며 마지막 put의 값 하나만 남습니다. 이번 출력은 {config=loader-b}였고 마지막 저장자의 이름은 실행마다 달라질 수 있습니다.
이 예제는 외부 계산이나 I/O를 실행하지 않습니다. 두 호출 사이에 그런 작업을 추가하면 최종 값이 하나여도 작업은 중복될 수 있습니다.
이 JDK의 synchronizedMap도 putIfAbsent·compute·merge 같은 복합 메서드의 전체 호출을 같은 내부 잠금으로 감쌉니다. 문제는 복합 API의 부재가 아니라 containsKey와 put을 나누어 호출한 데 있습니다.
여러 호출을 직접 묶어야 한다면 래퍼 객체의 같은 모니터를 사용하되, 이 재현용 래치 대기를 잠금 안으로 옮기지는 않습니다. 원본 맵에 대한 접근도 래퍼를 거쳐야 합니다.
컬렉션 선택 전에 정할 규칙
- ConcurrentHashMap.compute는 메서드 호출당 함수를 한 번 실행한다. 다른 갱신을 지연시킬 수 있으므로 짧게 유지하고 함수 안에서 이 맵을 수정하지 않는다.
- ConcurrentHashMap은
null키와 값을 허용하지 않는다. 함수의 null 반환이 뜻하는 부재·삭제는 아래 표에서 구분한다. - 정확한 전체 스냅샷이 필요하면 모든 쓰기가 같은 잠금 경계에 참여하게 하거나 불변 자료 전체 교체를 사용한다.
ConcurrentHashMap의 조건부 저장, 함수 갱신과 replace 두 오버로드가 무엇을 변경하고 반환하는지 비교하는 API 계약 표입니다.
| 호출 | 저장·변경 조건 | 반환값 |
|---|---|---|
putIfAbsent(k, v) | 부재일 때 v 저장 | 기존 값 · 부재였으면 null |
computeIfAbsent(k, f) | 부재일 때 f 계산 · null이면 저장 안 함 | 기존 값 또는 계산값 · 계산값이 null이면 null |
compute(k, f) | 기존 값 또는 null로 f 계산 · 결과 null이면 매핑 제거 | 새 값 · 매핑이 없으면 null |
merge(k, v, f) | 부재면 v · 존재하면 f(기존 값, v) · 결과 null이면 제거 | 새 값 · 매핑이 없으면 null |
replace(k, v) | 매핑이 있을 때만 v로 교체 | 기존 값 · 부재면 null |
replace(k, old, next) | 현재 값이 old와 같을 때만 next로 교체 | 교체했으면 true · 아니면 false |
putIfAbsent(k, v)- 저장·변경 조건: 부재일 때 v 저장반환값: 기존 값 · 부재였으면 null
computeIfAbsent(k, f)- 저장·변경 조건: 부재일 때 f 계산 · null이면 저장 안 함반환값: 기존 값 또는 계산값 · 계산값이 null이면 null
compute(k, f)- 저장·변경 조건: 기존 값 또는 null로 f 계산 · 결과 null이면 매핑 제거반환값: 새 값 · 매핑이 없으면 null
merge(k, v, f)- 저장·변경 조건: 부재면 v · 존재하면 f(기존 값, v) · 결과 null이면 제거반환값: 새 값 · 매핑이 없으면 null
replace(k, v)- 저장·변경 조건: 매핑이 있을 때만 v로 교체반환값: 기존 값 · 부재면 null
replace(k, old, next)- 저장·변경 조건: 현재 값이 old와 같을 때만 next로 교체반환값: 교체했으면 true · 아니면 false
putIfAbsent(k, expensive())의 인자 계산은 호출 전에 끝납니다. 조건부 저장이 그 계산의 중복까지 막지는 않습니다. computeIfAbsent도 null 반환·예외·제거 뒤의 이후 호출까지 계산을 한 번으로 고정하지는 않습니다.
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);
}
}모든 join 뒤 키 cas는 80,000, lock과 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");
}
}ListenerRegistry.main에서 두 번째 등록과 첫 반복자 생성의 순서를 비교하며, join 뒤 두 번째 publish의 공통 결과를 구분합니다.
| ready 반복자 생성 시점 | ready에서 호출하는 리스너 | join 뒤 next에서 호출하는 리스너 |
|---|---|---|
| ready 반복자 생성: second 등록 전 | first=ready | first=next, second=next |
| ready 반복자 생성: second 등록 뒤 | first=ready, second=ready | first=next, second=next |
- ready 반복자 생성: second 등록 전
- ready에서 호출하는 리스너:
first=readyjoin 뒤 next에서 호출하는 리스너:first=next,second=next - ready 반복자 생성: second 등록 뒤
- ready에서 호출하는 리스너:
first=ready,second=readyjoin 뒤 next에서 호출하는 리스너:first=next,second=next
이번 출력은 first=ready, first=next, second=next였고 second=ready가 추가되는 경로는 관측되지 않았습니다. 각 publish는 반복자 생성 시점의 배열을 사용합니다. main은 second 등록과 publisher.join 뒤 next를 발행합니다.
이 예제의 add는 새 배열을 복사하므로 등록 변경이 빈번하면 다른 구조를 선택합니다. 스냅샷은 리스너 참조 목록이며 리스너 객체의 내부 상태까지 복사하지는 않습니다.
접근 패턴별 동시성 컬렉션 선택
| 업무 패턴 | 컬렉션 | 주의점 |
|---|---|---|
| 키별 빈번한 갱신 | 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를 피합니다.
표준 라이브러리에 ConcurrentLinkedHashSet·ConcurrentLinkedHashMap이라는 전용 클래스는 없습니다. 다만 CopyOnWriteArraySet의 반복자는 원소가 추가된 순서의 스냅샷을 제공하므로, 원소가 적고 쓰기가 드문 고유 집합에는 이 성질을 활용할 수 있습니다.
삽입 순서가 필요한 맵이나 쓰기가 많은 집합은 잠금으로 감싼 구조 또는 단일 소유자 처리처럼 일관성 경계를 별도로 설계합니다.
연습 문제
여러 노드 이벤트가 한 프로세스에 들어옵니다.
사용자별 가장 이른 시각만 남기되 중복 사용자 초기화가 안전해야 합니다.
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"));
}
}두 입력 중 이른 시각인 2026-01-01T00:00:00Z가 남습니다. 이 예제는 compute 함수의 호출 횟수를 출력하지 않습니다.
compute 함수에는 외부 저장을 넣지 않고 순수 비교만 수행합니다.
컬렉션 원자성의 최종 경계
동시성이라는 이름만 보고 여러 메서드 조합이 원자적이라고 가정하지 않습니다.
업무 동작 하나에 대응하는 원자 API가 있는지 먼저 찾고, 반복 결과가 스냅샷인지 약한 일관성인지 문서로 확인하세요.
정확한 전체 상태가 필요하면 모든 쓰기와 읽기가 참여하는 공통 경계를 설계합니다.
컬렉션 교체 전후에는 동일한 총량과 키별 결과를 비교하고, 반복 중 쓰기가 보이는 범위를 회귀 기준으로 남깁니다.
성능 향상만 확인하고 관찰 규칙의 변화를 놓치면 소비 코드가 조용히 다른 스냅샷을 해석하게 됩니다.