Changsoon's Note Backend Developer

SSE, Polling, WebSocket 비교와 선택

이번에 장애 대응 프로세스를 구축하면서 Docker API에서 받은 데이터를 클라이언트로 실시간 전송하는 코드를 구현하게 되었다.

클라이언트로 데이터를 전송하는 기술 중 SSE, Polling, WebSocket 중 어떤 것을 사용하는 것이 적절할지 비교해보았다.

SSE란?

SSE(Server Sent Events)는 서버가 클라이언트에 실시간으로 이벤트를 푸시할 수 있는 기술이다.

클라이언트는 EventSource 객체를 사용해서 서버로부터 실시간으로 데이터를 받는다.

HTTP 프로토콜을 기반으로 하며, 서버에서 클라이언트로만 데이터가 흐른다.

장단점

장점

  • 단방향 실시간 통신이 적합하다. (서버 → 클라이언트)
  • 연결이 유지되므로 서버에서 데이터를 실시간으로 푸시할 수 있다.

단점

  • 양방향 통신을 지원하지 않는다.
  • 일부 오래된 브라우저에서 지원되지 않는다.

예제 코드

스프링 프레임워크에서 제공하는 SseEmitter 클래스를 사용해서 SSE를 구현한다.

SseEmitter 클래스는 클라이언트가 EventSource 객체를 통해 연결을 유지하고 있는 동안, 클라이언트에게 실시간으로 데이터를 전송한다.

서버가 이벤트를 푸시할 때, 클라이언트와의 연결을 블로킹하지 않고 비동기적으로 데이터를 보낼 수 있다.

주요 메서드

  • send() : 클라이언트에게 이벤트 데이터 전송
  • complete() : 이벤트 스트리밍이 끝났음을 알림
  • completeWithError() : 오류가 발생했을 때 스트리밍을 종료
@RestController
class SSEController {

    @GetMapping("/sse")
    fun loadSSEServer(): SseEmitter {
        val emitter = SseEmitter()

        val executorService = Executors.newSingleThreadExecutor()

        executorService.submit {
            try {
                for (i in 0..10) {
                    val data = SseEmitter.event().name("message").data("Data $i")
                    emitter.send(data)
                    Thread.sleep(1000)
                }
            } catch (e: Exception) {
                emitter.completeWithError(e)
            }
        }
        return emitter
    }
}

...

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>
<body>
<h1>Server-Sent Events</h1>
<ul id="messages"></ul>

<script>
    const eventSource = new EventSource('/sse');
    eventSource.onmessage = function(event) {
        const li = document.createElement("li");
        li.textContent = event.data;
        document.getElementById('messages').appendChild(li);
    };
</script>
</body>
</html>

alt text

1 ~ 10까지의 데이터를 서버에서 클라이언트로 푸시한다.

Polling

Polling은 클라이언트가 주기적으로 서버에 요청을 보내서 데이터를 받는 방식이다.

클라이언트는 일정한 시간 간격으로 서버에 HTTP 요청을 보내고, 서버는 새로운 데이터가 있을 경우 응답한다.

장단점

장점

  • 구현 및 설정이 간단하다.
  • 기존의 HTTP 프로토콜을 그대로 사용한다.

단점

  • 네트워크 트래픽이 많고 서버 자원을 낭비할 수 있다.
  • 실시간성을 제공하기 어렵고 지연이 발생할 수 있다.

예제 코드

@RestController
class PollingController {

    private var count: Int = 0

    @GetMapping("/polling")
    fun loadPollingServer(): Int {
        val result = count++
        return result
    }
}

...

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Polling Example</title>
</head>
<body>
<h1>Polling</h1>
<ul id="messages"></ul>

<script>
    function startPolling() {
        setInterval(() => {
            fetch("/polling")
                .then(response => response.json())
                .then(data => {
                    const li = document.createElement("li");
                    li.textContent = 'Data ' + data;
                    document.getElementById('messages').appendChild(li);
                })
                .catch(error => console.error('Error:', error));
        }, 1000); // 1초마다 요청
    }
    startPolling();
