3편에서는 SSE를 운영 환경에 올렸을 때 겪을 수 있는 문제들을 큰 덩어리로 정리했다. 그중에서도 실제로 가장 자주 헷갈리고, “가끔씩” 터져서 더 골치 아픈 게 동시성(concurrency) 이슈다. 처음에는 단순히 “EmitterRegistry가 JVM 메모리에 있으니까 thread-safe 컬렉션을 써야 한다” 정도로 정리하고 넘어가기 쉽다. 그런데 운영에서 겪는 문제를 보면, 동시성은 생각보다 단순하지 않다. 컬렉션만 thread-safe 하다고 끝나는 게 아니라, 같은 연결에 이벤트를 쓰는 send 자체가 동시에 들어오면서(interleaving) 알림이 끊기거나 유실된 것처럼 보이는 케이스도 충분히 생긴다. 그래서 이번 글에서는 동시성을 하나로 묶지 않고, 두 종류로 분리해서 정리한다.
- (A) 컬렉션 동시성 문제: emitter 저장소(Map/List)가 깨지는 문제
- (B) 전송(send) 동시성 문제: 같은 emitter(같은 연결)에 동시에 write가 들어오는 문제
둘 다 “동시에 접근한다”는 공통점은 있지만, 문제가 터지는 지점도 다르고 해결책도 다르다.
1. 왜 동시성이 생기나? “스레드 내부 순서”와 “스레드 간 순서”는 다르다
한 스레드 안에서는 코드가 위에서 아래로 순서대로 실행된다. 하지만 서버는 동시에 여러 요청을 처리한다. 그래서 실제로는 아래처럼 실행이 섞일 수 있다.
- Tomcat 요청 스레드 A가 실행하다가
- 중간에 스케줄러 스레드 B가 끼어들고
- 다시 A가 이어서 실행되는 식으로
이런 “끼어들기”를 인터리빙(interleaving) 이라고 한다. 공유 자원(Map/List/Emitter)에 영향을 주면 “가끔” 깨진다. 그래서 동시성 버그는 재현이 어렵고 운영에서 갑자기 터지는 케이스가 많다.
2. 우리 SSE 구현에서 “공유 자원”은 무엇인가?
우리가 만든 SSE 구현의 핵심 저장소는 아래다.
private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
그리고 구독 시 사용자별 emitter 리스트는 다음처럼 생성된다.
emitters.computeIfAbsent(memberId, ignored -> new CopyOnWriteArrayList<>()).add(emitter);
즉, 공유 자원은:
- emitters Map
- 그 안의 List<SseEmitter>
- 리스트 안의 SseEmitter
이다. 왜 공유냐?
- NotificationSseRegistry는 보통 싱글톤 Bean이고
- 요청 스레드든 @Scheduled 스레드든, onCompletion/onError/onTimeout 콜백 스레드든
- 같은 Bean 인스턴스의 필드(emitters)를 동시에 만질 수 있기 때문이다.
2. 전체 흐름(그림)부터 잡기: “구독/전송/정리”가 동시에 돌아간다
먼저 동시성이 터지는 지점을 그림으로 잡고 가자.

