BackEnd/Spring

[Spring] 알림 보내기

Hojung7 2024. 10. 17. 15:46
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 = '&times;';


       /* 삭제 버튼 클릭 시 비동기로 해당 알림 지움 */
       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