</script>
</body>
</html>

alt text

WebSocket

WebSocket은 양방향 통신을 지원하는 프로토콜로, 클라이언트와 서버 간에 지속적인 연결을 유지하며 실시간 데이터 송수신이 가능하다.

초기 핸드쉐이크는 HTTP를 사용하고, 그 이후에는 WebSocket 프로토콜을 통해 양방향 통신을 수행한다.

장단점

장점

  • 양방향 통신을 지원하므로 클라이언트와 서버 간에 실시간으로 데이터를 주고받을 수 있다.
  • 서버와 클라이언트가 연결을 유지하며 데이터 전송이 이루어지므로 매우 효율적이다.

단점

  • 클라이언트와 서버 모두 WebSocket을 지원해야 한다.
  • 비교적 구현이 복잡하다.

예제 코드

@Configuration
@EnableWebSocket
class WebSocketConfig : WebSocketConfigurer {
    override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
        registry.addHandler(WebSocketHandlerImpl(), "/ws")
            .setAllowedOrigins("*")
    }
}

class WebSocketHandlerImpl : TextWebSocketHandler() {

    override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
        if (message.payload == "start") {
            sendNumbers(session)
        }
    }

    private fun sendNumbers(session: WebSocketSession) {
        Executors.newSingleThreadExecutor().submit {
            try {
                for (i in 1..10) {
                    val responseMessage = "Data: $i"
                    session.sendMessage(TextMessage(responseMessage))
                    Thread.sleep(1000)
                }
            } catch (e: InterruptedException) {
                e.printStackTrace()
            }
        }
    }
    override fun afterConnectionClosed(session: WebSocketSession, closeStatus: org.springframework.web.socket.CloseStatus) {
        println("WebSocket connection closed: ${session.id}")
    }
}

...

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Example</title>
</head>
<body>
<h1>WebSocket</h1>
<button onclick="startSending()">Start</button>
<div id="messages"></div>

<script>
    const socket = new WebSocket('ws://localhost:8080/ws');

    socket.onopen = function() {
        console.log("WebSocket connected!");
    };

    socket.onmessage = function(event) {
        console.log("Received message: " + event.data);
        const messagesDiv = document.getElementById("messages");
        const newMessage = document.createElement("p");
        newMessage.textContent = event.data;
        messagesDiv.appendChild(newMessage);
    };

    socket.onclose = function() {
        console.log("WebSocket connection closed");
    };

    socket.onerror = function(error) {
        console.log("WebSocket error: " + error);
    };

    // 클라이언트가 "Start Sending Numbers" 버튼을 클릭하면 서버에 "start" 메시지를 보냄
    function startSending() {
        if (socket.readyState === WebSocket.OPEN) {
            socket.send("start");
        }
    }
</script>
</body>
</html>

alt text

어떤 것을 선택?

결론부터 말하자면 SSE를 사용하는 것이 적절하다고 생각이 되었다.

Docker API를 통해 클라이언트로 데이터를 전송만 하고 클라이언트에서 서버로 데이터를 주진 않으니 단방향 통신이 필요했다.

(WebSocket은 양방향 통신이 필요할 때 사용되니 이 과정에서 제외)

그리고 Docker에서 어떠한 이벤트가 발생했을 때 바로 이벤트가 수신되어야, 빠른 장애 대응이 가능하기 때문에 실시간성도 필요했다.

(WebSocket은 실시간성이 보장되나, 양방향 통신이 필요 없고, Polling은 실시간성이 보장되지 않기 때문에 이 과정에서 제외)

또한, 이벤트가 발생했을 때만 데이터를 전송하는 것이 효율적이므로, SSE가 제일 적절하다고 생각이 되었다.