- send(memberId, payload)는 요청 스레드에서 호출될 수 있고
- sendHeartbeat()는 스케줄러 스레드에서 주기적으로 호출되고
- 연결 종료 콜백은 별도 타이밍에 동시에 발생한다
즉, “추가/삭제/전송”이 동시에 일어날 수 있는 구조가 된다.
(A) 컬렉션 동시성 문제: Map/List 자체가 깨지는 문제
A-1) 어떤 작업들이 동시에 발생하나?
우리 구현에서 동시에 일어나는 작업을 다시 나열하면:
- 구독 요청: addEmitter() → Map에 List 생성/추가, List에 emitter add
- 전송 요청: send() → List 순회
- 하트비트: sendHeartbeat() → Map/ List 순회
- 연결 종료 콜백: onCompletion/onTimeout/onError → List remove
즉 추가/삭제/순회가 동시에 가능하다.
A-2) thread-safe 컬렉션이 아니면 어떤 문제가 나나?
만약 List가 ArrayList였다면 대표적으로 이런 상황에서 터진다.
- 스레드 A가 for-each로 List를 순회하는데
- 스레드 B가 remove로 List 구조를 바꾸면
- 순회 중인 A는 ConcurrentModificationException 같은 예외를 터뜨릴 수 있다
이게 흔히 말하는 “중간 상태를 다른 스레드가 건드려서 터지는 동시성 문제”다.
A-3) 우리 코드에서 “순회 중 remove”가 발생할 수 있는 위치
네 코드 기준으로 개념적으로 가능한 위치는 아래다.
(1) 알림 전송(send) 루프
for (SseEmitter emitter : memberEmitters) {
try {
emitter.send(SseEmitter.event()
.name("notification")
.data(payload, MediaType.APPLICATION_JSON));
} catch (IOException ex) {
removeEmitter(memberId, emitter); // 순회 중 remove 가능
}
}
(2) 하트비트(sendHeartbeat) 루프
for (SseEmitter emitter : memberEmitters) {
try {
emitter.send(SseEmitter.event()
.name("ping")
.data("ok", MediaType.TEXT_PLAIN));
} catch (IOException ex) {
removeEmitter(memberId, emitter); // 순회 중 remove 가능
}
}
A-4) 해결책: thread-safe 컬렉션(우리 구현이 이미 선택한 것)
그래서 우리는 정석대로 다음 조합을 사용했다.
- Map: ConcurrentHashMap
- List: CopyOnWriteArrayList
이 조합의 의미는:
- 여러 스레드가 동시에 put/remove/iterate를 해도 내부 구조가 쉽게 깨지지 않는다
- 특히 CopyOnWriteArrayList는 순회 시 스냅샷을 보기 때문에,
순회 중 remove가 들어와도 순회가 깨지기 어렵다
✅ 결론: 컬렉션 동시성(저장소 구조) 관점에서 우리의 구현 방향은 맞다.
다만 CopyOnWriteArrayList는 “쓰기(add/remove)가 자주 일어나면 비용이 커진다”는 특성이 있어,
접속/해제가 매우 빈번한 환경에서는 다른 구조(락/ConcurrentLinkedQueue 등)를 고민할 수도 있다.
(이건 규모가 커질 때의 이야기고, 초기/중간 단계에서는 매우 실용적인 선택이다.)
(B) 전송(send) 동시성 문제: 같은 emitter에 동시에 write하는 문제
여기부터가 사람들이 자주 놓치는 포인트다.
컬렉션이 안전해도 전송은 깨질 수 있다.
B-1) 우리 코드에서 send가 동시에 들어오는 경로
emitter.send()는 최소 두 경로에서 호출된다.
- 알림 전송: send(memberId, payload) (요청 스레드)
- 하트비트: sendHeartbeat() (스케줄러 스레드)
즉 같은 사용자(memberId)의 같은 emitter에 대해
- 요청 스레드가 notification을 send하는 순간
- 스케줄러 스레드가 ping을 send할 수 있다\
B-2) 왜 문제가 되나?
SseEmitter.send()는 결국 하나의 HTTP 응답 스트림에 텍스트를 쓴다.
SSE 이벤트는 텍스트 포맷이다:
- event: xxx
- data: yyy
- 빈 줄로 이벤트 종료
이 write가 겹치면:
- 이벤트 텍스트가 섞여 포맷이 깨질 수 있고 → 클라이언트 파싱 실패/끊김
- 간헐적 IOException → emitter 제거 → 사용자는 “갑자기 끊김”으로 체감
중요한 건 이거다. ConcurrentHashMap / CopyOnWriteArrayList는 “자료구조”를 안전하게 만들 뿐, emitter.send()의 동시 호출까지 자동으로 막아주지 않는다.
B-3) 해결책 1: emitter 단위 send 직렬화(가장 간단하고 실전적)
목표는 하나다.
같은 emitter에는 send가 한 번에 하나만 들어가게 만들기
가장 쉬운 방법은 emitter를 lock으로 묶는 것이다.
private void safeSend(SseEmitter emitter, SseEmitter.SseEventBuilder event) throws IOException {
synchronized (emitter) {
emitter.send(event);
}
}
이렇게 하면
- ping과 notification이 같은 순간에 들어오더라도
- 둘 중 하나가 먼저 끝나야 다음 send가 실행된다
→ write가 섞이지 않는다.
운영에서 “가끔 알림이 끊기거나, 어떤 날만 유실처럼 보이는” 케이스는
컬렉션보다 이런 send 경쟁에서 시작되는 경우가 생각보다 많다.
B-4) 해결책 2: 전송 큐/워커(규모가 커지면 고려)
트래픽이 커지거나 안정성을 더 올리고 싶다면
- send 요청을 큐에 쌓고
- 전용 워커가 순서대로 보내는 구조
로 발전할 수 있다.
이 방식은 backpressure(너무 많이 쌓일 때)나 재시도 정책을 붙이기도 쉽지만,
설계/코드가 커지니 보통 “후속편/부록”에 적합하다.