기계는 거짓말하지 않는다

Spring Boot Server-Sent Events(SSE) 실시간 이벤트 본문

Web/Spring Boot

Spring Boot Server-Sent Events(SSE) 실시간 이벤트

KillinTime 2023. 10. 29. 20:02

서버에서 클라이언트로 단방향 통신이 필요할 때가 있다.

일반적으로 풀링 방식을 사용하게 되면 비효율적일 수 있는데,

실시간 알람, 이벤트 등을 위해 Server-Sent Events(SSE)를 사용할 수 있다.

 

주의점은 SSE에서 DB 관련 작업을 하지 않아야 하고,
타임아웃 또는 complete 후 재 연결 시 데이터 유실이 가능하다.

Controller

connect 후 emitter를 반환해야 한다.

또한 connect 후 더미 데이터를 한 번 전송해야 503 에러를 방지할 수 있다.
connect 외에 함수에서 반환할 경우 타임아웃까지 계속해서 대기 상태가 된다.

connect의 id는 고유해야 한다. id 값으로 중복 요청을 방지할 수 있다.

아래 코드는 샘플이며, 재 연결 유실 처리, 중복 요청 처리가 되어있지 않다.

package com.temp.springboottemp;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/sse")
public class SSEController {
    // thread safe
    private final ConcurrentHashMap<String, SseEmitter> clients = new ConcurrentHashMap<>();

    @GetMapping("/connect")
    public SseEmitter connect(@RequestParam("id") String id) {
        SseEmitter emitter = new SseEmitter(180000L);    // millisecond 단위, connection 180초 유지 후 재연결 시도
        try {
            // 저장된 클라이언트에 연결을 추가
            clients.put(id, emitter);
            
            // 더미 데이터 전송. 503 에러 방지
            emitter.send(SseEmitter.event()  
                    .name("connect")  
                    .data("connected! emitter: " + id.toString()));

            // SSE 연결 타임아웃 및 완료 핸들링
            emitter.onTimeout(() -> {
                clients.remove(id);
                emitter.complete();
            });

            emitter.onCompletion(() -> clients.remove(id));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return emitter;
    }

    @GetMapping(value = "/send-json", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public void sendJson(@RequestBody String jsonString) {
        Map<String, Object> jsonMap = null;
        SseEmitter emitter = null;
        try {
            if (jsonString != null) {
                jsonMap = GeneralFunction.parseJson(jsonString);
                System.out.println(jsonMap);

                if (jsonMap != null)
                {
                    emitter = clients.get(jsonMap.get("id").toString());
                }
            }

            if (emitter != null) {

                emitter.send(SseEmitter.event()
                        .name("send-json")
                        .data(jsonMap));
            }
        }
        catch (Exception e) {
            e.printStackTrace();
        }

    }
}

JavaScript

EventSource를 사용한다. EventSource 선언 시 URL과 Controller의 요청 URL이 일치해야 한다.

그리고 Controller emitter send event의 name과 addEventListener 선언 시 이름도 일치해야 한다.

// id는 가변적으로 요청
const sse = new EventSource("/sse/connect?id=1", headers = {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache',
});

sse.addEventListener('connect', (e) => {
    const { data: receivedConnectData } = e;
    console.log('connect event data: ', receivedConnectData);  // "connected!"

});

sse.addEventListener('send-json', (e) => {
    const { data: receivedConnectData } = e;
    json_data = JSON.parse(receivedConnectData)
    console.log('connect json event data: ', json_data);  // json data

    // TextareaElement.value += JSON.stringify(json_data);
    // TextareaElement.scrollTop = TextareaElement.scrollHeight;
});

Controller에서 사용한 General Function

package com.temp.springboottemp;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Map;

public class GeneralFunction {
    public static Map<String, Object> parseJson(String jsonString) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            TypeReference<Map<String, Object>> typeReference = new TypeReference<Map<String, Object>>() {};
            return objectMapper.readValue(jsonString, typeReference);
        }
        catch (Exception e) {
            return null;
        }
    }
}
Comments