쿠버네티스 Deployment 실시간 모니터링 시스템에서 부딪힌 문제와 해결 과정

가오리·2025년 12월 6일

Kubernetes

목록 보기
4/4

기술: Java, Spring Boot, React, Vite, Kubernetes Java Client, SSE(Server-Sent Events)
목표: Kubernetes 클러스터의 namespace별 Deployment 상태를 5초 이내로 실시간에 가깝게 보여주고, Replica 수·이미지 태그를 웹에서 바로 수정할 수 있는 모니터링 시스템 만들기

이 글은 “어떤 코드를 썼는지”보다 왜 이런 구조를 선택했고, 다른 선택지는 왜 버렸는지에 초점을 맞춰서 정리했습니다.


1. 문제 정의 – 그냥 계속 불러오면 안 되나?

요구사항은 단순합니다.

  • Namespace 목록 조회

  • Namespace 상세 화면에서 Deployment 정보들 표시

    • 상태(Running, Failed …)
    • replicas (ready / desired)
    • image 태그
  • 상태 변경이 5초 이내에 UI에 반영

  • Replica 수, 이미지 태그를 UI에서 수정 가능

가장 단순한 접근은 이겁니다.

프론트에서 2~3초마다
GET /api/namespaces/{ns}/deployments

이렇게 polling만 해도 “5초 이내 반영”은 쉽게 만족합니다.

실제로 실서비스에서도 이런 방식이 꽤 많이 쓰입니다.

그럼에도 저는 Kubernetes Watch + SSE 조합을 선택했습니다.

  • 이유 1: “변경이 있을 때만” 보내는 이벤트 기반 흐름을 경험하고 싶었다.
  • 이유 2: 클러스터에 부하를 덜 주는 구조(K8s API에 매번 list 호출 X)를 설계해보고 싶었다.
  • 이유 3: Watch/SSE 조합에서 생기는 동시성·자원 관리 문제를 직접 부딪혀보고 싶었다.

즉, 요구사항만 맞추려면 polling으로 충분하지만, 이번 프로젝트에서는 “이벤트 기반 설계 + 안정적인 실시간 스트리밍”을 목표로 잡았습니다.


2. 첫 시도 – Watch와 SSE를 이어 붙였을 때 생긴 문제

처음에 구현한 코드입니다.

사용자에게 요청이 오면 컨트롤러에서 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 전송

겉으로 보면 멀쩡해 보입니다. 그런데 실제로 붙여보니 이런 문제가 바로 나왔습니다.

  1. 클라이언트마다 Watch 스트림이 하나씩 열린다

    • 브라우저 탭을 5개 켜면, 같은 namespace에 대해 Watch가 5개 열림
    • Kubernetes API 서버 입장에서는 불필요한 연결이 계속 늘어남
  2. SSE 전송이 느려지면 Watch도 같이 느려진다

    • for (event : watch) 루프 안에서 곧바로 emitter.send()를 호출
    • 만약 네트워크가 느리거나, 클라이언트가 send를 오래 잡고 있으면
    • 다음 Watch 이벤트 처리도 같이 밀림 → “실시간”이 깨지기 시작

직감적으로 느꼈습니다.

“아, Watch에서 읽는 일과, 클라이언트에게 뿌리는 일을 같은 흐름에서 처리하면 안 되겠구나.”

그래서 구조를 다시 잡았습니다.


3. 설계 다시 잡기 – “네임스페이스당 Watch 1개 + 여러 SSE 구독자”

생각을 이렇게 정리했습니다.

  1. 같은 namespace를 여러 명이 보고 있어도

    • Kubernetes API에는 Watch 연결은 딱 하나만 유지
  2. 이 Watch가 받은 이벤트를

    • 서버 내부에서 한 번 가공
    • 그걸 여러 SSE 클라이언트에게 브로드캐스트

여기서 의문이 생길 수도 있습니다. "그냥 k8s 전체에 대해 watch 하나 두고 발생한 이벤트에 대해서 필터링한 뒤 맞는 네임스페이스 보고 있는 사용자에게 sse로 알려주면 되는거 아니야?"

이에 대한 의문으로는 1.요구사항이 Namespace 상세 페이지에서 Deployment 실시간 조회 2. 안 보는 네임스페이스까지 계속 감시할 필요가 없음 로 해결할 수 있습니다. 또한 만약 이 모니터링 서비스를 사용하고 있는 유저가 없다면 watch는 다 종료되기 때문에 리소스 효율성도 챙길 수 있습니다.

3-1. WatchService – 네임스페이스별로 Watch 1개 관리

// 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) {}
            }
        }
    });
}

여기서 중요한 포인트는 두 가지였습니다.

  1. 네임스페이스별 Watch 1개 보장

    • putIfAbsent로 최초 1개만 등록, 나머지는 바로 닫기
  2. Watch는 “이벤트 읽기”까지만 책임

    • for 루프 안에서 UI에 바로 쓰지 않고,
    • DeploymentEvent로 감싸서 publishEvent만 호출

