Java/Java

실시간 데이터 전송 방식 1) SSE(Server-Sent Events)를 Java와 Javascript로 구현하기

Gamii 2025. 4. 21. 17:26
728x90

[목차]

0. SSE를 도입하게 된 이유
1. SSE 기본 개념
2. 장점/단점
3. 전체 흐름 요약 (지금 작성한 부분)
4. SSE Q&A

 

 

0. SSE로 구현하게 된 이유

 

 

대시보드에 실시간으로 데이터를 표시해야 하는 기능을 구현할 필요가 있었다.
처음에는 REST API polling 방식(주기적으로 HTTP 요청 보내서 데이터 받아오기)을 고려했지만,

데이터 전송 주기에 따라 딜레이가 발생하고, 서버와 클라이언트 모두에 불필요한 부하가 생길 수 있었다.

 

 

"완전히 실시간에 가깝게 데이터를 보여주고 싶다."
"그렇지만 복잡한 양방향 소켓 통신까지는 필요 없다."

 

 

이런 요구를 충족시킬 수 있는 다른 방식을 찾게 되었다.

 

 

이 조건을 충족하는 기술로 SSE(Server-Sent Events)를 선택했다.

 

  • HTTP 프로토콜 기반으로 추가 설정이 간단하고
  • 서버에서 클라이언트로 단방향으로 지속적으로 이벤트를 push할 수 있어
  • polling보다 효율적이고, WebSocket보다는 가볍다

 

따라서 이번 프로젝트에서는 SSE를 사용하여 실시간 데이터 전송 기능을 구현하게 되었다.

 

 

 

1. SSE란?

 

SSE(Server-Sent Events)

  • 서버가 클라이언트(브라우저)로 단방향(push) 데이터 스트림을 지속적으로 보내는 기술이다.
  • HTTP/1.1 연결을 유지하면서, 서버가 클라이언트로 실시간 데이터를 보낼 수 있다.

 

 

 

2. SSE의 특징과 장점/단점


프로토콜 HTTP 기반 (추가 프로토콜 필요 없음)
방향성 서버 → 클라이언트 (단방향)
연결 수 클라이언트당 1개의 HTTP 연결 유지
자동 재연결 브라우저 EventSource가 기본적으로 지원
데이터 포맷 text/event-stream 형식 (단순 텍스트)

✨ 장점

  • HTTP 기반이라 별도 소켓 프로토콜(TCP, WebSocket 등) 설정 없이 바로 사용 가능
  • 브라우저 EventSource 표준 지원 (대부분 크롬, 사파리, 파이어폭스 OK)
  • 서버가 끊겼을 때 자동 재연결 가능 (retry)
  • WebSocket에 비해 구현이 단순

✋ 단점

  • 단방향 통신만 가능 (클라이언트 ➡️ 서버 전송은 불가, 추가 API 필요)
  • 대규모 접속자 처리 시 서버 리소스 부담 (TCP 연결 유지)
  • 오래된 브라우저(특히 IE) 지원 불가
  • 서버가 죽은 클라이언트를 바로 인지 못한다 (heartbeat 필요)

 

 

 

3. SSE의 전체 흐름 요약

 

 

전체 코드는 아래 깃허브 참조

 

👉 SSE 서버(Spring Boot) 코드 바로 보기

👉 SSE 클라이언트(Javascript) 코드 바로 보기

 

 

1) 클라이언트에서 SSE 연결 요청 (EventSource 생성)

 

클라이언트는 서버에 SSE 연결을 시도한다.

 

const eventSource = new EventSource('/sse/subscribe.do');

eventSource.addEventListener('weldData', function(event) {
    const data = JSON.parse(event.data);
    console.log('Received weldData:', data);
});

 

 

 

먼저 /sse/subscribe.do로 GET 요청을 보내면서 서버 연결한다.

 

서버는 이 요청을 받아서 연결을 유지하게 된다.

 

 

 

 

 

2) 서버에서 SseEmitter 생성 및 반환

 

서버는 클라이언트 요청을 받으면 SseEmitter를 생성하고 반환한다.

 

private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private final long TIMEOUT = 60 * 1000;

@GetMapping("/sse/subscribe.do")
public SseEmitter subscribe() {
    SseEmitter emitter = new SseEmitter(TIMEOUT);

    emitter.onCompletion(() -> emitters.remove(emitter));
    
    emitter.onTimeout(emitter::complete);
    
    emitter.onError((e) -> {
        emitter.complete();
    });

    emitters.add(emitter);

    return emitter;
}

 

 

SseEmitter 객체를 생성하여 클라이언트 연결을 유지하고,

 

timeout, completion 발생 시 emitter를 제거하여 리소스 누수 방지한다.

 

 

 

 

3) 서버에서 데이터 발생 시 연결된 emitter로 일괄 전송

 

서버에서 새로운 데이터가 생기면, 연결된 모든 emitter로 데이터를 push한다.

 

public void sendToClients(Map<String, Object> data) {
    String jsonData;
    try {
        jsonData = objectMapper.writeValueAsString(data);
    } catch (JsonProcessingException e) {
        log.error("Failed to serialize data", e);
        return;
    }

    for (SseEmitter emitter : emitters) {
        try {
            emitter.send(SseEmitter.event()
                .name("customData")
                .data(jsonData)
                .reconnectTime(5000)); // 재연결 시간 5초 지정
        } catch (Exception e) {
            emitter.complete();
        }
    }
}

 

 

 

객체를 JSON 문자열로 변환하고 모든 연결된 emitter에게 .send()로 이벤트 전송한다.

 

send 실패 시 emitter를 제거하여 정리한다.

 

 

 

 

 

 

4) 클라이언트는 서버가 보내는 이벤트를 수신

 

