Spring

SseEmitter

Mecodata 2024. 4. 29. 11:10

정의

- org.springframework.web.servlet.mvc.method.annotation.SseEmitter

- Spring에서 SSE(Server-Sent Event)를 구현하기 위한 클래스

 

특징

- 클라이언트로 텍스트 기반의 이벤트 전송 (String이 아닌 데이터들 toString() 필수)

- 클라이언트와의 연결 유지, 데이터 전송, 연결 종료 기능을 제공

- 실시간 업데이트 혹은 클라이언트에서 서버로부터 비동기적으로 데이터 수신 시에 주로 사용

 

주요 메소드

- send() = 클라이언트로 이벤트 전송

- complete() = SSE 연결 종료

- completeWithError() = SSE 연결을 오류 상태로 종료

- onCompletion() = SSE 연결이 종료될 때 수행할 작업 정의

- onError() = SSE 연결에서 오류가 발생했을 때 수행할 작업 정의

- onTimeout() = SSE 연결이 클라이언트로부터 요청을 받은 후 일정 시간 동안 반응이 없을 경우에 대한 처리

- extendResponse() = SSE 응답 확장 (사용자 정의 헤더 추가, 상태 코드 수정 등)

 

기본 세팅

SseApplication

@SpringBootApplication
@EnableScheduling // @Scheduled 사용을 위해 적용
public class SseApplication {

	public static void main(String[] args) {
		SpringApplication.run(SseApplication.class, args);
	}

}

SseController

@RestController
@RequestMapping("/api")
public class SSEController {
	
    @Autowired
    private SseService sseService;
    
    // SSE는 텍스트 기반 이벤트를 전송하기 때문에 TEXT_EVENT_STREAM_VALUE 적용
    @GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter handleEvents() {
    	
        // SseEmitter 객체 생성 시 Long 데이터를 입력하면 timeout(연결 종료 시간)을 지정할 수 있음
    	// SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
        SseEmitter emitter = new SseEmitter();
        
	sseService.addEmitter(emitter);
        sseService.sendEvents();
        return emitter;
    
}

SseService

@Service
@Slf4j 
public class SseService {
    
    // 주로 순회가 일어나는 용도로 사용할 때는 안전한 스레드 처리를 위해 CopyOnWriteArrayList를 사용
    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    public void addEmitter(SseEmitter emitter) {

        emitters.add(emitter); // 리스트에 SseEmitter 추가
        
        emitter.onCompletion(() -> {  
            log.info("onCompletion callback");  
            emitters.remove(emitter);  // SSE 연결 종료 시 리스트에서 삭제
        });  
        
        emitter.onTimeout(() -> {  
            log.info("onTimeout callback");  
            emitter.complete();  // 지정 시간 경과 시 SSE 연결 종료
        });  
    }

    // 2초마다 해당 메소드가 자동으로 실행되도록 지정 (fixedRate 단위=밀리초)
    @Scheduled(fixedRate = 2000)
    public void sendEvents() {
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send("Hello, World!" + count); // 데이터 전송
            } catch (IOException e) {
                emitters.remove(emitter); 
                emitter.complete(); // 에러 발생 시 리스트에서 SseEmitter 제거 및 SSE 연결 종료
            }
        }
    }
}

Client 예시 (Vue.js)

<template>
  <div>
    <div v-if="mainBoardList.length > 0">
      <button @click="openModal">Open Modal</button>
    </div>
    <div v-else>
      No data received yet.
    </div>
    <button @click="reqApi">Request API</button>
    <button @click="stopApi">Stop API</button>

    <!-- 모달 창 -->
    <div class="modal" :class="{ 'is-active': showModal }">
      <div class="modal-background" @click="closeModal"></div>
      <div class="modal-content">
        <div>
          <ul class="list-group" style="max-height: 300px; overflow-y: auto;">
            <li class="list-group-item" v-for="(item, index) in mainBoardList" :key="index">
              {{ item }}
            </li>
          </ul>
        </div>
        <button @click="closeModal">Close Modal</button>
      </div>
    </div>
    <div>
      <label for="eventSourceUrl">EventSource URL:</label>
      <input type="text" id="eventSourceUrl" v-model="eventSourceUrl">
      <button @click="applyEventSource">Apply</button>
    </div>

    <!-- 요청 시작 및 종료 시간 -->
    <div v-if="startTime">
      Request started at: {{ startTime }}
    </div>
    <div v-if="stopTime">
      Request stopped at: {{ stopTime }}
    </div>

    <!-- Count 입력 -->
    <div>
      <label for="count">Count:</label>
      <input type="number" id="count" v-model="count">
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      mainBoardList: [],
      showModal: false, // 모달 창을 열고 닫기 위한 상태 변수
      eventSourceUrl: 'http://172.16.100.179:8009/api/emitter', // 기본 URL 설정
      eventSource: null, // EventSource 객체
      startTime: null, // 요청 시작 시간
      stopTime: null, // 요청 종료 시간
      count: 0, // 받을 데이터 개수
    };
  },
  methods: {
    reqApi() {
      if (!this.eventSourceUrl) {
        console.error('EventSource URL is not provided.');
        return;
      }

      console.log("Sending request to server");

      // 기존의 EventSource가 있다면 종료
      if (this.eventSource) {
        this.eventSource.close();
      }

      // EventSource에 http 포함 SSE 통신할 URL 입력
      this.eventSource = new EventSource(this.eventSourceUrl);

      // 요청 시작 시간 기록
      this.startTime = new Date().toLocaleTimeString();

      let receivedCount = 0; // 받은 데이터 개수 초기화

      this.eventSource.onmessage = (event) => {
        console.log("Received data from server:", event.data);
        // Process the received data
        this.mainBoardList.push(event.data);
        
        // 받은 데이터 개수 증가
        receivedCount++;

        // 받은 데이터 개수가 Count와 같으면 SSE 통신 종료
        if (receivedCount >= this.count) {
          this.eventSource.close();
          // 요청 종료 시간 기록
          this.stopTime = new Date().toLocaleTimeString();
        }
      };

      this.eventSource.onerror = (error) => {
        console.error('EventSource failed:', error);
        // Handle errors
        this.eventSource.close();
      };
    },
    stopApi() {
      if (this.eventSource) {
        this.eventSource.close();
        this.eventSource = null; // EventSource 객체 초기화

        // 요청 종료 시간 기록
        this.stopTime = new Date().toLocaleTimeString();
      }
    },
    openModal() {
      this.showModal = true; // 모달 창 열기
    },
    closeModal() {
      this.showModal = false; // 모달 창 닫기
    },
    applyEventSource() {
      this.reqApi(); // 설정된 EventSource를 적용
    },
  },
};
</script>

<style>
.modal {
  display: none;
}

.modal.is-active {
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal-background {
  position: fixed;
  top: 0;
  left: 0;
  width: 150%;
  height: 10%;
}

.modal-content {
  background-color: white;
  padding: 20px;
  border-radius: 5px;
  max-width: 80%;
  max-height: 80%;
  overflow: auto;
}

.list-group {
  margin: 0;
  padding: 0;
  list-style: none;
}

.list-group-item {
  padding: 10px;
}
</style>