학습 목표
- 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 통신 흐름
- 클라이언트: new EventSource('/sse/connect') → 서버에 SSE 연결 요청
- 서버: SseEmitter 생성 → 클라이언트와 지속적 연결 유지
- 메시지 작성: 사용자가 새 메시지 POST → 서버에 저장
- 브로드캐스트: sseService.broadcastMessage() → 모든 연결된 클라이언트에 즉시 전송
- 클라이언트: eventSource.addEventListener('newMessage') → 새 메시지 실시간 수신
SSE의 한계와 WebSocket의 필요성
SSE는 폴링보다 효율적이지만 여전히 단방향 통신의 한계가 있습니다. 채팅에서 사용자가 "입력 중..." 상태를 보여주거나, 실시간 게임과 같은 양방향 상호작용이 필요한 경우에는 WebSocket이 더 적합합니다.
'Spring boot > Spring boot websocket' 카테고리의 다른 글
| Spring Boot - STOMP(DTO) 변환 (0) | 2025.09.09 |
|---|---|
| Spring Boot - STOMP 프로토콜 채팅 (0) | 2025.09.05 |
| Spring Boot WebSocket 기본 채팅 시스템 (0) | 2025.09.04 |
| Spring Boot 폴링(Polling) 채팅 시스템 (0) | 2025.09.02 |