이번엔 커뮤니티 실시간 푸시알림을 구현했다.
일단 대략적인 기능 설명을 해보자면,
커뮤니티 전역에서 사용하는 Header include 부분에 웹소켓기능을 추가해서,
로그인하면 어느부분에서든 실시간으로 웹소켓이 연결이되고,
글작성자의 글에 댓글, 좋아요, 스크랩,
댓글작성자의 댓글에 좋아요,채택 등의 로직이 일어났을 때,
작성자에게 푸시알림이 가고,
알림들을 모아놓고 볼 수 있는 모달창에서 최대 6까지 알림을 볼 수 있고( 최신순 )
모달창의 버튼에는 알림이 몇개 왔는지 개수가 보이고,
알림을 클릭해서 해당하는 게시글에 들어갈때, 같은 게시글의 알림들은 모두 한번에 없어진다.
푸시알림은 웹소켓을 이용해서 만들었으며,
알림확인 부분은 ajax를 이용해서 만들었다.
웹소켓에 전해지는 이벤트의 데이터를 이용해서 알림정보가 DB에 저장되게 만들고 싶었으나,
웹소켓의 사용에 익숙치 않아,
알림정보는 댓글작성, 채택 등의 로직의 데이터를 이용해서 ajax로 따로 DB에 저장되게 만들었다.
웹소켓 구현
pom.xml
<!-- 웹소켓 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
<version>${org.springframework-version}</version>
</dependency>
스프링에서 웹소켓 라이브러리를 자원해준다.
servlet-context.xml
<!-- websocket handler -->
<beans:bean id="myHandler" class="kr.co.commons.socket.EchoHandler" />
<websocket:handlers>
<websocket:mapping handler="myHandler" path="/alram" />
<websocket:handshake-interceptors>
<beans:bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor"/>
</websocket:handshake-interceptors>
<websocket:sockjs websocket-enabled="true"/>
</websocket:handlers>
라이브러리를 추가했으니 당연히 라이브러리 설정을 해줘야겠다.
웹소켓을 사용하기위한 path값을 잡아주고,
웹소켓을 통해 구현할 알림 기능은 로그인시에 사용할 알림이기 때문에 세션값에서 값을 가져올 수 있는 handshake 기능을 추가해주고,
sockjs를 추가해서 프론트에서 웹소켓 기능을 js로 구현할 수 있도록 추가해준다.
마지막으로 bean class 위치를 지정해주자.
websocket HandshakeInterceptor란,
org.springframework.web.socket.server 패키지에 있으며 HandshakeInterceptor 인터페이스를 구현한 bean은 beforeHandshake()와 afterHandshake()를 수행한다. HandshakeInterceptor 는 HTTP 정보 (특히 쿠키-세션ID)를 옮길 때 많이 사용하는 듯하다.
구현체인 HttpSessionHandshakeInterceptor 에서는 HTTP.SESSION.ID라는 이름으로 JSESSIONID를 옮겨담는다. attributes.put("HTTP.SESSION.ID", session.getId());
만약, spring security와 spring websocket을 같이 쓴다면 HandshakeInterceptor를 구현해서 채팅유저의 인증을 손쉽게 처리할 수 있다. (물론 security 안써도 가능은 하다)
나의 경우는 스프링 시큐리티를 사용하지도 않고,
로그인 시 HttpSession을 사용해서 세션값을 가져올 수 있기 때문에,
따로 필요하진 않은 기능인 것 같았기 때문에,
일단 추가는 했지만, 구현체를 이용해서 사용하지는 않았다.
public class EchoHandler extends TextWebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
//로그인 한 인원 전체
private List<WebSocketSession> sessions = new ArrayList<WebSocketSession>();
// 1:1로 할 경우
private Map<String, WebSocketSession> userSessionsMap = new HashMap<String, WebSocketSession>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {//클라이언트와 서버가 연결
// TODO Auto-generated method stub
logger.info("Socket 연결");
sessions.add(session);
logger.info(currentUserName(session));//현재 접속한 사람
String senderId = currentUserName(session);
userSessionsMap.put(senderId,session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 메시지
// TODO Auto-generated method stub
logger.info("ssesion"+currentUserName(session));
String msg = message.getPayload();//자바스크립트에서 넘어온 Msg
logger.info("msg="+msg);
if (StringUtils.isNotEmpty(msg)) {
logger.info("if문 들어옴?");
String[] strs = msg.split(",");
if(strs != null && strs.length == 6) {
String cmd = strs[0];
String replyWriter = strs[1];
String boardWriter = strs[2];
String bno = strs[3];
String title = strs[4];
String bgno = strs[5];
logger.info("length 성공?"+cmd);
WebSocketSession replyWriterSession = userSessionsMap.get(replyWriter);
WebSocketSession boardWriterSession = userSessionsMap.get(boardWriter);
logger.info("boardWriterSession="+userSessionsMap.get(boardWriter));
logger.info("boardWirterSession"+boardWriterSession);
//댓글
if ("reply".equals(cmd) && boardWriterSession != null) {
logger.info("onmessage되나?");
TextMessage tmpMsg = new TextMessage(replyWriter + "님이 "
+ "<a href='/board/readView?bno="+ bno +"&bgno="+bgno+"' style=\"color: black\">"
+ title+" 에 댓글을 달았습니다!</a>");
boardWriterSession.sendMessage(tmpMsg);
}
//스크랩
else if("scrap".equals(cmd) && boardWriterSession != null) {
//replyWriter = 스크랩누른사람 , boardWriter = 게시글작성자
TextMessage tmpMsg = new TextMessage(replyWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 글을 스크랩했습니다!</a>");
boardWriterSession.sendMessage(tmpMsg);
}
//좋아요
else if("like".equals(cmd) && boardWriterSession != null) {
//replyWriter = 좋아요누른사람 , boardWriter = 게시글작성자
TextMessage tmpMsg = new TextMessage(replyWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 글을 좋아요했습니다!</a>");
boardWriterSession.sendMessage(tmpMsg);
}
//DEV
else if("Dev".equals(cmd) && boardWriterSession != null) {
//replyWriter = 좋아요누른사람 , boardWriter = 게시글작성자
TextMessage tmpMsg = new TextMessage(replyWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 글을 DEV했습니다!</a>");
boardWriterSession.sendMessage(tmpMsg);
}
//댓글채택
else if("questionCheck".equals(cmd) && replyWriterSession != null) {
//replyWriter = 댓글작성자 , boardWriter = 글작성자
TextMessage tmpMsg = new TextMessage(boardWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 댓글을 채택했습니다!</a>");
replyWriterSession.sendMessage(tmpMsg);
}
//댓글좋아요
else if("commentLike".equals(cmd) && replyWriterSession != null) {
logger.info("좋아요onmessage되나?");
logger.info("result=board="+boardWriter+"//"+replyWriter+"//"+bno+"//"+bgno+"//"+title);
//replyWriter=댓글작성자 , boardWriter=좋아요누른사람
TextMessage tmpMsg = new TextMessage(boardWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 댓글을 추천했습니다!</a>");
replyWriterSession.sendMessage(tmpMsg);
}
//댓글DEV
else if("commentDev".equals(cmd) && replyWriterSession != null) {
logger.info("좋아요onmessage되나?");
logger.info("result=board="+boardWriter+"//"+replyWriter+"//"+bno+"//"+bgno+"//"+title);
//replyWriter=댓글작성자 , boardWriter=좋아요누른사람
TextMessage tmpMsg = new TextMessage(boardWriter + "님이 "
+ "<a href='/board/readView?bno=" + bno + "&bgno="+bgno+"' style=\"color: black\"><strong>"
+ title+"</strong> 에 작성한 댓글을 DEV했습니다!</a>");
replyWriterSession.sendMessage(tmpMsg);
}
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {//연결 해제
// TODO Auto-generated method stub
logger.info("Socket 끊음");
sessions.remove(session);
userSessionsMap.remove(currentUserName(session),session);
}
private String currentUserName(WebSocketSession session) {
Map<String, Object> httpSession = session.getAttributes();
MemberVO loginUser = (MemberVO)httpSession.get("login");
if(loginUser == null) {
String mid = session.getId();
return mid;
}
String mid = loginUser.getMemberId();
return mid;
}
}
bean class위치로 지정해준 곳에, 클래스를 생성한다.
로그인 한 인원 전체에게 메시지를 보낼때 사용할 List와, 1:1로 보낼 때 사용할 Map이다.
afterConnectionEstablished는 소켓이 연결됐을 때, 사용되는 메서드이다.
웹소켓 세션을 추가하고,
코드 가장 아래쪽의 currentUserName메서드에서
httpSession.get 으로 세션값이 null이 아닐 때 , 값을 객체에 담아서 return 해준다.
그 값을 1:1전송에 사용할 소켓 map에 put 해준다.
이제 로그인시 사용자가 소켓에 연결됐다.
handleTextMessage는 클라이언트에서 온 메시지를 받는 메서드이다.
뷰의 send 함수를 통해 값이 넘어오고,(socket js)
TextMessage의 getPayload() 으로 웹소켓 세션으로 전달 된 메시지를 받을 수 있다.
msg가 null이 아니고, 뷰에서 보낸 값이 6개라면 값을 읽는다.
String cmd = strs[0]; // 댓글, 스크랩 등의 기능 구분
String replyWriter = strs[1]; // 댓글작성자
String boardWriter = strs[2]; //글작성자
String bno = strs[3]; //게시글 번호
String title = strs[4]; //게시글 제목
String bgno = strs[5]; // 게시글 카테고리
먼저 댓글, 스크랩등의 기능을 구분해서 전달 될 메시지를 바꿀때 사용할 cmd와
상황에 따라 메시지를 받아야 할 소켓 접속자를 구분 할 replyWriter, boardWriter
알림을 누르면 해당 게시글로 이동할 때 사용할 bno, title , bgno이다.
내 경우 하나의 테이블로 다중게시판을 구현 한 경우라서 bgno를 사용한다.(생략가능)
그럴 경우 strs.length 값을 수에 맞게 바꿔주면 되겠다.
WebSocketSession replyWriterSession = userSessionsMap.get(replyWriter);
WebSocketSession boardWriterSession = userSessionsMap.get(boardWriter);
웹소켓으로 전달 될 메시지는 전달받을 사용자가 접속되어 있을 때만 전송되어야 하겠다.
게시글에 댓글을 달면, 게시글 작성가 알림을 받아야 하므로 boardWriterSession 을,
댓글이 채택되거나 좋아요가 눌리면, 댓글 작성자가 알림을 받아야 하므로 replyWriterSession을 사용한다.
cmd의 값이 reply로 전송되었다면, 게시글에 댓글이 달렸을 때 기능 할 로직이다.
게시글 작성자가 소켓에 접속되어 있을 때,
메시지를 전송한다.
메시지는 html 형식으로 작성했으며,
boardWriterSession.sendMessage(tmpMsg); 로 글작성자에게 메시지를 보냈다.
글작성자의 onMessage 함수 ( socket js) 로 뷰단에서 메시지를 받을 수 있다.
나머지 뒤의 부분은 cmd에 따라 메시지가 달라지는 부분이므로 기능적으로 완전히 똑같으니 생략.
afterConnectionClosed은 소켓접속이 끊길 때 사용된다.
뷰
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script type="text/javascript">
var socket = null;
$(document).ready(function(){
if(${login != null}){
connectWs();
}
})
//소켓
function connectWs(){
console.log("tttttt")
var ws = new SockJS("/alram");
socket = ws;
ws.onopen = function() {
console.log('open');
};
ws.onmessage = function(event) {
console.log("onmessage"+event.data);
let $socketAlert = $('div#socketAlert');
$socketAlert.html(event.data)
$socketAlert.css('display', 'block');
setTimeout(function(){
$socketAlert.css('display','none');
}, 5000);
};
ws.onclose = function() {
console.log('close');
};
};
//소켓끝
</script>
커뮤니티 전역에서 사용 할 것이기 때문에,
모든 페이지에서 include 하고 있는 header.jsp에 해당 스크립트를 넣어줬다.
먼저 socket js를 사용해서 웹소켓 기능을 구현 할 것이기 때문에 CDN코드를 추가해준다.
그 후에는 간단하다. bean에서 지정한 path값을 맞춰주고, new SockJs로 함수를 호출한다.
onopen은 소켓이 연결됐을때,
onmessage는 서버에서 메시지를 받았을 때,
onclose는 서버가 닫혔을 때,
on.send는 서버로 메시지를 보낼 때 사용한다.
send를 댓글작성시, 좋아요를 누를시, 스크랩을 누를시 ajax함수내에 넣든, 버튼 id로 연결해서 넣든,
로직사이에 끼워넣으면 되겠다.
댓글등록 ajax
//댓글 등록
function commentInsert(insertData){
console.debug("reply.socket",socket)
$.ajax({
url : '/reply/writeReply',
type : 'post',
data : insertData,
processData: false, contentType: false,
enctype : 'multipart/form-data',
success : function(data){
commentList(); //댓글 작성 후 댓글 목록 reload
$('[name=content]').val('');
$('.myEditor').summernote('reset');
//소켓
if(readWriter != writer){
if(socket){
let socketMsg = "reply,"+writer+","+readWriter+","+bno+","+readTitle+","+bgno;
console.log(socketMsg);
socket.send(socketMsg);
}
}
}
});
댓글 등록 버튼을 눌렀을 때 연결되는 function이다.
댓글작성이 success 됐을때, 작성자와 댓글작성자가 같지 않다면
)
6개의 데이터를 socketMsg에 담고, (예를 들어 // "reply"이 들어가있어서, 서버에서 cmd로 받음)
socket.send로 값을 서버로 보낸다.
댓글작성자가 값을 서버로 보냄 (send) -
서버에서 값에 대응되는 메시지를 글작성자에게 보냄 (handleTextMessage) -
글작성자가 메시지를 받음 (onmessage) -
받은 값을 출력
이렇게 채팅이던 , 푸시알림이던 기능을 구현 할 수 있겠다.
div에 id값을 줘서, 원하는 위치에 해당 값이 출력되도록 했다.
결과
'1234' 회원의 게시판에 'zxcv'회원이 댓글을 작성
'1234'회원에게 푸시알림이 옴
글이 너무 길어질 것 같아, 알림목록 기능은 다음글에 작성하도록 하겠다..
'SPRING > IceWater Community' 카테고리의 다른 글
[스프링] 소셜로그인 구현 - 네이버 (0) | 2021.10.24 |
---|---|
[스프링] 커뮤니티 알림목록 (0) | 2021.10.24 |
[스프링] 메인페이지,인기글 구현 (블라인드 참고) (0) | 2021.10.17 |
[스프링] 회원 프로필사진 수정 , 등록 (3) | 2021.10.14 |
[스프링] 마이페이지 구현 - 회원 활동로그와 작성한 게시글,댓글,스크랩 목록 (0) | 2021.10.13 |