클라이언트는 등록한 addEventListener를 통해 이벤트를 수신하고 처리한다.

 

eventSource.addEventListener('customData', function(event) {
    const data = JSON.parse(event.data);
    updateDashboard(data); // 예시: 대시보드 업데이트
});

 

 

 

 

서버가 보내는 customData 이벤트를 수신하고,

 

받아온 데이터로 화면을 갱신하거나 추가 로직 실행한다.

 

 

 

 

 

3. SSE 질문 및 답변

 

 

Q1. 서버 timeout이 지나면 SSE 연결은 끊긴다. 서버와 연결을 어떻게 유지해야 할까?

 

A. 서버와 연결을 유지하기 위한 방법은 다음 두 가지가 있다

 

 

1. timeout을 길게 설정

 

클라이언트가 응답하지 않아도 서버는 연결을 유지함.

 

단점: 끊긴 클라이언트를 서버가 즉시 감지하지 못함.

 

 

2. 클라이언트에 heartbeat 이벤트를 주기적으로 전송

 

서버가 주기적으로 ping을 보내면서, 끊긴 emitter를 빠르게 감지하고 정리 가능.

 

실시간성과 메모리 관리를 고려하여 heartbeat 방식을 선택했다.

 

 

💡 예시 코드

@Scheduled(fixedRate = HEARTBEAT_RATE)
public void heartbeat(){

    if(emitters.isEmpty()) return;

    for (SseEmitter emitter : emitters) {
        try {
            emitter.send(SseEmitter.event().comment("ping").reconnectTime(5000));
        } catch (Exception e) {
            logger.error("[heartbeat] failed to send data to emitter --> {}", e.getMessage());
            emitters.remove(emitter);
        }
    }
}

 

 

 

 

Q2. SSE 연결이 끊겼을 때, 클라이언트와 서버 각각 어떻게 재연결 처리를 하는가?

 

A.

 

  • 클라이언트 → 서버:
    • 브라우저 EventSource는 연결이 끊어지면 자동으로 HTTP 재연결을 시도한다.
    • 특별한 설정 없이도 연결이 끊기면 일정 시간 후 재요청을 보낸다.
  • 서버 → 클라이언트:
    • 서버가 연결을 주도해서 재연결을 요청할 수는 없다.
    • 대신 서버는 retry:값을 내려 재연결 대기 시간을 지정할 수 있다. (reconnectionTime 사용)

 

emitter.send(SseEmitter.event()
        .name("heartbeat")
        .data("ping")
        .reconnectTime(5000)); // 5초 후 재연결 지시

 

 

 

 

Q3. SseEmitter의 onComplete()은 어떤 의미인가?

 

A. onCompletion()은 모든 종료 상황에서 호출된다.

  • 브라우저 종료
  • 네트워크 단절
  • timeout 발생
  • 서버에서 emitter.complete() 호출 등

→ emitter가 완전히 종료될 때 실행되는 콜백이다. (즉, onTimeout()보다 더 포괄적인 종료 이벤트 처리기)

 

 

 

 

Q4. 화면 새로고침(F5)하면 emitter가 여러 개 등록된다. 어떻게 해결해야 할까?

 

A.

 

1) 끊긴 emitter 정리 (cleanDeadEmitters)

 

subscribe() 호출 시 이 메서드를 먼저 실행하면, 이전에 끊긴 emitter를 정리한 뒤 새 emitter만 추가할 수 있다.

 

 

private void cleanDeadEmitters() {
    emitters.removeIf(emitter -> {
        try {
            emitter.send(SseEmitter.event().comment("ping"));
            return false; // 살아있음
        } catch (Exception e) {
            return true; // 죽음
        }
    });
}

 

 

2) timeout을 짧게 설정

일정 시간 후 연결을 자동으로 끊고 클라이언트는 EventSource 자동 재연결로 다시 subscribe한다.

 

 

new SseEmitter(60000); // timeout 60초

 

 

 

 

이슈) 

 

 

heartbeat를 통해 끊긴 emitter를 감지하려 했지만, TCP 연결 종료가 OS에서 바로 감지되지 않았다.

 

그래서 emitter.send()가 예외를 발생시키지 않았다.

 

 

두번째 방법인 timeout을 짧게 설정하여 자연스럽게 emitter를 끊고 정리하는 방법을 선택했다.

 

 

 

 

 

Q5. 다수의 emitter를 안전하게 관리하려면 어떤 문제를 고려하고, 어떤 방법을 써야 하는가?

 

문제

 

1) 다수의 클라이언트가 동시에 SSE 연결을 요청하거나 끊길 경우

2) 단순한 List 사용 시 ConcurrentModificationException 발생 가능

 

 

 

해결 방법

 

Thread-safe한 자료구조를 사용

CopyOnWriteArrayList 읽기(read)가 많고 쓰기(write)가 적은 경우 적합. 각 emitter를 안전하게 관리할 수 있다.
ConcurrentHashMap emitter를 사용자 ID 등 key 기반으로 관리할 때 유리

 

 

 

 

 

 

 

 

 

 

 

이번 글에서는 실시간 데이터 전송 방식 중 하나인 SSE(Server-Sent Events)의 기본 개념부터,


실무에서 직접 겪었던 문제들과 해결 방법까지 정리해보았다.

 

 

SSE는 간단한 실시간 데이터 전송이 필요한 경우 매우 유용한 기술이지만,


안정성을 확보하기 위해서는 timeout 관리, heartbeat 설계, emitter 정리 같은 세심한 관리가 필요하다.

 

 

 

다음 글에서는 Polling, WebSocket 등 다른 실시간 통신 방식과의 비교를 통해


상황에 따라 어떤 방식을 선택해야 하는지 정리해볼 예정이다.