
클라이언트의 요청에 의해 서버와 클라이언트가 연결된 후
서버가 이벤트가 있을 때 마다 클라이언트에게 메세지를 보내는 실시간 Data 처리 방식
일반적으로 웹 페이지는 서버에 요청을 보내서 새로운 데이터를 수신해야 한다. 즉, 페이지가 서버에 데이터를 요청하는 것이다.
그러나 반대로 SSE(서버에서 보낸 이벤트)를 사용하면 서버가 웹 페이지에 메세지를 푸시해서 언제든지 웹 페이지에 새로운 데이터를 보낼 수 있다.
WebSocket과 달리 서버에서 보낸 이벤트는 단방향 연결로 서버에서 클라이언트로 이벤트를 보내는 것은 가능하지만, 반대의 경우에는 불가하다.
즉, 데이터 메시지는 서버에서 클라이언트(예: 사용자의 웹 브라우저)로 한 방향으로 전달된다. 따라서 클라이언트에서 서버로 메시지 형태로 데이터를 보낼 필요가 없는 경우에 좋은 선택이다.
소셜 미디어 상태 업데이트나 뉴스 피드와 같은 서비스, 실시간 채팅방이 아닌 전체 공지를 내려주는 채팅방 같은 서비스에서 사용할 수 있다.
서버가 클라이언트로 이벤트를 보낼 수 있도록 컨트롤러를 구현한다.
Spring Boot에서는 SseEmitter를 활용하여 간단하게 구현할 수 있다.
1. 엔드포인트 작성 (SseEmitter 생성)
/see 엔드포인트로 요청을 받으면 SseEmitter를 생성하고, 이벤트를 비동기로 전송한다.
이벤트는 1초간격으로 10회에 걸쳐 전송된다.
10회 전송이 완료되면 complete()로 서버 측 연결이 종료된다. 클라이언트 측에서는 이벤트가 더 이상 전달되지 않아 EventSource도 자동으로 닫히게 된다.
@CrossOrigin(origins = "http://localhost:8081")
@RestController
public class SseController {
@GetMapping("/sse")
public SseEmitter streamEvents() {
//SseEmitter emitter = new SseEmitter();
//클라이언트와의 연결 타임아웃 설정
//SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); //무제한 타임아웃
Long timeout = 60L * 1000 * 60; //1시간
SseEmitter emitter = new SseEmitter(timeout);
Executors.newSingleThreadExecutor().execute(() -> {
try {
for (int i = 1; i <= 10; i++) {
emitter.send(SseEmitter.event()
.name("customEvent")
.data("Message " + i));
Thread.sleep(1000); // 1초 간격으로 메시지 전송
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
클라이언트가 서버에서 이벤트를 수신할 수 있도록 구현한다.
1. EventSource 생성
Server-Sent Event API는 EventSource 인터페이스에 포함되어 있다. 이벤트를 받기 위해 EventSource 객체를 먼저 생성해주어야 한다.
2. 엔드포인트 연결
생성한 EventSource를 서버의 /sse 엔드포인트에 연결한다.
3. 이벤트 수신 로직 작성
customEvent 이벤트를 수신해서 메세지를 출력하고, 에러 발생시에는 연결을 종료한다.
4. 클라이언트 측에서 연결 종료 로직 작성
서버측에서 10회 전송이 완료되면 서버-클라이언트 연결을 끊도록 작성했지만, 클라이언트 측에서 그 전에 연결을 종료할 수도 있도록 버튼을 만들어 준다.
closeConnection 메서드에서 EventSource.close() 를 호출해서 연결을 수동으로 종료한다.
연결이 종료되면 콘솔에 종료메세지를 출력하고 eventSource객체를 null로 설정해서 객체 참조를 제거한다.
<template>
<div>
<button @click="toggleConnection">{{ buttonLabel }}</button>
<p v-for="(message, index) in messages" :key="index">
서버에서 보낸 메세지 : {{ message }}
</p>
</div>
</template>
<script>
export default {
data() {
return {
messages: [],
eventSource: null, // SSE 연결 객체
isConnected: true, // 연결 상태 여부
buttonLabel: "연결 종료",
};
},
mounted() {
this.eventSource = new EventSource("http://localhost:8080/sse");
this.eventSource.addEventListener("customEvent", (event) => {
this.messages.push(event.data);
});
this.eventSource.onerror = () => {
this.eventSource.close();
};
},
methods: {
startConnection() {
this.eventSource = new EventSource("http://localhost:8080/sse");
this.eventSource.addEventListener("customEvent", (event) => {
this.messages.push(event.data);
});
this.eventSource.onerror = () => {
this.eventSource.close();
};
this.isConnected = true;
this.buttonLabel = "연결 종료";
console.log("연결이 시작되었습니다.");
},
closeConnection() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
this.isConnected = false;
this.buttonLabel = "연결 시작";
console.log("연결이 종료되었습니다.");
}
},
toggleConnection() {
if (this.isConnected) {
this.closeConnection();
} else {
this.startConnection();
}
},
},
};
</script>
Exception in thread "pool-176-thread-1" java.lang.IllegalStateException: A non-container (application) thread attempted to use the AsyncContext after an error had occurred and the call to AsyncListener.onError()
테스트하다보면 서버로 사용하고 있는 스프링부트앱에서 이렇게 에러로그 간헐적으로 떨어짐
SSE 연결이 클라이언트 측에서 끊어진 후에도 서버에서 이벤트를 전송하려 할 때 발생하는 상황에 발생함.
Spring Boot의 SseEmitter는 비동기 응답을 관리하는데, 클라이언트가 연결을 끊으면 내부적으로 AsyncContext가 종료되며, 더 이상 데이터를 보낼 수 없게 된다. 그 상태에서 emitter.send()를 호출하면 IllegalStateException이 발생하게 된다.
package com.sse.sample;
import java.util.concurrent.Executors;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@CrossOrigin(origins = "http://localhost:8081")
@RestController
public class SseController {
@GetMapping("/sse")
public SseEmitter streamEvents() {
Long timeout = 60L * 1000 * 60; // 1시간 타임아웃
SseEmitter emitter = new SseEmitter(timeout);
// 연결 종료 시 로그 출력 및 리소스 정리
emitter.onCompletion(() -> System.out.println("연결이 완료되었습니다."));
emitter.onTimeout(() -> System.out.println("연결이 타임아웃되었습니다."));
emitter.onError((e) -> System.out.println("연결 에러: " + e.getMessage()));
Executors.newSingleThreadExecutor().execute(() -> {
try {
for (int i = 1; i <= 10; i++) {
// 연결이 끊어졌으면 반복 종료
if (emitter.isDisposed()) {
System.out.println("클라이언트가 연결을 끊었습니다.");
break;
}
emitter.send(SseEmitter.event()
.name("customEvent")
.data("Message " + i));
Thread.sleep(1000); // 1초 간격으로 메시지 전송
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
스레드 풀 관리
현재 코드는 Executors.newSingleThreadExecutor()로 매 요청마다 새로운 스레드를 생성하므로, 스레드 풀을 관리하는 방식으로 개선할 수 있다.
// 클래스 레벨에 스레드 풀 추가
private final ExecutorService executorService = Executors.newCachedThreadPool();
// 메서드 내부에서 사용
executorService.execute(() -> {
// 이벤트 전송 로직
});
이렇게 하면 매번 새로운 스레드를 생성하지 않고 기존 스레드를 재사용할 수 있다.
참고
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events