이렇게 해서 Watch는 “Kubernetes와의 스트림”에만 집중하도록 만들었습니다.


4. SseService – 여러 클라이언트에게 안전하게 브로드캐스트 하기

SseService 쪽은 크게 세 가지를 해결해야 했습니다.

  1. 어떤 namespace에 어떤 클라이언트들이 붙어있는지 관리
  2. 끊긴 클라이언트 정리 (메모리 누수 방지)
  3. 브로드캐스트 중에 동시성 문제 안 나게 하기

4-1. 데이터 구조 설계

처음에는 이렇게 만들었습니다.

// 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();
}

그래서 선택지를 놓고 고민했습니다.

  1. 모든 사용처에서 synchronized(list) { ... }를 지키면서 쓰기
  2. 애초에 CopyOnWriteArrayList로 바꾸고, iterator.remove 대신 list.remove(e)로 쓰기

이 시스템은 “읽기(브로드캐스트)가 훨씬 많고, 추가/삭제는 상대적으로 적다”는 특성이 있어서,
이번에는 CopyOnWriteArrayList로 바꾸는 쪽을 선택했습니다.

결국 이렇게 정리했습니다.

private final ConcurrentMap<String, CopyOnWriteArrayList<SseEmitter>> namespaceEmitters = new ConcurrentHashMap<>();
  • 장점: 브로드캐스트 중에 emitter를 제거해도 ConcurrentModification 걱정이 없다.
  • 단점: emitter 추가/삭제 시 내부 배열 복사 비용이 있다.

4-2. 이벤트 브로드캐스트

이제 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가 끝날 때까지 묶입니다.

그래서 저는 두 가지 중 하나를 선택할 수 있었습니다.

  1. @Async @EventListener + @EnableAsync
  2. SseService 내부에서 직접 ExecutorService 사용 (sseExecutor.submit(...))

이번 프로젝트에서는 코드 흐름을 눈에 보이게 통제하고 싶어서
두 번째 방식(내부 Executor) 를 선택했습니다.


5. 리소스 정리 – 끊긴 SSE와 Watch를 어떻게 정리할 것인가

실시간 스트리밍에서 중요한 건 “시작”보다 “정리”입니다.
안 끊길 것처럼 보이는 SSE도, 브라우저를 닫으면 바로 소켓이 끊깁니다.

SseEmitter는 콜백을 제공합니다.

emitter.onCompletion(() -> { ... });
emitter.onTimeout(() -> { ... });
emitter.onError(error -> { ... });

처음엔 여기저기에서 emitter를 지우다 보니 코드가 꼬이기 시작했습니다.

  • removeExistingEmitter에서 한 번 제거
  • removeEmitterFromNamespace에서 또 제거
  • 리스트가 비면 stopNamespaceWatch(namespace)를 여기서도, 저기서도 호출

이렇게 되면:

  • 어떤 경로에서는 emitter를 두 번 지우려고 하고
  • 어떤 경로에서는 map 한쪽만 지우고, 다른 쪽은 그대로 남고
  • 결국 좀비 emitter가 map에 남을 수 있습니다.

그래서 정리 기준을 단순하게 정했습니다.

  1. “특정 namespace에서 emitter 하나 제거 + 필요하면 Watch 정지”는

    • 무조건 removeEmitterFromNamespace(namespace, emitter) 한 메서드에서만 한다.
  2. 다른 메서드는 이걸 호출만 한다.

예:

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));

이렇게 역할을 한 군데로 모으니,

  • “언제 Watch를 끊는가?”
  • “언제 emitter를 Map에서 지우는가?”

를 한 눈에 파악할 수 있게 되었습니다.


6. 정리

이번 프로젝트에서 가장 크게 배운 건 두 가지입니다.

  1. 이벤트 기반 설계에서는 “누가 어디까지 책임지는지”를 명확히 나눠야 한다

    • Watch는 “Kubernetes 이벤트 읽기”까지
    • SseService는 “클라이언트에게 브로드캐스트”까지
    • 그 사이를 DeploymentEvent로 느슨하게 연결
  2. 실시간 스트리밍에서 중요한 건 “정리와 동시성”이다

    • 네임스페이스마다 Watch 1개만 유지
    • 끊긴 emitter는 콜백에서 한 곳에서만 정리
    • 리스트/맵을 어떻게 쓰면 동시성 문제가 없는지 계속 신경

결과적으로:

  • Namespace별로 Deployment 상태, replicas, image를 실시간으로 볼 수 있고
  • Replica 수/이미지 태그 변경도 바로 반영되며
  • Kubernetes API와의 연결 수, 서버 자원 사용도 과도하게 늘어나지 않는 구조를 만들 수 있었습니다.

7. 영상

profile
가오리의 개발 이야기

0개의 댓글