SSE(Server-Sent Events)
클라이언트(응답) <- 서버(요청)
- 서버가 클라이언트에게 실시가능로
데이터를 전송할 수 있는 기술
- HTTP 프로토콜 기반으로 동작
- 단방향 통신(ex: 무전기)
1) 클라이언트가 서버에 연결
-> 클라이언트가 서버로 부터 데이터 받기 위한
대기상태에 돌입
(EventSource 객체 이용)
2) 서버가 연결된 클라이언트에게 데이터를 전달
(서버 -> 클라이언트 데이터 전달하라는
요청을 또 AJAX를 이용해 비동기 요청)
[common.html]
<!-- 공통으로 사용될 css, js 추가 코드-->
<link rel="stylesheet" href="/css/main-style.css">
<!-- font awesome 라이브러리 추가 + key 등록 -->
<!-- <script src="https://kit.fontawesome.com/a373078820.js" crossorigin="anonymous"></script> -->
<script src="https://kit.fontawesome.com/a373078820.js" crossorigin="anonymous"></script>
<!-- 알림 관련 전역 변수 -->
<script th:inline="javascript">
// 현재 접속한 클라이언트가 로그인 상태인지 확인하는 변수
// -> 알림은 로그인한 회원만 받을 수 있다!!1
const notificationLoginCheck
= /*[[${session.loginMember} ? true : false]]*/ false;
// 알림을 보낸 회원의 프로필 이미지가 없을 경우 사용할
// 기본 이미지
const notificationDefaultImage
= /*[[#{user.default.image}]]*/ '기본이미지';
</script>
<script src="/js/header.js"></script>
[header.html]
<!-- 알림 영역 -->
<div class="notification-container" th:if="${session.loginMember}">
<!-- 알림 버튼 -->
<button class="notification-btn fa-regular fa-bell" id="my-element">
<!-- 알림 개수 표시 -->
<div class="notification-count-area"></div>
</button>
<!-- 알림 목록 -->
<ul class="notification-list">
</ul>
</div>
[Notification.dto]
package edu.kh.project.sse.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Notification {
private int notificationNo;
private String notificationContent;
private String notificationCheck;
private String notificationDate;
private String notificationUrl;
private int sendMemberNo; // 알림 보낸 사람
private int receiveMemberNo; // 알림 받는 사람 (중요!)
private String notificationType; // 알림 내용을 구분해서 만드는 용도
private int pkNo; // 알림이 보내진 게시글 번호
private String sendMemberProfileImg; // 알림 보낸 사람 프로필 이미지
}
1. SSE 연결하는 함수
-> 연결을 요청한 클라이언트가 서버로부터 데이터가 전달될때까지 대기상태가 됨(비동기)
[header.js]
const connectSse = () => {
/* 로그인이 되어있지 않은 경우 함수 종료 */
if(notificationLoginCheck === false) return;
console.log("connectSse() 호출");
// 서버의 "/sse/connect" 주소로 연결 요청
const eventSource = new EventSource("/sse/connect");
// -----------------------------------------------------------
/* 서버로부터 전달받은 메시지가 왔을 경우(전달받은 경우) */
eventSource.addEventListener("message", e=> {
console.log(e.data); // e.data : 전달받은 메시지
// -> Spriing HttpMessageConverter가
// JSON으로 변환해서 응답해줌
const obj = JSON.parse(e.data);
console.log(obj); // 알림을 받는 사람 번호, 읽지 않은 알림 개수
// 종 버튼에 색 추가(활성화)
const notificationBtn = document.querySelector(".notification-btn");
notificationBtn.classList.add("fa-solid");
notificationBtn.classList.remove("fa-regular");
// 알림 개수 표시
const notificationCountArea
= document.querySelector(".notification-count-area");
notificationCountArea.innerText = obj.notiCount;
});
/* 서버 연결이 종료된 경우 */
eventSource.addEventListener("error", () => {
console.log("SSE 재연결 시도")
eventSource.close(); // 기존 연결 닫기
// 5초후 재연결 시도
setTimeout( () => {}, 5000);
})
}
[controller]
@RestController // @controller + @ResponseBody
@Slf4j
public class SseController {
@Autowired
private SseService service;
//SseEmitter : 서버로 부터 메시지를 전달 받을
// 클라이언트 정보를 저장한 객체 == 연결된 클라이언트
// ConcurrentHashMap : 멀티스레드 환경에서 동기화를 보장하는 Map
// -> 한번에 많은 요청이 있어도 차례대로 처리
private final Map<String, SseEmitter> emitters =
new ConcurrentHashMap<>(); // == 연결된 클라이언트 대기 명단
/** 클라이언트 연결 요청 처리 */
@GetMapping("sse/connect")
public SseEmitter sseConnect(
@SessionAttribute("loginMember") Member loginMember) {
//Map에 저장될 key 값으로 회원 번호 얻어오기
String clientId = loginMember.getMemberNo() + "";
// SseEmiter 객체 생성
// -> 연결 대기 시간 10분 설정
SseEmitter emitter = new SseEmitter(10*60*1000L);
// 클라이언트 정보를 Map에 추가
emitters.put(clientId, emitter);
// 클라이언트 연결 종료 시 Map에서 제거
emitter.onCompletion(() -> emitters.remove(clientId));
// 클라이언트 타임 아웃 시 Map에서 제거
emitter.onTimeout(() -> emitters.remove(clientId));
return emitter;
}
2. 알림 메시지 전송
알림을 받을 특정 클라이언트의 id 필요
(memberNo 또는 memberNo를 알아낼 수 있는 값)
[동작 원리]
1) AJAX를 이용해 Controller에 요청
2) 연결된 클라이언트 대기 명단(emmiters)에서
클라이언트 id가 일치하는 회원을 찾아
메시지 전달하는 send() 메서드를 수행
3) 서버로부터 메시지를 전달 받은 클라이언트의
eventSource.addEventListner()가 수행됨
[header.js]
const sendNotification = (type, url, pkNo, content) => {
// type : 댓글, 답글, 게시글 좋아요 등을 구분하는 값
// url : 알림 클릭 시 이동할 페이지 주소
// pkNo : 알림 받는 회원 번호 또는
// 회원 번호를 찾을 수 있는 pk값
// content : 알림 내용
/* 로그인이 되어있지 않은 경우 함수 종료 */
if(notificationLoginCheck === false) return;
/* 서버로 제출할 데이터를 JS 객체 형태로 저장 */
const notification = {
"notificationType" : type,
"notificationUrl" : url,
"pkNo" : pkNo,
"notificationContent" : content
}
fetch("/sse/send", {
method : "POST",
headers : {"Content-Type" : "application/json"},
body : JSON.stringify(notification)
})
.then(response => {
if(!response.ok){ // 비동기 통신 실패
throw new Error("알림 전송 실패");
}
console.log("알림 전송 성공");
})
.catch(err => console.error(err));
}
[controller]
/**알림 메시지 전송*/
@PostMapping("sse/send")
public void sendNotification(
@RequestBody Notification notification,
@SessionAttribute("loginMember") Member loginMember
) {
// 알림 보낸 사람(현재 로그인한 회원) 번호 추가
notification.setSendMemberNo(loginMember.getMemberNo());
// 전달 받은 알림 데이터를 DB에 저장하고
// 알림 받을 회원의 번호
// + 해당 회원이 읽지 않은 알림 개수 반환 받는 서비스 호출
Map<String, Object> map
= service.insertNotification(notification);
// 알림을 받을 클라이언트 id(수정 예정)
String clientId = map.get("receiveMemberNo").toString();
// 연결된 클라이언트 대기 명단(emitters)에서
// clientId가 일치하는 클라이언트 찾기
SseEmitter emitter = emitters.get(clientId);
// clientId가 일치하는 클라이언트가 있을 경우
if(emitter != null) {
try {
emitter.send(map);
}catch(Exception e) {
emitters.remove(clientId);
}
}
}
[service]
/** 알림 삽입 후 알림 받을 회원 번호 + 알림 개수 반환
* @param notification
* @return
*/
Map<String, Object> insertNotification(Notification notification);
[serviceImpl]
@Transactional
@Service
@RequiredArgsConstructor
public class SseServiceImpl implements SseService{
private final SseMapper mapper;
// 알림 삽입 후 알림 받을 회원 번호 + 알림 개수 반환
@Override
public Map<String, Object> insertNotification(Notification notification) {
// 매개 변수 notification에 저장된 값
// -> type, url, content, pkNo, sendMemberNo
// 결과 저장용 map
Map<String, Object> map = null;
// 알림 삽입
int result = mapper.insertNotification(notification);
if(result >0) { // 알림 삽입 성공 시
// 알림을 받아야하는 회원의 번호 + 안 읽은 알람 개수 조회
map = mapper.selectReceiveMember(notification.getNotificationNo());
}
return map;
}
[mapper]
@Mapper
public interface SseMapper {
// 알림 삽입
int insertNotification(Notification notification);
[xml]
<insert id="insertNotification"
parameterType="Notification"
useGeneratedKeys = "true">
<selectKey order="BEFORE" resultType="_int"
keyProperty="notificationNo">
SELECT SEQ_NOTI_NO.NEXTVAL FROM DUAL
</selectKey>
INSERT INTO "NOTIFICATION"(
NOTIFICATION_NO,
NOTIFICATION_CONTENT,
NOTIFICATION_URL,
SEND_MEMBER_NO,
RECEIVE_MEMBER_NO)
VALUES(
#{notificationNo},
#{notificationContent},
#{notificationUrl},
#{sendMemberNo},
<choose>
<when test="notificationType == 'insertComment'
or notificationType == 'boardLike'">
(SELECT MEMBER_NO
FROM "BOARD"
WHERE BOARD_NO = #{pkNo})
</when>
<!-- 알림 종류가 답글인 경우 -->
<when test= "notificationType == 'insertChildComment'">
(SELECT MEMBER_NO
FROM "COMMENT"
WHERE COMMENT_NO = #{pkNo})
</when>
</choose>
)
</insert>
<!-- 알림을 받아야하는 회원의 번호 + 안 읽은 알람개수 조회 -->
<select id="selectReceiveMember" resultType="map">
SELECT
RECEIVE_MEMBER_NO "receiveMemberNo",
(SELECT COUNT(*)
FROM "NOTIFICATION" SUB
WHERE SUB.RECEIVE_MEMBER_NO = MAIN.RECEIVE_MEMBER_NO
AND SUB.NOTIFICATION_CHECK = 'N')
"notiCount"
FROM (
SELECT RECEIVE_MEMBER_NO
FROM "NOTIFICATION"
WHERE NOTIFICATION_NO = #{notificationNo}
) MAIN
</select>
** useCeneratedKeys = "true" **
-SQL에서 생성된 key값(시퀀스)을 자바에서도 사용할 수 있게 하는 속성
- 원리 : 전달받은 파라미터에(얕은 복사로 인해 주소만 복사됨)
생성된 key값을 세팅해서 java에서도 사용가능하게 함
3. 비동기로 알림 목록을 조회하는 함수
[header.js]
/**비동기로 알림 목록을 조회하는 함수 */
const selectNotificationList = () => {
// 로그인 안된 경우
if(notificationLoginCheck === false) return;
fetch("/notification") // GET 방식
.then(response => {
if(response.ok) return response.json();
throw new Error("알림 목록 조회 실패");
})
.then(selectList => {
console.log(selectList);
// 이전 알림 목록 삭제
const notiList = document.querySelector(".notification-list");
notiList.innerHTML = '';
for (let data of selectList) {
// 알림 전체를 감싸는 요소
const notiItem = document.createElement("li");
notiItem.className = 'notification-item';
// 알림을 읽지 않은 경우 'not-read' 추가
if (data.notificationCheck == 'N') notiItem.classList.add("not-read");
// 알림 관련 내용(프로필 이미지 + 시간 + 내용)
const notiText = document.createElement("div");
notiText.className = 'notification-text';
// 알림 클릭 시 동작
notiText.addEventListener("click", e => {
// 만약 읽지 않은 알람인 경우
if (data.notificationCheck == 'N') {
fetch("/notification", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: data.notificationNo
})
// 컨트롤러 메서드 반환값이 없으므로 then 작성 X
}
// 클릭 시 알림에 기록된 경로로 이동
location.href = data.notificationUrl;
})
// 알림 보낸 회원 프로필 이미지
const senderProfile = document.createElement("img");
if (data.sendMemberProfileImg == null) senderProfile.src = notificationDefaultImage; // 기본 이미지
else senderProfile.src = data.sendMemberProfileImg; // 프로필 이미지
// 알림 내용 영역
const contentContainer = document.createElement("div");
contentContainer.className = 'notification-content-container';
// 알림 보내진 시간
const notiDate = document.createElement("p");
notiDate.className = 'notification-date';
notiDate.innerText = data.notificationDate;
// 알림 내용
const notiContent = document.createElement("p");
notiContent.className = 'notification-content';
notiContent.innerHTML = data.notificationContent; // 태그가 해석 될 수 있도록 innerHTML
// 삭제 버튼
const notiDelete = document.createElement("span");
notiDelete.className = 'notidication-delete';
notiDelete.innerHTML = '×';
/* 삭제 버튼 클릭 시 비동기로 해당 알림 지움 */
notiDelete.addEventListener("click", e => {
fetch("/notification", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: data.notificationNo
})
.then(resp => {
if(resp.ok) return resp.text();
throw new Error("네트워크 응답이 좋지 않습니다.");
})
.then(result => {
// 클릭된 x버튼이 포함된 알림 삭제
notiDelete.parentElement.remove();
notReadCheck();
})
})
// 조립
notiList.append(notiItem);
notiItem.append(notiText, notiDelete);
notiText.append(senderProfile, contentContainer);
contentContainer.append(notiDate, notiContent);
}
})
.catch(err => console.log(err));
}
[controller]
/**로그인한 회원의 알림 목록 조회
* @param loginMember
* @return
*/
@GetMapping("notification")
public List<Notification> selectNotificationList(
@SessionAttribute("loginMember") Member loginMember
){
int memberNo = loginMember.getMemberNo();
return service.selectNotificationList(memberNo);
}
[service]
/**로그인한 회원의 알림 목록 조회
* @param memberNo
* @return
*/
List<Notification> selectNotificationList(int memberNo);
[serviceImpl]
//로그인한 회원의 알림 목록 조회
@Override
public List<Notification> selectNotificationList(int memberNo) {
return mapper.selectNotificationList(memberNo);
}
[mapper]
/**로그인한 회원의 알림 목록 조회
* @param memberNo
* @return
*/
List<Notification> selectNotificationList(int memberNo);
[xml]
<!--로그인한 회원의 알림 목록 조회 -->
<select id="selectNotificationList" resultType="Notification">
SELECT
NOTIFICATION_NO,
NOTIFICATION_CONTENT,
NOTIFICATION_URL,
PROFILE_IMG AS SEND_MEMBER_PROFILE_IMG,
SEND_MEMBER_NO,
RECEIVE_MEMBER_NO,
NOTIFICATION_CHECK,
CASE
WHEN TRUNC(NOTIFICATION_DATE) = TRUNC(CURRENT_DATE) THEN TO_CHAR(NOTIFICATION_DATE, 'AM HH:MI')
ELSE TO_CHAR(NOTIFICATION_DATE, 'YYYY-MM-DD')
END AS NOTIFICATION_DATE
FROM "NOTIFICATION"
JOIN "MEMBER" ON (SEND_MEMBER_NO = MEMBER_NO)
WHERE RECEIVE_MEMBER_NO = #{memberNo}
ORDER BY NOTIFICATION_NO DESC
</select>
4. 페이지 로딩 완료 후 실행
[header.js]
// 페이지 로딩 완료 후 수행
document.addEventListener("DOMContentLoaded", () => {
connectSse();
// 종 버튼(알림) 클릭 시 알림 목록이 출력하기
const notificationBtn
= document.querySelector(".notification-btn");
notificationBtn?.addEventListener("click", () => {
// 알림 목록
const notificationList
= document.querySelector(".notification-list");
// 알림 목록이 보이고 있을 경우
if( notificationList.classList.contains("notification-show") ){
// 안보이게 하기
notificationList.classList.remove("notification-show");
}
else { // 안보이는 경우
selectNotificationList(); // 비동기로 목록 조회 후
// 화면에 목록 보이게 하기
notificationList.classList.add("notification-show");
}
})
'BackEnd > Spring' 카테고리의 다른 글
| [Spring]Gradle을 이용한 jar 파일 Build 및 배포 (0) | 2024.11.27 |
|---|---|
| [Spring]예외처리 (0) | 2024.10.15 |
| [Spring]게시글 검색 (0) | 2024.10.14 |
| [spring]인터셉터(Interceptor) (0) | 2024.10.14 |
| [Spring]댓글 등록/삭제/등록 (0) | 2024.10.11 |