프로젝트/협업 프로젝트(2023.12.18-2024.01.25)

[Key Word 개발기] Server-Sent Events ?? 알림기능??

dal_been 2024. 2. 8. 16:15
728x90

협업프로젝트가 마무리 되었지만 아직 알림기능이 구현이 안되었다. 그래서 알림 담당 팀원분이 데모데이끝나고 알림기능을 구현하시고 계시는데 어려우시다고 하셨다.물론 나도 개인프로젝트때 반만 이해하고 했던지라... 좀 초반에 개념을 이해하기 어렵다는 걸 알고 있었다..
그래서 팀원분께 저도 공부하고 있을테니 공부계속하시라고 일단 말씀드리고 나도 개인프로젝트 리팩토링도 할겸 sse에 대해서 공부하게 되었다.
 


HTTP

 
다 알고 있겠지만 HTTP는 비연결성 프로토콜이다. 즉 한번 연결되고, 요청과 응답을 주고 받으면 끝난다.(연결이 끊긴다)
뿐만 아니라 HTTP는 클라이언트 - 서버 아키텍처를 따른다. 음..그니까 서버가 클라이언트의 요청을 수동적으로 기다린다. 
만약 클라이언트가 서버에 요청을 보내면 서버는 요청에대한 응답을 해준다. 
다시말해 서버가 먼저 클라이언트에게 능동적으로 메세지를 보내는 일이 없다.
 
이러한 특성 때문에 실시간 댓글, 실시간 알림과 같은 실시간으로 동작해야하는 기능을 HTTP로는 제공하기 어렵다.
 
그래서 나온 방법들이 크게 Polling, webSocket, SSE가 있다.
 


Polling

 
1. Polling

클라이언트가 일정한 주기로 서버에 업데이트 요청을 보내는 방식이다
지속적으로 요청을 보내야함으로 리소스가 낭비되고 일정주기로 HTTP요청을 반복하기 때문에 완전한 실시간성을 보장하기 어렵다.
 
2. Long Polling
 

앞서 말한 Polling에 비해 유지시간이 길다.
요청을 보낸후 서버에서 변경이 일어날때까지 대기한다.
일단 이벤트 발생할때까지 커넥션이 연결되어있으므로 실시간으로 감지가능하고 지속적으로 요청을 보내지 않으므로 부담이 덜 할 수 있다.
그러나 만약 유지시간이 짧다면 polling과 차이가 없고 지속적으로 연결이 되어있기때문에 많은 클라이언트가 동시에 이벤트를 발생하면 순간적으로 부담이 증가하게 된다.
 
 

WebSocket

 
다들 들어봤겠지만 양뱡향으로 데이터를 주고받을 수 있다.
처음 접속할때는 HTTP요청을 통한 handshaking이 이루어진다.
이후 서버와 브라우저가 지속적으로 TCP라인을 통해 실시간으로 데이터를 주고박게 된다.
주로 채팅과 같은 곳에 많이 쓰인다.
 
 

SSE

클라이언트가 서버와 한번 연결을 맺고 나면 서버에서 이벤트가 발생할때마다 데이터를 전송받은 단뱡향 통신 방식이다.
앞서 말한 polling처럼 주기적으로 http요청을 보낼 필요없이 http연결을 통해 서버에서 클라이언트로 데이터를 전송할 수 있다.
만약 접속에 문제가 있더라도 자동으로 재연결을 시도할 수 있다.
다만 클라이언트가 접속을 닫으면 서버에서 감지하기 어렵다.
 
 
간단하게 개념설명은 끝났다. 오늘은 SSE를 가지고 알림을 어떻게 구현하는지 설명할 것이다.
 


SSE 통신과정

 
일단 클라이언트 측에서 SSE Subscribe 요청을 해야한다
-> 클라이언트가 서버의 이벤트를 구독하기 위한 요청을 전송한다
-> 이때 이벤트의 mediaType은 text/event-stream 표준 스펙으로 정해져 있다
 
이후 서버측에서 subscription에 대한 응답을 해준다.
-> Response의 mediaType은 text/event-stream
-> 서버는 동적으로 생성된 컨텍츠를 스트리밍하기 때문에 본문의 크기를 미리 알 수 없으므로 TransferEncoding 헤더값을 chunked로 설정해야함
 
서버측 이벤트 전송
-> 자신을 구독하고 있는 클라이언트에게 비동기적으로 데이터를 전송할 수 있다
 
 

