Taehwan Dev Blog

쿠버네티스, eBPF, 그리고 머신러닝 이야기

View My GitHub Profile

9 April 2026

Server-Sent Events 애플리케이션

by Yang Taehwan

프로젝트 소개

깃헙 레포지토리

개발 스택

Server-Sent Events

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
event: notification
data: client connected

동작 순서

클라이언트 연결

이벤트 발행

Redis 메시지 수신

각 인스턴스에서 로컬 SSE 전송

Redis 실행

docker run --name local-redis -p 6379:6379 -d redis:7

테스트 : 클라이언트로 브라우저 이용

인스턴스 2개 실행

./gradlew bootRun
./gradlew bootRun --args='--server.port=8081'

테스트 : 클라이언트로 curl 이용

인스턴스 1개 실행

./gradlew bootRun

구독(SSE 연결)

다른 터미널에서 이벤트 발생

curl -X POST http://localhost:8080/notifications/broadcast \
  -H "Content-Type: application/json" \
  -d '{"message":"hello"}'

쿠버네티스 운영시 주의할 점

Nginx Ingress Controller의 경우

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: sseexample.ythwork.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: sse-example
                port:
                  number: 8080
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: sse-ingress
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
  ingressClassName: nginx
  rules:
    - host: sseexample.ythwork.com
      http:
        paths:
          - path: /subscribe
            pathType: Prefix
            backend:
              service:
                name: sse-example
                port:
                  number: 8080
location /api {
    proxy_pass ...
}

location /subscribe {
    proxy_buffering off;
    proxy_read_timeout 3600;
}
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-cache");
    response.setHeader("X-Accel-Buffering", "no");
    return sseService.subscribe();
}

테스트 : nginx ingress controller가 있을 때

cd k8s
kubectl apply -f redis-deployment.yaml
kubectl apply -f redis-service.yaml

kubectl apply -f sse-example-deploy.yaml
kubectl apply -f sse-example-service.yaml
kubectl apply -f sse-example-ingress-sse.yaml
kubectl apply -f  sse-example-ingress-api.yaml
127.0.0.1       sse.ythwork.com
curl -N http://sse.ythwork.com/subscribe
curl -X POST http://sse.ythwork.com/notifications/broadcast \
  -H "Content-Type: application/json" \
  -d '{"message":"hello"}'

구현 상세

public class BroadcastMessage {

    private String eventName;
    private String message;
    private Long timestamp;
    private String senderInstanceId;
    // ...
}
@RestController
public class SseController {
    // ...
    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe() {
        return sseService.subscribe();
    }
}
@Service
public class SseService {
    // ...
    public SseEmitter subscribe() {
        // ...
        SseEmitter emitter = new SseEmitter(timeout);
        // ..
        try {
            emitter.send(SseEmitter.event()
                    .name("connect")
                    .data("connected: " + clientId));
        } catch (IOException | IllegalStateException ex) {
            // ...
        }
        return emitter;
    }
    // ...
}
@RestController
@RequestMapping("/notifications")
public class NotificationController {
    // ...
    @PostMapping("/broadcast")
    public String broadcast(@RequestBody NotificationRequest request) {
        BroadcastMessage message = new BroadcastMessage(
                "notification",
                request.getMessage(),
                System.currentTimeMillis(),
                "server-" + port
        );

        redisPubSubService.publish(message);

        return "published. local connected clients = " + sseService.count();
    }
    // ...
}
@Service
public class RedisPubSubService {
    // ...
    public void publish(BroadcastMessage message) {
        try {
            String payload = objectMapper.writeValueAsString(message);
            stringRedisTemplate.convertAndSend(channel, payload);
        } catch (JsonProcessingException e) {
            // ...
        }
    }
}
@Component
public class RedisSubscriber implements MessageListener {
    // ...
    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String body = new String(message.getBody());
            BroadcastMessage broadcastMessage =
                    objectMapper.readValue(body, BroadcastMessage.class);
            // ...
            sseService.broadcastLocal(
                    broadcastMessage.getEventName(),
                    broadcastMessage
            );
        } catch (Exception e) {
            // ...
        }
    }
}
@Service
public class SseService {
    // ...
    public void broadcastLocal(String eventName, Object data) {
        // ...
        repository.findAll().forEach((clientId, emitter) -> {
            try {
                emitter.send(SseEmitter.event()
                        .name(eventName)
                        .data(data));
            } catch (IOException | IllegalStateException ex) {
                // ...
            }
        });
        // ...
    }
}
tags: