알림 기능을 추가하고자 한다.
결론부터 얘기하면 알림은 많은 방법으로 알림을 구현할 수 있지만, SEE를 사용했다.
단순 SSE를 통해 클라이언트가 서버에 구독하고,메세지를 받는건 쉽게 할 수 있었다.
문제는, 이미 구독하여 알람을 받고있는 클라이언트가 네트워크 문제로 인해, 알림을 받아야 하는데 받지 못한 알림을 클라이언트가 네트워크가 원활히 되고 다시 새로운 페이지로 진입했을 때 받지 못한 알림을 전송하는데 있어 많은 시간이 소요되었고 완벽하지 않았다. 어떤 방법으로 테스트를 하고 경우의 수를 찾느라 많은 시간을 소비했다.
알림방식에는 여러개 있지만 Poling(일정주기로 호출), Long Poling(업데이트시에만)은 많이 사용하지 않는다.
웹소켓으로 실시간 알림을 구현해본적이 있는데 이버넹는 SSE를 사용해보려고 한다.
SSE라는 것은 Poling과 websocket(실시간 양방향 데이터이동) 중간정도라 생각한다.
- 웹소켓과 SSE 차이점
WebSocket | SSE(Server-Sent-Event) | |
브라우저 지원 | 대부분의 브라우저에서 지원 | 대부분 모던 브라우저에서 지원(polyfills 가능) |
통신 방향 | 양방향 | 단방향(서버 -> 클라이언트) |
리얼타임 | O | O |
데이터형태 | Binary, UTF-8 | UTF-8 |
자동 재접속 | X | O(3초마다 재시도) |
최대 동시 접속수 | 브라우저 연결 한도는 없지만, 서버 셋업에 따라 다름 | HTTP를 통해서 할 때는 브라우저당 6개까지 가능, HTTP2로는 100개 기본 |
프로토콜 | websocket | HTTP |
배터리 소모량 | 큼 | 작음 |
Firewall 친화적 | X | O |
- websocket(server push)
- websocket은 연결을 유지하여 서버와 클라이언트 간 양방향 통신이 가능
- SSE(server push)
- 이벤트가 [서버 -> 클라이언트] 방향으로만 흐르는 단방향 통신 채널
- 단순 알림은 양방향까진 필요없을 것 같아 SSE로 알림을 구현해본다.
SSE: 웹브라우저가 서버쪽으로 특정 이벤트를 구독하면, 서버에서는 해당 이벤트 발생시, 브라우저쪽으로 이벤트를 보내주는 방식이다. 따라서 한 번만 연결 요청요청을 보내면 연결이 종료될 때까지 재연결 과정 없이 서버에서 웹 브라우저로 데이터를 계속 보낼 수 있다.
단점은, 서버에서 웹브라우저로 전송만 가능한 단방향이고 최대 동시접속이 제한되어있다.
SSE (Server-Sent Event)
그림1)
그림을 설명해보자면, 클라이언트가 서버에 subscribe(구독)을 신청한다. 신청한 후, server에서 SseEmitter( spring framework 4.2부터 SSE 통신을 지원 ) 를 사용하여 구독했을 때의 발급해준 id와 데이터를 send()로 보내게 되면, 구독해서 발급받은 id에게 실시간으로 데이터를 전송할 수 있다.
활용방안: 회원이 로그인을 한후 메인페이지로 리다이렉트 되었을 때, 웹에서 제공하는 Notification을 사용하여 알람여부를 클라이언트에게 묻고, 서버로 구독되어있는 로직에 접근하여 강제로 구독을 시킵니다.
클라이언트의 액션으로 특정 알림을 보내야 할 때, 구독하여 받은 id로 데이터를 보냅니다. Notification.permission를 활용하여 클라이언트가 알림 허용을 했다면, var notification = new Notification("알림 메세지를 받습니다."); 를 사용하여 메세지를 보내주면 되고, 허용을 허락하지 않았다면, 허용에 대한 경고창으로 알림상태의 현재 상황을 말해줍니다.
클라이언트 구연
main.jsp :
메인으로 들어왔을 때, 알림을 받을건지 안받을건지에 대한 유무이다.퍼시션이 grranted가 되면 알림을 받을 수 있다.
$(document).ready(function (){
console.log("Notification.permission", Notification.permission)
// 브라우저가 알림(notifications)를 지원하는지 확인
if (!("Notification" in window)) {
alert("브라우저가 notification을 지원하지 않음");
}
// 알림 허용이 되었는지 확인
else if (Notification.permission === "granted") {
// notification 생성
var notification = new Notification("알람을 받습니다.");
setTimeout(() => {
notification.close();
}, 10 * 1000);
}
// 사용자가 최초 웹전근시 default일 때, 사용자가 허용으로 바꿨을 경우
else if (Notification.permission !== 'denied') {
Notification.requestPermission().then((permission) => {
console.log("permission", permission)
// handlePermission(permission);
// 사용자가 허용하면 알림 띄우기
if (permission === "granted") {
var notification = new Notification("알림 메세지를 받습니다.");
setTimeout(() => {
notification.close();
}, 10 * 1000);
}
});
}
})
header.jsp
페이지가 이동할 때마다 header의 영향을 받는다. 이동될 때마다 구독신청을 한다.
sse로 구독하기 위해선 자바스크립트에서 지원되는 EventSource 사용한다.
서버에 만든 url과 고유 아이디가 필요하다.
$(document).ready(function (){
console.log("header start")
console.log("Notification.permission", Notification.permission)
if($('#info').val() != null) {
sendNotification();
}//if end
})//ready end
let totalNCount= 0; //mypage,친구리스트 알람개수
//현재시점 알람개수 가져와서 매핑
function getNotificationCount() {
$.ajax({
type : 'get',
url : "/notification/count",
success : function(data) { // 결과 성공 콜백함수
console.log("notiCount", data)
if(data.totalNCount != 0) {
totalNCount = data.totalNCount
$('#totalNCount').text(totalNCount)
$('#friendshipListCount').text(totalNCount)
} else {
$('#totalNCount').text('')
$('#friendshipListCount').text('')
}
},
error : function(request, status, error) { // 결과 에러 콜백함수
console.log("error", error)
}
})
}
//구독하기
function sendNotification() {
//구독커넥션 연결
const eventSource = new EventSource('http://localhost:8080/notifications/subscribe/' + $('#info').val());
eventSource.addEventListener('sse', event => {
//서버에서 send()를 하면 이부분부터 로직이 수행된다.
// 현재시점 알람가져오기
getNotificationCount(); //totalNCount의 변수 할당
console.log(">>>>>>>>>>>>>>>>>>>event : ",event);
if(event.data == '호감') {
alertNotification("이성이 높은 점수를 줬습니다.");
}
if(event.data == '친구해요') {
alertNotification("이성이 친구신청을 했습니다.");
}
});//eventSource.addEventListener end
}
//알림 띄우기
function alertNotification (message) {
console.log(">>>>>>>>>>>>>>>>>>>승낙 >> mypage 채팅에 알람표시");
if(Notification.permission !== "granted") {
alert("새로운 메세지가 있습니다. 실시간으로 알람을 받고 싶은 경우 브라우저에서 알림을 활성화 시켜주세요.")
Notification.requestPermission().then((permission) => {
console.log("permission", permission)
// handlePermission(permission);
// 사용자가 허용하면 알림 띄우기
if (permission === "granted") {
var notification = new Notification("알림 메세지를 받습니다.");
}
});
}
//친구하기 또는 호감을 했기 때문에 알람개수 늘려주기
if(totalNCount != 0) {
console.log("totalNCount", totalNCount)
$('#totalNCount').text(totalNCount + 1)
$('#friendshipListCount').text(totalNCount + 1)
}
let granted = false;
if (Notification.permission === 'granted') {
granted = true;
}
console.log("granted", granted)
// 알림 보여주기
if (granted) {
const notification = new Notification('알림!!!', {
body: message
});
setTimeout(() => {
notification.close();
}, 10 * 1000);
notification.addEventListener('click', () => {
window.open("http://localhost:8080/user/friendList/", '_blank');
});
}
}
가장 중요한 서버쪽이다.
서버:controller
1. subscribe메소드: 클라이언트가 header또는 메인에 접속했을 때, 이 URL을 통해 들어온다.
매개변수
- id: 해당유저의 고유 id다. 유저테이블의 pk아이디
- lasteventId: 구독한 유저가 마지막으로 수신받았던 아이디가 되겠다. 아래에 나오겠지만, 구독을 할 때 emitter를 생성하는데, 키값으로 userId_현재시간 으로 키값을 정해준다. 마지막으로 송신한 emitter의 키값이 되겠다. 최초 수신을 받지 않는다면 lstenveId는 들어오지 않는다. lasteventId가 들어오는 경우를 다른 블로그에서 제대로 설명한 부분이 없어서 여러가지 테스트를 하다보니 내가 알게된 사실은 구독신청한 emiter의 시간이 만료되어 다시 구독신청을 했을 때, 또는 서버가 재시작될 때뿐이다.(혹시 더 알고계신게 있다면 댓글로 알려주세요).
2. sendData 메소드
클라이언트의 특정 이벤트로 인해 알림을 보내고 싶을 때 이 URL로 들어온다. 해당메소드에서는 알림정보를 DB에 저장하고 구독한 emiter의 키값을 찾아 알림을 보내도록 한다.
/**
* 로그인한 회원 main, 또는 header에서 구독하기
* @param id
* @return
* sse 통신을 하기 위해서는 MIME 타입을 text/event-stream로 해줘야한다.
*/
@GetMapping(value = "/notifications/subscribe/{id}", produces = "text/event-stream")
public SseEmitter subscribe(@PathVariable Long id, @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId) {
logger.info("subscribe controller start id : {}", id);
logger.info("lastEventId는 emitters가 만료되었을 때, 혹은 서버가 나갔을 때 클라이언트에서 보내서 서버에서 받을 수 있다" +
". 하지만 서버를 내렸다 다시 올리면 캐시에 저장했던 알림 데이터는 모두 사라진다.");
logger.info("lastEventId : {}", lastEventId);
String userId = String.valueOf(id);
return notificationService.subscribe(id,lastEventId);
}
/**
* 구독한 회원에게 알림보내기
* @param id
* @param data
*/
@GetMapping("/notifications/send-data/{id}/{refId}/{data}")
@ResponseBody
public void sendData(@PathVariable String id, @PathVariable String data, @PathVariable int refId) {
logger.info("sendData start");
logger.info("id: {}", id);
logger.info("data: {}", data);
logger.info("refId: {}", refId);
//알림 데이터 저장
Notification notification = new Notification();
notification.setUserId(Integer.parseInt(id));
if(data.equals("친구해요") || data.equals("승낙해요")) {
notification.setField("relationship");
} else {
notification.setField("evaluation");
}
notificationService.insertNotification(notification);
notificationService.saveEventCacheAndSendNotification(id, data);
}
service
1. 가장 이해하기 힘들고 시간을 소비한 부분이다.
- emitter를 등록할 때 필요한 키값을 userId_시간으로 만든다.
- hasLostData는 못받은 데이터가 있느냐 없느냐인데 lastEventId의 존재에 따라 결정된다.
- hasLostData가 트루면 sendLostData에서 user_id로 저장되었던 캐시를 찾는다.
- 이 캐시는 클라이언트가 특정이벤트로 알림을 받는 api를 호출할 때 로직에서 캐시가 저장된다.
- user_id로 시작하는 emitter 키값이 존재한다면, lastEventId와 비교하여 마지막으로 수신받은 lastEventId보다 더 최근에 보낸 eventId가 있는지 없는지 compareto로 비교를 한다.
- 이 부분을 이해하는데 있어 많이 헷깔렸는데, lastEventId는 클라이언트가 마지막으로 수신받은 emitter의 eventId이고, 캐시에 저장되어있는 eventId들 중 lastEventId보다 더 큰 값인경우(더 최근에 보낸경우)에는 음수의 값을 반환하여, 클라이언트에게 해당키값으로 알림을 보내게 된다.
- 클라이언트가 알림을 받는순간 lastEventId는 그때그때 변환된다. 새로운 알림을 받은 emiiterId로,
- 오전7시에 내가 알림을 받았다. 캐시에 저장되었다 userId_7시
- 하지만 7시 30분에 네트워크 , 로직, 서버등의 어떠한 오류등으로 인해, 캐시에는 user_7시30분이 저장되었지만, 알림을 받지못했다. lastEventId는 그대로 7시이다.
- 그러면 lstEventId < 캐시에 저장된 eventId가 성립된다. 이때 손실된 데이터로 판단되고, 캐시에 저장한 eventId가 lasteventId보다 더 크기 때문에, 클라이언트가 새로고침을 했을 떄 알림을 받을 수 있게된다.
- (참고로 네트워크오류로 lastEventId를 어떻게 받는지의 케이스, 상황을 모르는분들이 많았고 나 또한 그랬다. 내가 현재 발견한 방법은 2가지이다. 첫번 째: 알림수신을 최초로 한 번 받고나서 서버를 재실행한다. 그러면 lastEventId가 받아진다(하지만 캐시에 저장된 데이터가 모두 날아가서 테스트를 할 수 없었다. chatGPT는 서버재시작으로 인해 currentMap에 저장한 캐시는 날아가지 않는다고 하는데 나는 날아간다..) 두번 째: 현재 구독되어있는 emitter의 만료시간을 강제로 짧게 지정한다. 현재 deafult가 1시간으로 위에 final로 설정했다. 이걸 30초로 지정하고 saveEventCacheAndSendNotification에서 실질적으로 알림을 보내는 메소드 sendNotification를 주석하고 서버를 재시작한다. 30초뒤 emitter가 만료됬다는게 콘솔에 찍히는 동시에 클라이언트가 특정 이벤트를 postman으로 보낸다. 그럼 캐시에만 저장되고 알림은 수신받지 못한다(주석했기 때문에) 그리고, 클라이언트가 새로고침을 하면 그 못받은 알림이 다시 온다.
package com.example.self_board_project.notification.service;
import com.example.self_board_project.notification.mapper.EmitterRepository;
import com.example.self_board_project.notification.mapper.NotificationMapper;
import com.example.self_board_project.notification.vo.Notification;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class NotificationService {
// 기본 타임아웃 설정
Logger logger = LoggerFactory.getLogger(getClass());
private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; //1시간
private final EmitterRepository emitterRepository;
@Autowired
private NotificationMapper notificationMapper;
/**
* 클라이언트가 구독을 위해 호출하는 메서드.
*
* @param userId - 구독하는 클라이언트의 사용자 아이디.
* @return SseEmitter - 서버에서 보낸 이벤트 Emitter
* 연결 요청에 의해 SseEmitter가 생성되면 더미 데이터를 보내줘야한다. sse 연결이 이뤄진 후,
* 하나의 데이터도 전송되지 않는다면 SseEmitter의 유효 시간이 끝나면 503응답이 발생하는 문제가 있다. 따라서 연결시 바로 더미 데이터를 한 번 보내준다.
*/
public SseEmitter subscribe(long userId, String lastEventId) {
logger.info("subscribe Service start ");
String emitterId = makeTimeIncludeId(userId); // (1-2)
logger.info("emitterId : {}", emitterId);
//emitter 등록, emitter를 등록한 번호로 send를 보내면 화면에서 알람을 받을 수 있다.
SseEmitter emitter = createEmitter(emitterId);
//emitter.send에 eventid를 같이 보낸다. 나중에 lastId를 받기 위함
String eventId = makeTimeIncludeId(userId);
logger.info("eventId : {}", eventId);
logger.info("hasLostData {} ", hasLostData(lastEventId));
logger.info("hasLostData는 lastEventId가 있을 때 즉: 네트워크상 문제로 인해 알람을 받지 못했을 때 데이터 손실이라 판단하여 true가 된다.");
if (hasLostData(lastEventId)) {
sendLostData(lastEventId, userId, emitterId, emitter);
}
//구독시 default로 미리 보내야 에러 방지
sendNotification(emitter, eventId, emitterId, "EventStream Created. [userId=" + userId + "]");
return emitter;
}
private String makeTimeIncludeId(long id) { // (3)
return id + "_" + System.currentTimeMillis();
}
private boolean hasLostData(String lastEventId) { // (5)
return !lastEventId.isEmpty();
}
private void sendLostData(String lastEventId, long userId, String emitterId, SseEmitter emitter) { // (6)
logger.info("sendLostData start > 데이터 손실이 있다.");
logger.info("userId : {} " , userId);
logger.info("lastEventId : {} , " , lastEventId);
logger.info("user의 pk값으로, userId_*******로 되어있는 모든 아이디들을 불러온다., 그 후 아래의 lastEventId과 시간을 따져봐야한다.");
Map<String, Object> eventCaches = emitterRepository.findAllEventCacheStartWithByUserId(String.valueOf(userId));
logger.info("eventCaches.toString() : {} ",eventCaches.toString());
//lastEventId는 해당 웹브라우저가 마지막으로 수신하게된 eventId이다. lastEventId가 캐시에 저장된 eventId보다 작을경우(더 오래전일 경우) 음수를 반환하게 되고 sendNotification이
//실행되어, 이전에 보내지 못했던 알람을 보내게 된다. 반대로 lastEventId가 더 큰경우(더 최근일 경우), 캐시에 저장된 아이
//그럼 어떻게 lastEventId가 더 큰경우가 나오냐한다면, 일단 알람 수신이 되는순간 lastEventId는 현재 브라우저가 구독한 eventId이다. 예를 들어 7시라고 하자
//그후 7시 10분에 emmit.send부분의 코드를 지우고, 알람을 요청했을 경우, cash에 7시10분의 eventId가 저장된다. 하지만 알람은 발생하지 않는다.수신이되지 않은것이다.
//현재까지 lastEventId는 7시이다. 그러면 cash에 저장된 eventId보다 작을 수 있게 된다. 이 때 키값을 통해 알람을 보낸다.
//테스트를 위해 코드를 지우고 테스트를 했지만, 클라이언트또는 서버, 아니면 시스템문제상 캐시에만 저장되고, 알람부분 제대로 동작을 안했을 경우를 대비한 것 이다.
//테스트는, 일단 정상적으로 알람을 보낸다. 그리고 클라이언트가 알람보내는 send service부분에 알람보내는 부분을 주석하고
// 시간 만료되어 deleteId가 발동되는 순간, 알람을 보낸다. 그러면 캐시만 저장한 상태이기 때문에, deleteId된걸 다시 서버가 자동으로 구독하려고 할 때 알람을 받을 수 있게된다.
//어쨌든 알람을 보냈지만, 서버, 코드, sse끊김현상,네트워크등으로 알림을 송신하지 못했을 때, 다음번에 다시 구독을 요청할 때 알람을 받을 수 있다.
eventCaches.entrySet().stream()
.filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
.forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue()));
}
/**
* 캐시를 저장하고 궁극적인 emmiter.send가 있는 sendNotification으로 보내는 메소드이다.
* 다른 서비스 로직에서 이 메서드를 사용해 데이터를 Object event에 넣고 전송하면 된다.
*
* @param userId - 메세지를 전송할 사용자의 아이디.
* @param event - 전송할 이벤트 객체.
*/
public void saveEventCacheAndSendNotification(String userId, Object event) {
logger.info("send Start");
String eventId = userId + "_" + System.currentTimeMillis();
Map<String, SseEmitter> emitters = emitterRepository.findAllEmitterStartWithByUserId(userId);
logger.info("emitters.isEmpty : {}", emitters.isEmpty());
logger.info("emitters : {}", emitters);
emitters.forEach(
(key, emitter) -> {
emitterRepository.saveEventCache(key, event);
// sendNotification(emitter, eventId, key, event);
}
);
}
/**
* emitter로 send보내는 마지막 단계
* @param emitter
* @param eventId
* @param emitterId
* @param data
*/
private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) { // (4)
logger.info("sendNotification Start");
logger.info("emitter : {}", emitter);
logger.info("eventId : {}", eventId);
logger.info("emitterId : {}", emitterId);
logger.info("data : {}", data);
try {
emitter.send(SseEmitter.event().id(eventId).name("sse").data(data));
} catch (IOException exception) {
emitterRepository.deleteById(emitterId);
}
}
/**
* 사용자 아이디_시간을 기반으로 이벤트 Emitter를 생성
* id_시간으로 emmiter를 생성한다.
*
* @param id - 사용자 아이디.
* @return SseEmitter - 생성된 이벤트 Emitter.
*/
private SseEmitter createEmitter(String id) {
/*
클라이언트의 sse연결 요청에 응답하기 위해서는 SseEmitter 객체를 만들어 반환해줘야한다.
SseEmitter 객체를 만들 때 유효 시간을 줄 수 있다. 이때 주는 시간 만큼 sse 연결이 유지되고, 시간이 지나면 자동으로 클라이언트에서 재연결 요청을 보내게 된다.
id를 key로, SseEmitter를 value로 저장해둔다. 그리고 SseEmitter의 시간 초과 및 네트워크 오류를 포함한 모든 이유로 비동기 요청이 정상 동작할 수 없다면 저장해둔 SseEmitter를 삭제한다.
*/
logger.info("createEmitter start emitterId : {}", id);
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitterRepository.save(id, emitter);
//설정한 DEFAULT_TIMEOUT가 지났을 때 또는 브라우저를 닫았을 때, deleteId메소드가 실행된다.
// Emitter가 완료될 때(모든 데이터가 성공적으로 전송된 상태) Emitter를 삭제한다. (완료되도 삭제는 안되는 것 같다)
emitter.onCompletion(() -> emitterRepository.deleteById(id));
// Emitter가 타임아웃 되었을 때(지정된 시간동안 어떠한 이벤트도 전송되지 않았을 때) Emitter를 삭제한다.
emitter.onTimeout(() -> emitterRepository.deleteById(id));
return emitter;
}
}
EmitterRepository
package com.example.self_board_project.notification.mapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Repository
public class EmitterRepository {
Logger logger = LoggerFactory.getLogger(getClass());
// 모든 Emitters를 저장하는 ConcurrentHashMap
// sseEmitter를 관리하는 스레드들이 콜백할때 스레드가 다를수 있기에 ThreadSafe한 구조인 ConcurrentHashMap을 사용해서 해당 메시지를 저장해야한다.
/**
* emitterRepo에 save(id, ssemitter(timeout))을 저장함으로써 향후 이벤트가 발생됐을때 클라이언트에게 데이터를 전송할 수있다.
* 이때 repo에서는 sseEmitter를 관리하는 스레드들이 콜백할때 스레드가 다를수 있기에 ThreadSafe한 구조인 ConcurrentHashMap을 사용해서 해당 메시지를 저장해야한다.
*/
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final Map<String, Object> eventCache = new ConcurrentHashMap<>();
/**
* 주어진 아이디와 이미터를 저장
*
* @param id - 사용자 아이디.
* @param emitter - 이벤트 Emitter.
*/
public void save(String id, SseEmitter emitter) {
emitters.put(id, emitter);
logger.info("save : {} ", emitters);
}
/**
* 주어진 아이디의 Emitter를 제거
*
* @param id - 사용자 아이디.
*/
public void deleteById(String id) {
logger.info("deleteById id : {} ", id);
emitters.remove(id);
}
/**
* 주어진 아이디의 Emitter를 가져옴.
*
* @param id - 사용자 아이디.
* @return SseEmitter - 이벤트 Emitter.
*/
public SseEmitter get(String id) {
logger.info("get id : {} ", id);
return emitters.get(id);
}
public Map<String, Object> findAllEventCacheStartWithByUserId(String memberId) {
logger.info("findAllEventCacheStartWithByMemberId start");
logger.info("eventCache.isEmpty() : {}", eventCache.isEmpty());
return eventCache.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(memberId))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
public Map<String, SseEmitter> findAllEmitterStartWithByUserId(String memberId) {
return emitters.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(memberId))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
public void saveEventCache(String id, Object event) {
logger.info("saveEventCache start");
logger.info("id : {}", id);
logger.info("event : {}", event);
eventCache.put(id, event);
logger.info(eventCache.toString());
}
//
// public Map<String, SseEmitter> findAllStartById(String id) {
// return emitters.entrySet().stream()
// .filter(entry -> entry.getKey().startsWith(id))
// .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// }
//
// public void deleteAllStartByWithId(String id) {
// emitters.forEach((key, emitter) -> {
// if (key.startsWith(id)) emitters.remove(key);
// });
// }
}
현재 로컬에는 잘 돌아가지만 나중에 배포했을 때, nginx를 이용했을 때 sse가 제대로 동작이 안한다고 한다.
아래에 참고글을 통해 해결해야 할 것 이다.
3일동안 SSE만 보게 되어 많이 지쳤다.. 더이상 보기도 싫다.

🚨 개발서버에 프로젝트를 배포하고, 서비스를 돌리는데 두가지 이슈 발생
1. 개발자도구에서 script 에러
net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)
해결방법 :
🔑 버퍼링 비활성화: SSE는 일부 브라우저에서는 버퍼링을 사용하지 않아야 한다고 합니다.
nginx설정에서 location에 추가합니다.
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection '';
2. /subscibe를 한번만 요청해야하는데 시도때도 없이 거의 1분마다 서버로 요청을 한다. 그리고 통신은 200, 하지만 빨간색으로 표시, 뭔가 에러같아서 빨리 해결하고 싶었다.
해결방법:
🔑 클라이언트와 서버의 타임아웃 설정: SSE 연결이 끊어지지 않도록 클라이언트와 서버의 타임아웃을 확인해야 한다.
👉 keepalive_timeout 65 : 클라이언트와 서버간에 http연결을 65초로 유지하라는 의미
🔑 Nginx에서는 proxy_read_timeout을 조절해주기
👉 클라이언트가 nginx을 통해 프록시 서버로 요청을 보내면 proxy-read_timeout 시간동안 응답을 기다린다.
👉 만약 프로식시 서버가 이 시간내에 으답을 제공하지 않으면
👉 nginx는 연결을 종료하고 클라이언트에게 타임아웃 오류를 반환한다.
location에 추가
#server안에
#클라이언트와 서버간에 http연결을 65초로 유지하라는 의미
keepalive_timeout 65;
#location안에
proxy_read_timeout 86400; # 예: 24시간
❓ : 윗부분 추가로 인해 문제가 해결된듯 했지만 역시나 시간이 오래 지나면 또다시 서버로 /subscribe api를 호출해서 메세지를 받아온다. 아직 완전히 해결하지 못했다.
websoket vs sse 비교
https://suzzeong.tistory.com/86
2022.09.20 / 알림기능 - SSE vs WebSocket
오늘한 일 알림기능을 구현하기 위한 방법으로는 두가지가 있음 - SSE vs WebSocket 그 기능들의 차이점을 찾아봄 WebSocket SSE(Server-Sent-Event) 브라우저 지원 대부분의 브라우저에서 지원 대부분 모던
suzzeong.tistory.com
코드 관련: https://dkswnkk.tistory.com/702
SSE로 알림 기능 구현하기 with Spring
서론 인터넷은 웹 브라우저와 웹 서버 간의 데이터 통신을 위해서 HTTP 표준 위에 구축되어 있습니다. 대부분의 경우 웹 브라우저인 클라이언트가 HTTP 요청을 서버에 보내고, 서버는 적절한 응답
dkswnkk.tistory.com
lastEventId관련
nigix관련 :
https://sothoughtful.dev/posts/sse/
SSE Spring에서 구현하기 A to Z
SSE란?
sothoughtful.dev
niginx, jpa, jwt, 스케일아웃 이슈
https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/
'Dev.BackEnd > Spring & Spring Boot' 카테고리의 다른 글
[springboot,mysql] mysql 5.xx버전에서 8.xx버전으로 바꿔 driver가 spring에 붙지 않을 때 (0) | 2023.11.07 |
---|---|
[spring boot] multipart타입으로 파일업로드를 할 때 파일사이즈로 인한 오류 해결방법 (0) | 2023.10.17 |
SpringBoot: @RequestParam vs @RequestBody이해하기 (0) | 2023.09.20 |
[스프링부트] Scheduled을 이용해 일정시간 또는 특정시간에 코드가 실행되게 해보자 (0) | 2023.08.25 |
[Spring boot] 웹페이지 tiles를 사용해서 간편하게 레이아웃 설정하기 (0) | 2023.08.24 |