SSE 구현

 
javascript와 java코드를 이용해 간단하게 구현할 예정이다
관련 깃허브 코드는 여기에 들어가면 있다.
 

SSE - 자바스크립트

앞서 말했듯이 클라이언트가 서버에게 이벤트 구독 요청을 보내야한다.
이 부분을 자바스크립트를 이용하여 구현하였다.

let subscribeUrl = "http://localhost:9091/sub";

$(document).ready(function() {

  getMemos();

  if (sessionStorage.getItem("token") != null) {
    let token = sessionStorage.getItem("token");
    let eventSource = new EventSource(subscribeUrl + "?token=" + token);

    eventSource.addEventListener("addComment", function(event) {
      let message = event.data;
      alert(message);
    })
    eventSource.addEventListener("connect", function(event) {
      let message = event.data;
      console.log(message);
    })

    eventSource.addEventListener("error", function(event) {
      eventSource.close()
    })
  }
})

 
여기서 잘 봐야할 것은 new EventSource를 통해서 SSE이벤트를 어디서 연결(받아올지) 경로를 지정하고, 이벤트 리스너만 설정해주면 끝이다.
이벤트 리스너의 경우 addEventListener를 보면된다. addComment라는 이름으로 이벤트가 발생했다면 alert가 발생하고, comment이벤트가 발생했다면 console.log가 발생한다.
 
 

SSE - 자바

@RequiredArgsConstructor
@Slf4j
@RestController
public class SseController {

  public static Map <Long, SseEmitter> sseEmitters = new ConcurrentHashMap <>();
  private final JwtUtils jwtUtils;


  @CrossOrigin
  @GetMapping(value = "/sub", consumes = MediaType.ALL_VALUE)
  public SseEmitter subscribe(@RequestParam String token) {
  	//토큰 파싱하여 user id값 얻기
    Long userId = jwtUtils.getUserIdFromToken(token);

	//현재 클라이언트을 위한 SseEmitter생성
    SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);
    try {
      //연결
      sseEmitter.send(SseEmitter.event().name("connect"));
    } catch (IOException e) {
      e.printStackTrace();
    }
	//userid값으로 SseEmitter저장
    sseEmitters.put(userId, sseEmitter);

    sseEmitter.onCompletion(() -> sseEmitters.remove(userId));
    sseEmitter.onTimeout(() -> sseEmitters.remove(userId));
    sseEmitter.onError((e) -> sseEmitters.remove(userId));

    return sseEmitter;
  }
}

 
토큰값으로 userid값을 얻을다음에 해당 값으로 사용자별로 SseEmitter를 식별하여 이벤트를 보낼수 있게 한다.
 

@RequiredArgsConstructor
@Service
public class NotificationService {

  private final MemoRepository memoRepository;

  public void notifyAddCommentEvent(Memo memo) {

    Long userId = memo.getUser().getId();

    if (sseEmitters.containsKey(userId)) {
      SseEmitter sseEmitter = sseEmitters.get(userId);
      try {
        sseEmitter.send(SseEmitter.event().name("addComment").data("댓글이 달렸습니다"));
      } catch (Exception e) {
        sseEmitters.remove(userId);
      }
    }
  }
}

 
이 메서드를 이용하여 SseEmitter에 userid가 저장되어있다면 이벤트를 발생해준다.
 

  @PostMapping("/memo/{id}/comment")
  public ResponseEntity addComment(
      @AuthenticationPrincipal UserDetailsImpl userDetails,
      @PathVariable Long id,
      @RequestBody CommentDto commentDto) {
    Memo memo = memoService.addComment(id, userDetails.getUser(), commentDto);
    notificationService.notifyAddCommentEvent(memo);
    return ResponseEntity.ok().build();
  }

위 같이 notificationService메서드를 호출하면 끝!!
 


사실 이방법은 단점이 존재한다.
만약 A가 was1에 연결되어있고 B가 was2에 연결되어있다면 A가 B에게 실시간 알림 전송이 불가능하다.
그때 사용하는게 Redis pub/sub이다. 다음 블로그에 Redis pub/sub과 실시간 알림 기능에 적용해볼 것이다
 
 
https://amaran-th.github.io/Spring/[Spring]%20Server-Sent%20Events(SSE)/
https://hudi.blog/server-sent-events-with-spring/