트래블 캐리어엔 인스타그램과 유사한 알림 기능이 필요했다. 주제가 <함께 쓰는 여행 기록>이므로, 팔로워 및 게시판 관련 알림들을 구현하고자 했다.
웹사이트의 알림들을 보면, 실시간으로 알림을 만들어주는 사이트도 있고, 일정 시간마다 알림이 업데이트 되는 경우도 있었다. 대부분의 SNS가 전자 형식이었고, Okky라는 개발자 커뮤니티의 경우 후자의 방식을 택하고 있었다.
나는 실시간 알림을 구현하고 싶었고, 이를 위해 총 세가지 방식에 대해 공부하게 되었다.
1. WebSocket
2. Polling / Long Polling
3. Server-Client-Events(SSE)
WebSocket을 활용한 실시간 알림
웹소켓은 대학시절 IoT수업 실습에서 사용해 본 적이 있는 기술이다. 대표적으로 실시간 채팅을 구현할때 많이 사용한다.
서버와 클라이언트간의 양방향 통신 프로토콜로, 스프링부트에서는 Spring Websocket을 통해 구현한다. 백문이 불여일견, 바로 구현해보자.
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new ReplyEchoHandler(), "/replyEcho").setAllowedOrigins("*");
}
}
우선 위와같이 config클래스를 작성해준다. /replyEcho 경로의 요청에 대해 동작하는 핸들러인것이다.
public class ReplyEchoHandler extends TextWebSocketHandler {
@Override
// 웹소켓 커넥션이 연결되었을때, 즉 클라이언트가 웹서버에 접속했을때 실행
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("afterConnectionEstablished : "+session);
}
@Override
// 소켓에 메세지를 보냈을때 실행
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("handleTextMessage: "+session +", "+message);
}
@Override
// 커넥션이 클로즈 됐을때 실행
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
System.out.println("afterConnectionClosed : "+session);
}
}
그리고 소켓에 연결하고, 메세지를 보내고, 연결을 종료하는 메소드들을 override한 클래스를 작성한다.
뿐만아니라, 테스트를 위해 특정 댓글이 현재 접속한 모두에게 브로드 캐스팅 되는 코드도 작성해보자.
List<WebSocketSession> sessions = new ArrayList<>();
@Override
// 웹소켓 커넥션이 연결되었을때, 즉 클라이언트가 웹서버에 접속했을때 실행
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("afterConnectionEstablished : "+session);
sessions.add(session); //sessions에 현재 접속한 모든 세션들을 담는다.
}
@Override
// 소켓에 메세지를 보냈을때 실행
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("handleTextMessage: "+session +", "+message);
//접속된 모든사람에게 알림 보내기 : 브로드캐스팅하기
String senderId = session.getId();
for(WebSocketSession sess : sessions){
sess.sendMessage(new TextMessage(senderId + ": " +message.getPayload()));
}
}
(위 브로드 캐스팅 메소드에 의해 아래에 나오는 ws.onmessage 함수가 실행된다)
// 소켓 세팅
var socket = null;
connect();
function connect(){
var ws = new WebSocket("ws://localhost:8080/replyEcho?bno=1234");
socket = ws;
ws.onopen = function(){
console.log("Info : connection Opened.");
};
ws.onmessage = function(event){
console.log(event.data+'\\n');
};
ws.onclose = function(event) {
console.log('Info : connection closed.');
//setTimeout(function(){ connect(); },1000); //retry connection
};
ws.onerror = function(event) {console.log('Error : '+error);};
}
// 댓글등록 ajax후 실행되는 소켓통신
function testSocket(text){
if(socket.readyState !== 1) return;
let msg =text;
socket.send(msg);
}
타임리프에선 위와 같이 클라이언트 코드를 작성해주었다. /replyEcho 경로를 통해 커넥션을 만든다.
이제 해당 화면에 접속한 유저들은 소켓에 접속하게 되고, 댓글등록을 하게 되면 미리 접속해있던 다른 유저에게 댓글 내용이 표시될것이다.
서로 다른 유저로 로그인했을때, 왼쪽 댓글을 작성하면 오른쪽과 같은 소켓 통신이 완료된다.
Long Polling을 활용한 실시간 알림
Polling 방식은 간단한 개념으로, 클라이언트가 일정 간격으로 서버에 새로운 알림이 있는지 확인하는 요청을 보내는 방식이다. Long Polling은 이 방식의 발전된 형태로, 클라이언트가 서버에 요청을 보내면 서버는 즉시 응답하지 않고, 새로운 알림이나 이벤트가 발생할 때까지 응답을 보류한다. 그러다 새로운 알림이 발생하면 그 즉시 클라이언트에 응답을 보내고, 클라이언트는 응답을 받은 후 다시 새로운 요청을 보낸다.
다만 이 방식은 댓글알림과 같이 서버에서 빈번히 발생하는 이벤트에 사용하기엔 오버헤드가 크다고 판단되어 사용하지 않았다.
SSE를 활용한 실시간 알림
SSE는 단방향 통신을 통해 서버에서 클라이언트로 실시간 이벤트를 전송하는 방식이다. 즉, 알림 메세지를 서버에서 클라이언트로 보낼 수 있다. 처음 HTTP 연결을 맺고나면 서버는 클라이언트로 계속해서 데이터를 전송할 수 있다. 스프링부트에서는 SseEmitter를 활용해서 구현할 수 있다.
최종적으로는 이 방식을 선택했다. 그 이유는 SSE는 단방향 통신이기 때문에 웹소켓에 비해 리소스가 덜 사용되기 때문이다. 무엇보다도 실시간 알림의 경우엔 서버->클라이언트로의 단방향 통신만 필요하지, 사용자간의 통신인 양방향 통신은 필요하지 않았다.
구현은 다음과같다.
success: function (response) {
alert("로그인 성공 : "+response);
notification();
//location.href="/TravelCarrier/";
},
우선 클라이언트에서 서버에 연결요청을 해야한다. 로그인을 한 유저에게 알림을 보내줄것이므로, 로그인 성공시 SSE 연결을 시도하는 코드를 실행한다.
//notification
function notification() {
let eventSource = new EventSource("<http://localhost:8080/sub>");
eventSource.addEventListener("notification", function(event) {
let message = event.data;
console.log("message");
alert(message);
let notification_content = document.getElementById('noti');
let notification_container = document.getElementById('noti');
notification_content.textContent = message;
notification_container.classList.add('show');
setTimeout(() => {
notification_container.classList.remove('show');
}, 2000);
});
eventSource.addEventListener("error", function(event) {
eventSource.close();
});
}
eventSource 객체를 통해 연결소스를 구현한다. addEventListner를 통해 어떤 응답을 받을지 구조화한다. 내경우 테스트를 위해 "notification"이라는 SSE 요청에 대해 알림을 발생시켜줄 것이다.
@RequiredArgsConstructor
@Controller
public class NotificationController {
private final NotificationService notificationService;
// userId와 sseEmitter객체를 매핑해서 static으로 전역공유
**public static Map<Integer, SseEmitter> sseEmitters = new ConcurrentHashMap<>();**
@GetMapping(value = "/sub", produces = "text/event-stream")
public SseEmitter subscribe(@AuthenticationPrincipal User principalDetails,
@RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId) {
int userId = principalDetails.getId();
return notificationService.subscribe(userId);
}
}
백엔드에서 중요한것은 static을 활용해서 SSE Emitter 객체들을 관리해주는 것이다. 우리 서버에 로그인한 모든 유저들이 고유의 SSE 객체를 갖고 있을것 이므로, 서로에게 알림을 전송해주기 위해 전역으로 설정한다.
@Service
@RequiredArgsConstructor
public class NotificationService {
public SseEmitter subscribe(int userId) {
// 현재 클라이언트를 위한 SseEmitter 생성
SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);
try {
System.out.println("연결 시도");
// 연결!!
sseEmitter.send(SseEmitter.event().name("connect"));
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("연결 됨");
// user의 pk값을 key값으로 해서 SseEmitter를 저장
NotificationController.sseEmitters.put(userId, sseEmitter);
sseEmitter.onCompletion(() -> NotificationController.sseEmitters.remove(userId));
sseEmitter.onTimeout(() -> NotificationController.sseEmitters.remove(userId));
sseEmitter.onError((e) -> NotificationController.sseEmitters.remove(userId));
return sseEmitter;
}
}
연결을 시도했을때 객체를 등록하는 코드이다. user의 pk값을 key로 함으로써 빠르게 상대를 찾을 수 있게 구현했다.
public int saveReply(Reply reply){
int replyId = replyRepository.save(reply);
**SseEmitter sseEmitter = NotificationController.sseEmitters.get(20);
try {
sseEmitter.send(SseEmitter.event().name("notification").data("새로운 글을 업로드했습니다!"));
} catch (Exception e) {
NotificationController.sseEmitters.remove(20);
}**
return replyId;
}
댓글을 작성했을때 해당 글의 작성자에게 데이터를 전송하는 코드를 작성한다. 테스트를 위한것이기 때문에 DB조회 없이 바로 20번 사용자에게 알림을 전송했다.
위와같이 정상적으로 알림이 생긴다! 이제 해당 데이터를 html을 통해 정상적인 알림로직으로 변경하면 된다.
다양한 방식으로 실시간 알림을 구현하는 방식을 알아보고, SSE라는 가장 적합한 방식을 도출해 보았다. HTTP 통신은 클라이언트->서버로만 요청할수있기 때문에 초반에 많은 어려움을 겪었는데, okky에서 자문도 받아보고, 검색도 많이 해보면서 서버->클라이언트 방식의 단방향 통신을 알게 되었다.
작년 초에 작성했던 코드긴 하지만, 이 경험을 바탕으로 SSAFY의 다른 프로젝트에서 SSE를 활용해 통신상 문제점을 해결하고, 아키텍처를 주도적으로 설계할 수 있었다. 이것도 나중에 포스팅할 예정이다 😀
참고로 위 코드의 경우는 구현과정에서 작성했던 개발일지를 바탕으로 작성한 내용으로, 현재는 다양한 알림을 전송할 수 있도록 이벤트명을 Enum화 하는 등 조금 더 업그레이드 해 두었다.
자세한 코드는 여기에서 볼 수 있다!
'프로젝트 > 트래블 캐리어' 카테고리의 다른 글
Spring MultipartFile 이미지 리사이징, 회전 및 AWS S3서버 저장 (5) | 2024.11.07 |
---|