목차
@EventListener로 온도 이벤트를 화면에 보내기
온도 센서가 값을 만들 때마다 여러 브라우저 화면을 갱신한다고 하자. 센서가 브라우저 연결 객체를 직접 알고 있으면 연결·종료 관리가 센서 코드에 섞인다. Spring의 ApplicationEventPublisher와 @EventListener를 쓰면 같은 애플리케이션 컨텍스트 안에서 센서가 이벤트를 발행하고 다른 빈이 받도록 분리할 수 있다.

여기서 구독자는 두 종류다. @EventListener 메서드는 Spring 애플리케이션 이벤트의 수신자이고, EventSource를 쓰는 브라우저는 HTTP Server-Sent Events(SSE) 연결의 수신자다. 두 계층을 혼동하면 “Spring 이벤트를 발행했으니 다른 서버의 브라우저도 받는다”는 잘못된 기대를 하게 된다. 기본 애플리케이션 이벤트는 메시지 브로커가 아니며, 기본 리스너는 발행 스레드에서 동기 실행된다.
flowchart LR A["온도 생성기"] --> B["ApplicationEventPublisher"] B --> C["@EventListener"] C --> D["연결된 SseEmitter 집합"] D --> E["SSE HTTP 응답"] E --> F["브라우저 EventSource"]
그림의 B → C는 JVM 안의 이벤트 전달이고 D → F는 HTTP 스트림이다. 이벤트를 DB에 저장하거나 재생하지 않으므로 연결 전에 발행된 온도를 나중에 접속한 브라우저가 자동으로 받지 않는다. 재접속 뒤 누락된 데이터를 복구하려면 이벤트 ID와 저장소를 추가해야 한다.
1. 센서가 이벤트를 발행하기
온도값 타입은 Java 17의 record로 단순하게 표현한다. 난수는 실제 측정이 아니라 화면 흐름을 확인하기 위한 모의 데이터다.
public record Temperature(double value) { }
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class TemperatureSensor {
private final ApplicationEventPublisher events;
public TemperatureSensor(ApplicationEventPublisher events) {
this.events = events;
}
@Scheduled(fixedDelay = 5_000)
public void sample() {
double value = 16 + ThreadLocalRandom.current().nextDouble(0, 10);
events.publishEvent(new Temperature(value));
}
}
@Scheduled를 활성화하려면 설정 클래스에 @EnableScheduling을 붙여야 한다. fixedDelay는 이전 실행이 끝난 뒤 다음 실행까지 기다리는 시간이다. 이전 글은 마이크로초 단위 난수 지연을 0~5초라고 설명하고 종료 시 스케줄러를 정리하지 않았다. 이 예제는 Spring이 관리하는 스케줄링을 사용하고 5초 단위로 제한한다. 실제 센서 입력이라면 실패와 단절, 오래된 값의 표시 방식을 따로 정한다.
2. MVC의 SseEmitter로 연결을 관리하기
아래는 Spring MVC 컨트롤러다. 패키지가 org.springframework.web.servlet...SseEmitter인 점에서 알 수 있듯 WebFlux의 Flux 응답 예제가 아니다. Spring MVC도 Servlet 비동기 응답으로 SSE를 보낼 수 있다.
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import org.springframework.context.event.EventListener;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
public class TemperatureController {
private final Set<SseEmitter> clients = new CopyOnWriteArraySet<>();
@GetMapping(value = "/temperature-stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter events() {
SseEmitter emitter = new SseEmitter(30_000L);
clients.add(emitter);
emitter.onCompletion(() -> clients.remove(emitter));
emitter.onTimeout(() -> clients.remove(emitter));
emitter.onError(error -> clients.remove(emitter));
return emitter;
}
@EventListener
public void onTemperature(Temperature value) {
for (SseEmitter emitter : clients) {
try {
emitter.send(SseEmitter.event()
.name("temperature")
.data(value.value()));
} catch (IOException | IllegalStateException ex) {
clients.remove(emitter);
}
}
}
}
CopyOnWriteArraySet은 연결의 추가·제거보다 온도 발행이 더 잦은 작은 데모에서 읽기 쉬운 선택이다. 연결 수가 많으면 쓰기 복사 비용과 각 브라우저로 보내는 시간이 커진다. 기본 동기 이벤트 전달이므로 느린 SSE 쓰기가 발행 흐름을 늦출 수 있다. @Async를 무턱대고 붙이면 순서·오류 처리·큐 상한 문제가 생기므로 별도의 작업 큐와 전송 정책을 설계해야 한다. 연결 종료나 타임아웃 콜백이 항상 즉시 온다는 가정도 피한다. 끊긴 클라이언트는 다음 쓰기나 하트비트에서 발견될 수 있다.
SseEmitter(30_000L)은 30초 타임아웃을 설정한다. 브라우저 EventSource는 끊기면 재연결을 시도한다. 운영에서는 프록시의 응답 버퍼링·유휴 타임아웃, 서버의 SSE 타임아웃, 주기적 하트비트를 함께 맞춘다. 한 인스턴스의 clients 집합은 다른 인스턴스와 공유되지 않는다.
3. 브라우저에서 안전하게 표시하기
<ul id="events"></ul>
<script>
const list = document.getElementById("events");
const stream = new EventSource("/temperature-stream");
stream.addEventListener("temperature", event => {
const value = Number(event.data);
if (!Number.isFinite(value)) return;
const item = document.createElement("li");
item.textContent = `Temperature: ${value.toFixed(2)} C`;
list.appendChild(item);
});
stream.addEventListener("error", () => {
// UI에는 재연결 중임을 알리고, 필요 시 연결을 명시적으로 닫는다.
});
window.addEventListener("pagehide", () => stream.close());
</script>
textContent를 사용해 값을 HTML로 해석하지 않는다. 이전 예제의 innerHTML은 이벤트 내용이 바뀌거나 신뢰할 수 없는 값이 들어오면 XSS 위험이 있다. 새 이벤트마다 항목을 영원히 추가하면 DOM 메모리가 커지므로 실제 화면에서는 최근 N개만 유지하거나 현재 값 하나를 갱신한다.

이 그림은 당시 화면 결과다. 위 새 예제를 이 대화에서 실행해 얻은 결과는 아니다.
실패와 확장 경계
| 상황 | 나타나는 현상 | 다음 선택 |
|---|---|---|
| 브라우저가 끊김 | 쓰기 실패 또는 타임아웃 | emitter 제거, 하트비트와 재연결 정책 |
| 클라이언트가 느림 | 발행 스레드 지연 | 전송 큐 상한·느린 연결 종료 |
| 서버 인스턴스가 여러 개 | 다른 인스턴스의 클라이언트는 이벤트를 못 받음 | 외부 브로커나 공유 이벤트 스트림 |
| 재연결 뒤 과거 온도 필요 | 이미 지난 이벤트가 없음 | 이벤트 ID, 저장소, 재생 범위 |
| 양방향 명령이 필요 | EventSource는 서버→클라이언트 방향 | WebSocket이나 별도 POST |
@EventListener는 애플리케이션 내부의 단순한 알림을 분리할 때 유용하다. 보장된 저장·재전송·여러 서버 간 전달이 필요하면 Kafka, RabbitMQ 같은 브로커와 소비자 정책을 검토한다. WebFlux로 옮길 때도 브로커 요구가 사라지는 것은 아니다. 먼저 필요한 전달 보장과 연결 수를 정한 뒤 MVC SSE, WebFlux 스트림, WebSocket 중 방식을 고른다.
