SSE 레지스트리에서 thread-safe를 잡을 때 흔히 쓰는 조합은 아래다.
- Map: ConcurrentHashMap
- List: CopyOnWriteArrayList
- send(write): synchronized(emitter) 같은 직렬화
이 셋은 전부 “동시 접근이 생겼을 때 중간 상태를 다른 스레드가 보지 못하게” 만들기 위한 장치인데, 각각 안전이 확보되는 순서와 지점이 다르다.
0. 큰 그림: thread-safe를 만드는 대표 전략 3가지

- ConcurrentHashMap은 주로 락 + CAS를 섞어서 “Map 구조”를 보호한다.
- CopyOnWriteArrayList는 스냅샷으로 “순회 안전”을 만든다.
- synchronized(emitter)는 “같은 연결(write)”을 순서대로 보내게 만든다.
1. ConcurrentHashMap — CAS는 “빈 버킷 설치”, 락은 “버킷 내부 수정”
ConcurrentHashMap의 핵심은 “Map 전체를 잠그지 않는다”는 것이다. 대신, 내부적으로 버킷(table의 한 칸) 단위로 경쟁을 조정한다.
내부 구조
- 내부에 table[] 배열이 있고 (버킷 배열)
- 키의 해시로 인덱스 i를 계산해 table[i]에 노드를 둔다
- 충돌이 나면 table[i] 안에서 리스트/트리로 이어진다
put / computeIfAbsent에서 동시성이 잡히는 순서
ConcurrentHashMap은 크게 두 경우로 갈린다.
(A) 버킷이 비어 있다 (table[i] == null)
이때는 “첫 노드를 누가 설치할지”가 경쟁 포인트다. 그래서 CAS가 등장한다.

- CAS는 “이 칸이 아직 null이면 내가 넣겠다”를 원자적으로 보장한다.
- 그래서 “둘 다 null을 봤는데 둘 다 넣는” 중간 상태가 생기지 않는다.
(B) 버킷이 이미 차 있다 (table[i] != null)
이때는 리스트/트리를 수정해야 하므로, 수정 과정에서 구조가 깨지지 않게 버킷 단위 락을 잡는다. 구현상으로는 보통 “버킷의 head 노드”를 락 대상으로 삼는다(개념적으로 synchronized(f)).

- “전체 Map”이 아니라 해당 버킷만 잠근다.
- 키가 골고루 분산되면(다른 버킷에 떨어지면) 병렬 처리 가능성이 커진다.
SSE에서 computeIfAbsent가 특히 유리한 이유(구현 관점)
SSE 레지스트리에서 흔히 쓰는 패턴:
emitters.computeIfAbsent(memberId, k -> new CopyOnWriteArrayList<>()).add(emitter);
여기서 중요한 건 “없으면 만들어서 넣기”를 경쟁 상황에서도 안전하게 하고 싶다는 점이다.
- 어떤 스레드가 “memberId 키가 없다”고 보고 리스트를 만들려는 순간,
- 다른 스레드도 동시에 “없다”고 보고 또 만들 수 있다.
computeIfAbsent는 내부적으로
- “누가 생성 담당인지”를 결정하는 과정에서 CAS/락을 활용하고,
- 단 하나의 값이 최종적으로 Map에 들어가도록 정리한다.
2. CopyOnWriteArrayList — “쓰기 때 복사, 읽기는 스냅샷”
CopyOnWriteArrayList는 순회 안정성에 특화된 구조다. SSE에서 순회(send) 중 remove(종료 콜백)가 동시에 생길 수 있어서 특히 잘 맞는다.
내부 전략(구현 관점)
- 내부에 Object[] array가 있고
- add/remove가 발생하면:
- 락을 잡고
- 기존 배열을 복사해서 새 배열을 만들고
- 새 배열로 array 참조를 교체한다
읽기/순회는:
- 현재 array 참조(스냅샷)를 들고 끝까지 돈다

그래서 어떤 문제가 사라지나?
- ArrayList였다면 “순회 중 remove”에 터질 수 있는
ConcurrentModificationException 계열을 피할 수 있다. - 실제로 SSE에서는 “send 루프”와 “removeEmitter 콜백”이 겹칠 수 있어서 효과가 크다.
비용 포인트(현실적인 단점)
- add/remove가 일어날 때마다 배열 전체 복사 비용
- 접속/해제가 매우 빈번(churn)하면 부담이 커질 수 있다
다만 SSE는 보통 “연결이 길게 유지되고, 리스트 수정 빈도는 상대적으로 낮다”는 전제에서 출발하니 초기/중간 규모에서는 실용적인 선택인 경우가 많다.
3. synchronized(emitter) — 같은 연결(send/write)을 “한 줄로 세우는” 방식
여기부터는 컬렉션이 아니라 “전송(write)” 안전이다.
왜 컬렉션이 thread-safe여도 send가 깨질 수 있나?
SseEmitter.send()는 결국 하나의 HTTP 응답 스트림에 텍스트를 쓴다.
그래서 같은 emitter에 동시에 send가 들어오면 이벤트 텍스트가 섞일 수 있다.
- 요청 스레드가 notification을 쓰는 중
- 스케줄러 스레드가 ping을 동시에 쓰면
- 클라이언트 입장에서는 포맷이 깨져 끊김/파싱 실패처럼 보일 수 있다
해결은 emitter 단위 직렬화
private void safeSend(SseEmitter emitter, SseEmitter.SseEventBuilder event) throws IOException {
synchronized (emitter) {
emitter.send(event);
}
}

- “Map 전체”를 막는 게 아니라
- 그 emitter(그 연결) 에 대해서만 send가 직렬화된다.
4. 결론
ConcurrentHashMap/CopyOnWriteArrayList는 “저장소 구조”를 안전하게 만들고, synchronized(emitter)는 “같은 연결 write”를 안전하게 만든다. 둘을 분리해서 잡아야 SSE 동시성 이슈를 끝까지 막을 수 있다.