기술: Java, Spring Boot, React, Vite, Kubernetes Java Client, SSE(Server-Sent Events)
목표: Kubernetes 클러스터의 namespace별 Deployment 상태를 5초 이내로 실시간에 가깝게 보여주고, Replica 수·이미지 태그를 웹에서 바로 수정할 수 있는 모니터링 시스템 만들기
이 글은 “어떤 코드를 썼는지”보다 왜 이런 구조를 선택했고, 다른 선택지는 왜 버렸는지에 초점을 맞춰서 정리했습니다.
요구사항은 단순합니다.
Namespace 목록 조회
Namespace 상세 화면에서 Deployment 정보들 표시
상태 변경이 5초 이내에 UI에 반영
Replica 수, 이미지 태그를 UI에서 수정 가능
가장 단순한 접근은 이겁니다.
프론트에서 2~3초마다
GET /api/namespaces/{ns}/deployments
이렇게 polling만 해도 “5초 이내 반영”은 쉽게 만족합니다.
실제로 실서비스에서도 이런 방식이 꽤 많이 쓰입니다.
그럼에도 저는 Kubernetes Watch + SSE 조합을 선택했습니다.
즉, 요구사항만 맞추려면 polling으로 충분하지만, 이번 프로젝트에서는 “이벤트 기반 설계 + 안정적인 실시간 스트리밍”을 목표로 잡았습니다.
처음에 구현한 코드입니다.
사용자에게 요청이 오면 컨트롤러에서 sse를 만들고 k8s의 delployment를 계속 감시하다 이벤트가 생기면 sse에 보낸다.
@GetMapping("/namespaces/{namespace}/deployments/stream")
public SseEmitter stream(@PathVariable String namespace) {
SseEmitter emitter = new SseEmitter(0L);
executor.submit(() -> {
try (Watch<V1Deployment> watch = createDeploymentWatch(namespace)) {
for (Watch.Response<V1Deployment> event : watch) {
emitter.send(SseEmitter.event().data(event));
}
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
의도는 단순합니다.
Watch로 Kubernetes 이벤트를 받고emitter.send()로 SSE 전송겉으로 보면 멀쩡해 보입니다. 그런데 실제로 붙여보니 이런 문제가 바로 나왔습니다.
클라이언트마다 Watch 스트림이 하나씩 열린다
SSE 전송이 느려지면 Watch도 같이 느려진다
for (event : watch) 루프 안에서 곧바로 emitter.send()를 호출직감적으로 느꼈습니다.
“아, Watch에서 읽는 일과, 클라이언트에게 뿌리는 일을 같은 흐름에서 처리하면 안 되겠구나.”
그래서 구조를 다시 잡았습니다.
생각을 이렇게 정리했습니다.
같은 namespace를 여러 명이 보고 있어도
이 Watch가 받은 이벤트를
여기서 의문이 생길 수도 있습니다. "그냥 k8s 전체에 대해 watch 하나 두고 발생한 이벤트에 대해서 필터링한 뒤 맞는 네임스페이스 보고 있는 사용자에게 sse로 알려주면 되는거 아니야?"
이에 대한 의문으로는 1.요구사항이 Namespace 상세 페이지에서 Deployment 실시간 조회 2. 안 보는 네임스페이스까지 계속 감시할 필요가 없음 로 해결할 수 있습니다. 또한 만약 이 모니터링 서비스를 사용하고 있는 유저가 없다면 watch는 다 종료되기 때문에 리소스 효율성도 챙길 수 있습니다.

// namespace -> Watch 객체
private final ConcurrentMap<String, Watch<V1Deployment>> namespaceWatchRegistry = new ConcurrentHashMap<>();
public void startNamespaceWatch(String namespace) {
// 이미 Watch가 있으면 새로 만들지 않음
if (namespaceWatchRegistry.containsKey(namespace)) return;
executor.submit(() -> {
Watch<V1Deployment> watch = null;
try {
watch = createDeploymentWatch(namespace);
// putIfAbsent로 네임스페이스당 1개만 보장
Watch<V1Deployment> previous = namespaceWatchRegistry.putIfAbsent(namespace, watch);
if (previous != null) {
// 누군가 먼저 넣었으면 현재 것은 바로 종료
watch.close();
return;
}
for (Watch.Response<V1Deployment> event : watch) {
// 여기서는 "이벤트를 받았다"까지가 역할
eventPublisher.publishEvent(
new DeploymentEvent(this, namespace, formatDeploymentEventData(event))
);
}
} catch (Exception e) {
log.error("Error while watching namespace {}", namespace, e);
} finally {
namespaceWatchRegistry.remove(namespace);
if (watch != null) {
try { watch.close(); } catch (IOException ignored) {}
}
}
});
}
여기서 중요한 포인트는 두 가지였습니다.
네임스페이스별 Watch 1개 보장
putIfAbsent로 최초 1개만 등록, 나머지는 바로 닫기Watch는 “이벤트 읽기”까지만 책임
for 루프 안에서 UI에 바로 쓰지 않고,DeploymentEvent로 감싸서 publishEvent만 호출이렇게 해서 Watch는 “Kubernetes와의 스트림”에만 집중하도록 만들었습니다.
SseService 쪽은 크게 세 가지를 해결해야 했습니다.
처음에는 이렇게 만들었습니다.
// clientId -> SseEmitter
private final Map<String, SseEmitter> sseEmitterRegistry = new ConcurrentHashMap<>();
// namespace -> emitters
private final ConcurrentMap<String, List<SseEmitter>> namespaceEmitters = new ConcurrentHashMap<>();
// emitter -> clientId (역방향)
private final ConcurrentMap<SseEmitter, String> emitterToClientIdMap = new ConcurrentHashMap<>();
그리고 namespaceEmitters의 값은 이렇게 만들었습니다.
namespaceEmitters.computeIfAbsent(
namespace, key -> Collections.synchronizedList(new ArrayList<>())
);
여기서 문제가 하나 나옵니다.
synchronizedList는 “각 메서드 호출”은 thread-safe지만,iterator()로 돌면서 remove()하는 패턴은예:
List<SseEmitter> emitters = namespaceEmitters.get(namespace);
Iterator<SseEmitter> it = emitters.iterator();
while (it.hasNext()) {
SseEmitter e = it.next();
// 여기서 예외 나면 remove
it.remove();
}
그래서 선택지를 놓고 고민했습니다.
synchronized(list) { ... }를 지키면서 쓰기CopyOnWriteArrayList로 바꾸고, iterator.remove 대신 list.remove(e)로 쓰기이 시스템은 “읽기(브로드캐스트)가 훨씬 많고, 추가/삭제는 상대적으로 적다”는 특성이 있어서,
이번에는 CopyOnWriteArrayList로 바꾸는 쪽을 선택했습니다.
결국 이렇게 정리했습니다.
private final ConcurrentMap<String, CopyOnWriteArrayList<SseEmitter>> namespaceEmitters = new ConcurrentHashMap<>();
이제 SseService는 DeploymentEvent를 받아서 해당 namespace의 emitter들에게 브로드캐스트만 하면 됩니다.
@EventListener
public void handleDeploymentEvent(DeploymentEvent event) {
// 여기서 바로 돌리면 Watch 스레드가 묶이므로, 비동기로 넘김
sseExecutor.submit(() ->
notifyEmitters(event.getNamespace(), event.getData())
);
}
private void notifyEmitters(String namespace, String data) {
List<SseEmitter> emitters = namespaceEmitters.getOrDefault(namespace, List.of());
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().data(data));
} catch (IOException e) {
// 끊긴 연결 정리
removeEmitter(namespace, emitter);
} catch (Exception e) {
removeEmitter(namespace, emitter);
}
}
}
여기서 중요한 결정은 하나 더 있습니다.
“Watch 스레드와 SSE 브로드캐스트 스레드를 반드시 분리하자”
Spring의 ApplicationEventPublisher는 기본이 “동기”라서,
그냥 @EventListener만 쓰면 Watch thread가 notifyEmitters가 끝날 때까지 묶입니다.
그래서 저는 두 가지 중 하나를 선택할 수 있었습니다.
@Async @EventListener + @EnableAsyncExecutorService 사용 (sseExecutor.submit(...))이번 프로젝트에서는 코드 흐름을 눈에 보이게 통제하고 싶어서
두 번째 방식(내부 Executor) 를 선택했습니다.
실시간 스트리밍에서 중요한 건 “시작”보다 “정리”입니다.
안 끊길 것처럼 보이는 SSE도, 브라우저를 닫으면 바로 소켓이 끊깁니다.
SseEmitter는 콜백을 제공합니다.
emitter.onCompletion(() -> { ... });
emitter.onTimeout(() -> { ... });
emitter.onError(error -> { ... });
처음엔 여기저기에서 emitter를 지우다 보니 코드가 꼬이기 시작했습니다.
removeExistingEmitter에서 한 번 제거removeEmitterFromNamespace에서 또 제거stopNamespaceWatch(namespace)를 여기서도, 저기서도 호출이렇게 되면:
그래서 정리 기준을 단순하게 정했습니다.
“특정 namespace에서 emitter 하나 제거 + 필요하면 Watch 정지”는
removeEmitterFromNamespace(namespace, emitter) 한 메서드에서만 한다.다른 메서드는 이걸 호출만 한다.
예:
private void removeEmitterFromNamespace(String namespace, SseEmitter emitter) {
List<SseEmitter> emitters = namespaceEmitters.get(namespace);
if (emitters == null) return;
emitters.remove(emitter);
String clientId = emitterToClientIdMap.remove(emitter);
if (clientId != null) {
sseEmitterRegistry.remove(clientId);
}
if (emitters.isEmpty()) {
// 이 namespace를 더 이상 보고 있는 클라이언트가 없으면 Watch도 정지
watchService.stopNamespaceWatch(namespace);
namespaceEmitters.remove(namespace);
}
}
그리고 콜백에서는 이렇게만 호출합니다.
emitter.onCompletion(() -> removeEmitterFromNamespace(namespace, emitter));
emitter.onTimeout(() -> removeEmitterFromNamespace(namespace, emitter));
emitter.onError(error -> removeEmitterFromNamespace(namespace, emitter));
이렇게 역할을 한 군데로 모으니,
를 한 눈에 파악할 수 있게 되었습니다.
이번 프로젝트에서 가장 크게 배운 건 두 가지입니다.
이벤트 기반 설계에서는 “누가 어디까지 책임지는지”를 명확히 나눠야 한다
DeploymentEvent로 느슨하게 연결실시간 스트리밍에서 중요한 건 “정리와 동시성”이다
결과적으로:
