Spring boot/Spring boot websocket

Spring Boot SSE(Server-Sent Events) 채팅 시스템

whs5758 2025. 9. 4. 11:51

학습 목표

  • SSE(Server-Sent Events) 통신 방식의 개념과 동작 원리 이해
  • 폴링의 한계를 극복하는 서버 푸시(Server Push) 방식 구현
  • 스프링 부트의 SseEmitter를 활용한 실시간 통신 구현
  • ConcurrentHashMap의 멀티스레드 환경에서의 필요성 이해
  • JavaScript EventSource API의 동작 원리와 이벤트 처리 방식 학습
  • 실시간 통신의 진화 과정 이해 (폴링 → SSE → WebSocket 로드맵의 두 번째 단계)

사전 기반 지식

  • 1단계 폴링 시스템 완전 이해 (MVC 패턴, JPA, Mustache)
  • HTTP 통신의 한계: 클라이언트가 먼저 요청해야만 응답 가능
  • JavaScript 기초: EventSource API와 이벤트 처리
  • 멀티스레드 개념: 동시성과 스레드 안전성에 대한 기본 이해

핵심 개념 이해

1. SSE(Server-Sent Events)란?

SSE는 서버가 클라이언트에게 실시간으로 데이터를 푸시할 수 있는 HTML5 표준 기술입니다.

1. 폴링 vs SSE 비교

폴링 방식 (1단계)

클라이언트 ──[요청]──> 서버
클라이언트 <──[응답]── 서버 (연결 종료)
     (2초 대기)
클라이언트 ──[요청]──> 서버 (반복)
클라이언트 <──[응답]── 서버 (연결 종료)

문제점: 불필요한 요청, 서버 부하, 지연 발생

 

SSE 방식 (2단계)

클라이언트 ──[연결 요청]──> 서버
클라이언트 <══════════════ 서버 (연결 유지!)

클라이언트 <──[새 메시지]── 서버 (즉시 푸시!)
클라이언트 <──[새 메시지]── 서버 (즉시 푸시!)

장점: 즉시 전송, 서버 부하 감소, 효율적

2. 폴링 vs SSE 비교

구분  폴링(Polling) SSE(Server-Sent Events)
통신 방향 클라이언트 → 서버 (요청/응답) 서버 → 클라이언트 (푸시)
연결 방식 매번 새로운 HTTP 연결 하나의 지속적인 HTTP 연결
실시간성 폴링 주기에 따른 지연 즉시 전달 가능
서버 부하 높음 (불필요한 요청) 낮음 (필요시에만 전송)
네트워크 효율 비효율적 효율적
구현 복잡도 간단 중간

 

3. SseEmitter 핵심 개념

SseEmitter는 스프링 부트에서 제공하는 SSE 구현체로, 서버와 클라이언트 간의 지속적인 연결을 관리합니다.

주요 특징:

  • 비동기 처리: 여러 클라이언트와 동시에 연결을 유지할 수 있습니다
  • 타임아웃 관리: 연결 유지 시간을 설정할 수 있습니다
  • 이벤트 기반: 특정 이벤트명과 함께 데이터를 전송할 수 있습니다
  • 자동 정리: 연결이 끊어지면 자동으로 리소스를 해제합니다

Spring의 SseEmitter는 비동기 요청 처리를 위해 설계된 객체입니다. 일반적인 요청-응답 모델과 달리, 서버는 클라이언트 요청에 즉시 응답하지 않고 SseEmitter 객체를 반환하여 연결을 열어둡니다. 이후, 새로운 데이터가 발생했을 때만 연결된 스레드를 사용해 데이터를 전송함으로써, 서버 자원을 효율적으로 관리할 수 있습니다.

 

생명주기 관리:

SseEmitter emitter = new SseEmitter(timeout);
emitter.onCompletion(() -> { /* 정상 완료 시 실행 */ });
emitter.onTimeout(() -> { /* 타임아웃 시 실행 */ });
emitter.onError((ex) -> { /* 에러 발생 시 실행 */ });

 

SSE 동작 원리

1. 클라이언트가 EventSource로 SSE 연결 요청
2. 서버가 SseEmitter 객체 생성하여 연결 유지
3. 새로운 메시지 발생 시 서버가 모든 연결된 클라이언트에게 푸시
4. 클라이언트가 실시간으로 메시지 수신 및 화면 업데이트

 

4. ConcurrentHashMap의 필요성

왜 일반 HashMap이 아닌 ConcurrentHashMap을 사용해야 할까요?

 

멀티스레드 환경의 문제점

// 위험한 코드 (HashMap 사용)
Map<String, SseEmitter> emitters = new HashMap<>();

// 스레드 A: 새로운 연결 추가
emitters.put("client1", emitter1);

// 스레드 B: 기존 연결 제거 (동시 실행)
emitters.remove("client2");

// 결과: 데이터 손상, 무한루프, 예외 발생 가능

 

ConcurrentHashMap의 해결책

  • 스레드 안전성: 여러 스레드가 동시에 접근해도 안전합니다
  • 세그먼트 잠금: 전체 맵을 잠그지 않고 부분적으로만 잠급니다
  • 높은 성능: 읽기 작업은 대부분 잠금 없이 수행됩니다
// 안전한 코드 (ConcurrentHashMap 사용)
ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();

// 여러 스레드가 동시에 실행해도 안전
emitters.put("client1", emitter1);    // 스레드 A
emitters.remove("client2");           // 스레드 B
emitters.forEach((id, emitter) -> {   // 스레드 C
    // 브로드캐스트 로직
});

 

5. JavaScript EventSource API

EventSource는 브라우저에서 제공하는 SSE 클라이언트 API입니다. 즉 EventSource는 자바스크립트에서 SSE를 처리하기 위해 특별히 설계된 클래스입니다.

기본 사용법:

// SSE 연결 생성
const eventSource = new EventSource('/sse/connect');

// 이벤트 리스너 등록
eventSource.addEventListener('eventName', function(event) {
    console.log('받은 데이터:', event.data);
});

// 연결 상태 감지
eventSource.onopen = function() {
    console.log('연결 성공');
};

eventSource.onerror = function() {
    console.log('연결 에러 - 자동 재연결 시도');
};

 

EventSource의 특징

  • 자동 재연결: 연결이 끊어지면 자동으로 재시도합니다 (기본 3초 간격)
  • 이벤트 기반: 서버에서 보내는 다양한 이벤트를 구분해서 처리할 수 있습니다
  • UTF-8 전용: 텍스트 데이터만 전송 가능합니다
  • GET 요청만 사용 가능: POST나 다른 HTTP 메서드는 사용할 수 없습니다

1. SSE(Server-Sent Events)란?

SSE는 서버가 클라이언트에게 실시간으로 데이터를 푸시할 수 있는 HTML5 표준 기술입니다.

1. 폴링 vs SSE 비교

폴링 방식 (1단계)

클라이언트 ──[요청]──> 서버
클라이언트 <──[응답]── 서버 (연결 종료)
     (2초 대기)
클라이언트 ──[요청]──> 서버 (반복)
클라이언트 <──[응답]── 서버 (연결 종료)

문제점: 불필요한 요청, 서버 부하, 지연 발생

 

SSE 방식 (2단계)

클라이언트 ──[연결 요청]──> 서버
클라이언트 <══════════════ 서버 (연결 유지!)

클라이언트 <──[새 메시지]── 서버 (즉시 푸시!)
클라이언트 <──[새 메시지]── 서버 (즉시 푸시!)

장점: 즉시 전송, 서버 부하 감소, 효율적

 

2. 폴링 vs SSE 비교

구분 폴링(Polling) SSE(Server-Sent Events)
통신 방향 클라이언트 → 서버 (요청/응답) 서버 → 클라이언트 (푸시)
연결 방식 매번 새로운 HTTP 연결 하나의 지속적인 HTTP 연결
실시간성 폴링 주기에 따른 지연 즉시 전달 가능
서버 부하 높음 (불필요한 요청) 낮음 (필요시에만 전송)
네트워크 효율 비효율적 효율적
구현 복잡도 간단 중간

 

3. SseEmitter 핵심 개념

SseEmitter는 스프링 부트에서 제공하는 SSE 구현체로, 서버와 클라이언트 간의 지속적인 연결을 관리합니다.

주요 특징:

  • 비동기 처리: 여러 클라이언트와 동시에 연결을 유지할 수 있습니다
  • 타임아웃 관리: 연결 유지 시간을 설정할 수 있습니다
  • 이벤트 기반: 특정 이벤트명과 함께 데이터를 전송할 수 있습니다
  • 자동 정리: 연결이 끊어지면 자동으로 리소스를 해제합니다

Spring의 SseEmitter는 비동기 요청 처리를 위해 설계된 객체입니다. 일반적인 요청-응답 모델과 달리, 서버는 클라이언트 요청에 즉시 응답하지 않고 SseEmitter 객체를 반환하여 연결을 열어둡니다. 이후, 새로운 데이터가 발생했을 때만 연결된 스레드를 사용해 데이터를 전송함으로써, 서버 자원을 효율적으로 관리할 수 있습니다.

 

생명주기 관리:

SseEmitter emitter = new SseEmitter(timeout);
emitter.onCompletion(() -> { /* 정상 완료 시 실행 */ });
emitter.onTimeout(() -> { /* 타임아웃 시 실행 */ });
emitter.onError((ex) -> { /* 에러 발생 시 실행 */ });

 

SSE 동작 원리

1. 클라이언트가 EventSource로 SSE 연결 요청
2. 서버가 SseEmitter 객체 생성하여 연결 유지
3. 새로운 메시지 발생 시 서버가 모든 연결된 클라이언트에게 푸시
4. 클라이언트가 실시간으로 메시지 수신 및 화면 업데이트

 

4. ConcurrentHashMap의 필요성

왜 일반 HashMap이 아닌 ConcurrentHashMap을 사용해야 할까요?

 

멀티스레드 환경의 문제점

// 위험한 코드 (HashMap 사용)
Map<String, SseEmitter> emitters = new HashMap<>();

// 스레드 A: 새로운 연결 추가
emitters.put("client1", emitter1);

// 스레드 B: 기존 연결 제거 (동시 실행)
emitters.remove("client2");

// 결과: 데이터 손상, 무한루프, 예외 발생 가능

 

ConcurrentHashMap의 해결책

  • 스레드 안전성: 여러 스레드가 동시에 접근해도 안전합니다
  • 세그먼트 잠금: 전체 맵을 잠그지 않고 부분적으로만 잠급니다
  • 높은 성능: 읽기 작업은 대부분 잠금 없이 수행됩니다
// 안전한 코드 (ConcurrentHashMap 사용)
ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();

// 여러 스레드가 동시에 실행해도 안전
emitters.put("client1", emitter1);    // 스레드 A
emitters.remove("client2");           // 스레드 B
emitters.forEach((id, emitter) -> {   // 스레드 C
    // 브로드캐스트 로직
});

 

코드 작성하기

SseService 생성

package com.example.class_step_websocket.chat;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

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

@Slf4j
@Service
public class SseService {

    // SSE 연결을 저장하는 Map 자료구조
    private final ConcurrentHashMap<String, SseEmitter> emitterHashMap = new ConcurrentHashMap<>();

    // 타임아웃 설정
    private static final long TIMEOUT = 1000L * 60 * 5;

    // 새로운 SSE 연결 생성 기능
    public SseEmitter createConnection(String clientId) {
        // 1. 객체 생성 
        SseEmitter emitter = new SseEmitter(TIMEOUT);
        
        // 2. 연결 정보를 가진 객체를 자료구조 map 에 저장 
        emitterHashMap.put(clientId, emitter);
        log.info("새로운 연결요청 - SSE 객체 생성");
        
        // 3. 연결 완료/타임아웃/에러 시 콜백 등록 
        emitter.onCompletion(() -> {
            log.info("연결 요청 완료");
        });

        // 타임 아웃시 클라이언트 정보 제거
        emitter.onTimeout(() -> {
            emitterHashMap.remove(clientId);
            log.error("SSE 타임 아웃");
        });

        emitter.onError((e) -> {
            emitterHashMap.remove(clientId);
            log.error("SSE 연결 에러");
        });

        // 초기 메시지 전송 (연결 성공 알림)
        try {
            emitter.send(SseEmitter.event().name("connect").data("연결 성공"));
        } catch (IOException e) {
            log.error("초기 메시지 전송 실패 : {}", clientId);
        }

        return emitter;
    }

    // 모든 연결된 클라이언트에게 메세지 보내기 (브로드캐스트)
    public void broadcastMessage(String message) {
        // 자료구조에 들어가 있는 객체들 가지고 오자
        emitterHashMap.forEach((clientId, sseEmitter) -> {
            try {
                sseEmitter.send(SseEmitter.event().name("newMessage").data(message));
            } catch (IOException e) {
                log.warn("메세지 전송 실패");
                emitterHashMap.remove(clientId);
            }
        });
    }

}

 

SseController
package com.example.class_step_websocket.chat;


import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.UUID;

@RequiredArgsConstructor
@RestController
public class SseController {

    private final SseService sseService;


    // 클라이언트가 SSE 연결 요청하는 엔드 포인트
    // - MediaType.TEXT_EVENT_STREAM_VALUE : SSE 전용 Content-Type
    // - 클라이언트가 아래 주소로 연결 요청
    @GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter connect() {
        // 클라이언트 고유 ID 생성 (UUID 사용할 예정)
        String clientId = UUID.randomUUID().toString();
        return sseService.createConnection(clientId);
    }

}

 

ChatController 코드 수정
package com.example.class_step_websocket.chat;

import org.springframework.boot.autoconfigure.data.web.SpringDataWebProperties;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Transactional(readOnly = true)  // 읽기 전용 트랜잭션 상태
@RequiredArgsConstructor
@Service
public class ChatService {

    private final ChatRepository chatRepository;
    private final SseService sseService; // DI 처리
    
    // TODO 수정
    // 채팅 메세지 저장
    @Transactional // 쓰기 작업이므로 읽기 전용 해제 처리
    public Chat save(String msg) {
        Chat chat = Chat.builder().msg(msg).build();
        Chat savedChat = chatRepository.save(chat);

        // 핵심 : 새 메세지를 연결된 클라이언트에게 즉시 전송 처리
        sseService.broadcastMessage(savedChat.getMsg());
        
        return savedChat;
    }

    // 채팅 메세지 리스트
    public List<Chat> findAll() {
        // 내림 차순으로 정렬하고 싶다면
        Sort sort = Sort.by(Sort.Direction.ASC, "id");
        return chatRepository.findAll(sort);
    }

}

 

index.mustache
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>채팅</title>
    <style>
        body { margin: 20px; font-family: Arial, sans-serif; }
        nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
        nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }

        /* 채팅창 */
        .chat-area {
            border: 1px solid #ccc;
            height: 300px;
            padding: 10px;
            overflow-y: scroll;
            background: #f9f9f9;
        }

        /* 메시지 */
        .message {
            padding: 5px 10px;
            margin: 3px 0;
            background: white;
            border-radius: 8px;
            max-width: 80%;
        }

        .message:nth-child(odd) {
            background: #e1f5fe;
            margin-left: 20%;
        }
    </style>
</head>
<body>
<nav>
    <ul>
        <li><a href="/">목록</a></li>
        <li><a href="/save-form">작성</a></li>
    </ul>
</nav>

<h3>채팅</h3>
<p style="color: #666; font-size: 13px;">비동기 자동 업데이트</p>

<div class="chat-area" id="chatArea">
    {{#models}}
        <div class="message">{{msg}}</div>
    {{/models}}
</div>

<script>

    const chatArea = document.getElementById('chatArea');

    // JS에서 EventSource 객체를 생성하여 SSE 연결 함
    const eventSource = new EventSource('/sse/connect');

    // 페이지 로드시 스크롤 맨 아래로 이동
    window.addEventListener('load', () => {
        chatArea.scrollTop = chatArea.scrollHeight;
    });

    // 실제 메세지 처리
    eventSource.addEventListener('newMessage', (event) => {
        const newMessage = event.data;
        const messageDiv = document.createElement('div');
        messageDiv.className = 'message';
        messageDiv.textContent = newMessage;
        chatArea.appendChild(messageDiv);
        // 새 메세지가 있는 새 DIV 생성해서 스크롤을 맨 아래로 이동 처리
        chatArea.scrollTop = chatArea.scrollHeight;
    });

    // 페이지 종료 시 연결 정리
    window.addEventListener('beforeunload', () => {
        eventSource.close();
    });

</script>

</body>
</html>

SSE의 주요 활용 사례

  • 실시간 알림 및 알림 센터: 새로운 메시지, 친구 요청, 좋아요 등 서버에서 발생하는 이벤트들을 사용자에게 즉시 알릴 때 사용됩니다.
  • 뉴스피드 및 타임라인: 페이스북, 인스타그램과 같이 팔로우하는 사용자의 새로운 게시물이 올라왔을 때, 클라이언트가 새로고침하지 않아도 자동으로 업데이트되게 만듭니다.
  • 실시간 주가 정보/스포츠 경기 스코어: 서버에서 지속적으로 변하는 주식 가격이나 경기 스코어를 여러 사용자에게 실시간으로 중계할 때 사용됩니다.
  • 라이브 대시보드: 서버의 상태, 데이터 사용량, 로그 등 실시간으로 변하는 모니터링 데이터를 웹 대시보드에 보여줄 때 유용합니다.
  • 배달/주문 상태 추적: 배달 앱에서 주문 상태가 '접수 완료' → '배달 중' → '배달 완료' 등으로 변경될 때, 클라이언트 화면을 실시간으로 업데이트하는 데 사용됩니다.

이러한 기능들은 모두 클라이언트가 서버에 데이터를 보낼 필요 없이, 오직 서버로부터 데이터를 받기만 하면 되기 때문에 단방향 통신에 최적화된 SSE가 효율적이고 정석적인 방법이 될 수 있습니다.


핵심 동작 원리

SSE 통신 흐름

  1. 클라이언트: new EventSource('/sse/connect') → 서버에 SSE 연결 요청
  2. 서버: SseEmitter 생성 → 클라이언트와 지속적 연결 유지
  3. 메시지 작성: 사용자가 새 메시지 POST → 서버에 저장
  4. 브로드캐스트: sseService.broadcastMessage() → 모든 연결된 클라이언트에 즉시 전송
  5. 클라이언트: eventSource.addEventListener('newMessage') → 새 메시지 실시간 수신

SSE의 한계와 WebSocket의 필요성

SSE는 폴링보다 효율적이지만 여전히 단방향 통신의 한계가 있습니다. 채팅에서 사용자가 "입력 중..." 상태를 보여주거나, 실시간 게임과 같은 양방향 상호작용이 필요한 경우에는 WebSocket이 더 적합합니다.