-
10월 4일 수요일 TIL 회고록카테고리 없음 2023. 10. 5. 00:46
SSE를 사용한 실시간 알림 기능 개발
설정
Springboot의 경우 따로 의존성 추가는 필요없다.
Spring Framework 4.2부터 SSE 통신을 지원하는 SseEmitter 클래스를 이용해 구현할 계획이다.
구현에 앞서서 요구사항을 말로 풀어봤다.
- 로그인 된 사용자만 이벤트를 받으면 되므로 로그인 된 사용자만 서버와 연결되도록 한다. (연결 == 구독)
- 로그인 된 사용자는 loginId, username, 권한 등이 포함된 토큰을 localStorage에 갖는다.
- 페이지가 로드되었을 때 localStorage에 토큰이 있다면 토큰을 쿼리스트링으로 전달하면서 서버와 연결한다.
- ex) http://localhost:8080/sub?token=
- 어떤 게시글에 댓글이 달렸을 때 서버는 해당 게시글의 pk로 해당 게시글의 주인(user)를 찾고 주인의 pk와 매핑된 emitter로 이벤트 스트림을 보낸다.
클라이언트(프론트엔드) 부분 구현
클라이언트(프론트엔드)는 토큰을 보유하고 있을 때만 서버를 구독하면 된다.
function subscribe() { let subscribeUrl = "http://localhost:8080/sub" $(document).ready(function() { if(localStorage.getItem("accessToken") != null) { let token = localStorage.getItem("accessToken"); let accessToken = token.substring(7); let eventSource = new EventSource(subscribeUrl+"?token="+ accessToken); eventSource.addEventListener("addComment" , function(event) { let message = event.data; alert(message); }) eventSource.addEventListener("error", function(event) { eventSource.close() }) } }) }
서버측에서 구독(연결)을 위해 열어놓은 엔드포인트는 /sub이다. 이 엔드포인트에 쿼리스트링으로 토큰값과 함께 요청하고 있다.
// 페이지가 로딩 될 때 실행된다. jQuery(document).ready(function($) { getUserMe(); subscribe(); });
jQuery를 사용하여 페이지가 로딩 될 때 실행되게 만들었다.
서버(백엔드) 부분 구현
SseController.class
@RequiredArgsConstructor @Slf4j @RestController public class SseController { public static Map<String, SseEmitter> sseEmitters = new ConcurrentHashMap<>(); private final JwtUtil jwtUtil; @CrossOrigin @GetMapping(value = "/sub", consumes = MediaType.ALL_VALUE) public SseEmitter subscribe(@RequestParam String token) throws IOException { // 토큰에서 user의 pk값 피싱 String userId = jwtUtil.getUserInfoFromToken(token); // 현재 클라이언트를 위한 SseEmitter 생성 SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE); try { // 연결 sseEmitter.send(SseEmitter.event().name("connect")); } catch (IOException e) { e.printStackTrace(); } // user의 pk값을 key값으로 해서 SseEmitter를 저장 sseEmitters.put(userId, sseEmitter); // 세션이 종료되거나 에러가 발생 시 저장한 SseEmitter를 삭제한다. sseEmitter.onCompletion(() -> sseEmitters.remove(userId)); sseEmitter.onTimeout(() -> sseEmitters.remove(userId)); sseEmitter.onError((e) -> sseEmitters.remove(userId)); return sseEmitter; } }
가장 중요한 부분은 전달받은 토큰에서 user의 loginId 값을 파싱하고 파싱된 값을 키 값으로 하여 SseEmitter를 저장하였다.
이로써 사용자별로 SseEmitter를 식별하여 이벤트를 보낼 수 있게 되었다.
NotificationService.class
@RequiredArgsConstructor @Service public class NotificationServiceImpl implements NotificationService { private final CommentRepository commentRepository; private final BasicBoardRepository basicBoardRepository; @Override public void notifyAddCommentEvent(Long boardId) { BasicBoard basicBoard = basicBoardRepository.findById(boardId).orElseThrow( () -> new CustomException(ExceptionStatus.NOT_EXIST_BOARD) ); String userId = basicBoard.getUser().getLoginId(); if (sseEmitters.containsKey(userId)) { SseEmitter sseEmitter = sseEmitters.get(userId); try { sseEmitter.send(SseEmitter.event().name("addComment").data("댓글이 달렸습니다!")); } catch (Exception e) { sseEmitters.remove(userId); } } } }
알림(이벤트)를 보내기 위한 클래스이다. notifyAddCommentEvent 메서드는 댓글에 대한 처리를 마친 후에 호출된다.
파라미터로 받은 게시글의 주인(user)의 LoginId를 조회한다.
우리는 유저의 loginId와 연결된 SseEmitter 저장소를 가지고있다.
SseEmitter 저장소에 현재 유저의 loginId가 존재한다면 (게시글의 주인이 현재 서버와 연결되어 있는 상태라면) 이벤트를 발생시킨다.
// 토큰에서 user의 pk값 피싱 String userId = jwtUtil.getUserInfoFromToken(token); // sseEmitters에 userId가 있으면 이벤트 발생 if (sseEmitters.containsKey(userId)) { SseEmitter sseEmitter = sseEmitters.get(userId); try { sseEmitter.send(SseEmitter.event().name("addComment").data("댓글이 달렸습니다!")); } catch (Exception e) { sseEmitters.remove(userId); } }
CommentController.class
// 댓글 작성 @PostMapping("/comments") public ResponseEntity<String> createComment(@RequestParam Long basicBoardId, @AuthenticationPrincipal UserDetailsImpl userDetails, @RequestBody CommentRequest commentRequest, Long commentId) { basicBoardService.getBasicBoardAndCheck(basicBoardId); commentService.createComment(basicBoardId, userDetails.getUser(), commentRequest, commentId); notificationService.notifyAddCommentEvent(basicBoardId); return new ResponseEntity<>("댓글 작성 완료!", HttpStatus.CREATED); }
댓글 작성 시 notificationService.notifyAddCommentEvent 메서드를 실행시킨다.
실행 결과
Postman을 사용하여 1번 게시글에 댓글을 달 시 댓글이 달렸습니다! 라고 알림이 나온다.
반대로 Postman을 사용해서 만든 게시글에 댓글을 달고 Postman에서 확인해보았다.
Postman에서도 SSE가 정상 작동하는 것을 볼 수 있다.
문제가 있었던 부분
문제
sseEmitters에서 키값을 넣을 때 문제가 있었다. 키값이 null이 들어가면서 에러가 출력되었다.
sseEmitters에 넣어질때 키값이 1,2 이런식이 아닌 로그인 아이디가 들어갔다. (ex : user12)
해결
참고한 블로그에서 jwtUtils.getUserIdFromToken 메서드는 Long 타입 userId를 반환하는 메서드였다.
하지만 나는 사용자 정보를 가져오는 getUserInfoFromToken 메서드를 사용하였다. Long 타입이 아닌 String 타입이고
반환값은 Long 타입 userId가 반환되지 않고 String 타입 LoginId가 반환된다.
// 토큰에서 사용자 정보 가져오기 // 사용자 정보를 가져오는 것은 이미 vaildateToken() 으로 검증을 했기 때문에 이거는 유효한 토큰 // 이라고 가정을 해서 여기는 try/catch가 없다. public String getUserInfoFromToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody() .getSubject(); } // 토큰에서 user의 pk값 피싱 String userId = jwtUtil.getUserInfoFromToken(token); // user의 loginId를 key값으로 해서 SseEmitter를 저장 sseEmitters.put(userId, sseEmitter);
jwtUtil.getUserIfoFromToken 메서드를 사용하여 loginId를 userId에 넣는다.
그 후 유저의 loginId를 키값으로 해서 SseEmitter를 저장한다.
NotificationService.class
String userId = basicBoard.getUser().getLoginId(); if (sseEmitters.containsKey(userId)) { SseEmitter sseEmitter = sseEmitters.get(userId); try { sseEmitter.send(SseEmitter.event().name("addComment").data("댓글이 달렸습니다!")); } catch (Exception e) { sseEmitters.remove(userId); } }
서비스 부분에서는 게시글을 작성한 유저의 로그인 아이디를 userId에 넣은 후 if문을 사용해서 이벤트를 발생시켰다.