[네트워크] SSE로 구현하는 채팅

Coastby·2022년 12월 19일
2

기타

목록 보기
9/12

SSE로 구현하는 채팅

폴링 vs SSE

실시간인 것처럼 작동하는 방법

💡 클라이언트의 요청이 있기 전까지는 서버는 어떠한 데이터도 줄 수 없다.

1) 폴링 통신방식

  • 클라이언트가 n초 간격으로 request (GET /poll)를 서버로 계속 날려서 Response를 전달받는 방식
  • 장점
    • 비교적 구현이 쉽다. (ajax 통신을 주기적으로 요청하는 것만으로도 구현 가능)
  • 단점
    • 서버측에 보낼 내용이 없어도 클라이언트는 알 수 없기 때문에 계속해서 request를 보내야 한다.
    • HTTP는 단발성 통신이기 때문에 header가 매우 무거운 통신 중 하나로 이 프로토콜이 계속해서 request를 날리면 서버의 부담이 증가한다.
    • 초 간격을 늘리면 실시간성이라고 보기 어렵다.

2) Long Polling

롱 폴링은 클라이언트에서 서버에 요청을 날리면 서버에서 바로 응답을 해주는 것이 아니라, 업데이트 내용이 생기면 응답을 해주는 방식이다. 클라이언트에서는 응답을 받으면 다시 서버로 업데이트된 사항을 요청한다.

  • 장점
    • 폴링에 비해 좀 더 실시간에 가까운 통신을 구현할 수 있다.
  • 단점
    • 서버에서 응답할 내용이 있을 때까지 커넥션을 유지하야해서 클라이언트의 수가 많아질 경우 서버의 부하가 발생한다. (poll과 유사)
    • 동시에 많은 수의 연결이 요청되고, 반복되면 서버에 갑작스런 부하가 발생할 수 있다.

3) SSE (Server-Sent-Events)

  • SSE 방식이 어떤식으로 폴링 방식의 비효율을 개선하는가?
    • 서버와 한 번 커넥션을 맺어두면 서버에서 이벤트가 발생하면 지속적으로 데이터를 푸시하는 형태로 동작한다.
  • 자바스크립트를 이용하여 서버의 데이터를 실시간, 지속적으로 Streaming하는 기술
  • html5 표준, 웹소켓의 역할
  • 양방향이 아닌 server → client 단방향
  • 재접속의 저수준 처리 자동지원
  • 서버에서 클라이언트로의 단방향 통신만 가능하기 때문에 클라이언트의 상호작용 없이 서버로부터 데이터가 전달되어야 하는 상황에 적합하다.
  • 대표적으로 스포츠 중계 서비스SNS 피드 같은 성격의 서비스에서 사용을 고려해볼 수 있다.

https://ssup2.github.io/theory_analysis/Web_Polling_Long_Polling_Server-sent_Events_WebSocket/

실시간을 구현할 때, 상황에 따라

  • ajax
  • polling
  • SSE
  • 웹소켓 (STOMP)

순서로 구현해본다.

채팅앱 구조

채팅앱 만들기

전체 코드 : https://github.com/coastby/prac_1219_chatting_app

○ 메세지 쓰는 api 만들기

  1. POST /chat/messages api 만들기
    • ChatController
      @RestController
      @RequestMapping("/chat")
      public class ChatController {
          @PostMapping("/messages")
          public String writeMessage(){
              return "메세지가 작성되었습니다.";
          }
      }
  2. RsData 만들기
    • RsData
      @Getter
      @AllArgsConstructor
      public class RsData<T> {
          private String resultCode;
          private String msg;
          private T data;
      }
  3. ChatMessage 만들기
    • ChatMessage
      @AllArgsConstructor
      public class ChatMessage {
          private Long id;
          private LocalDateTime createdDate;
          private String authorName;
          private String content;
      
          public ChatMessage(String authorName, String content) {
              this(ChatMessageIdGenerator.genNextId(), LocalDateTime.now(), authorName, content);
          }
      }
    • ChatMessageIdGenerator
      class ChatMessageIdGenerator{
          private static Long id = 0L;
          public static long genNextId(){
              return id++;
          }
      }
  4. controller 수정해서 원하는 정보 넣기
    • controller
      @PostMapping("/writeMessage")
      public RsData<ChatMessage> writeMessage(){
          ChatMessage message = new ChatMessage("hoon", "hi");
          return new RsData<>("S-1", "메세지가 작성되었습니다.", message);
      }
  5. message 저장할 list controller안에 만들기
    • controller
      ```java
      @RestController
      @RequestMapping("/chat")
      public class ChatController {
          private List<ChatMessage> messageList = new ArrayList<>();
          public record WriteMessageResponse(Long id){
          }
          @PostMapping("/writeMessage")
          public RsData<WriteMessageResponse> writeMessage(){
              ChatMessage message = new ChatMessage("hoon", "hi");
              messageList.add(message);
              return new RsData<>("S-1", "메세지가 작성되었습니다.", new WriteMessageResponse(message.getId()));
          }
      }
      ```

○ 메세지 목록 받는 api 만들기

  1. controller 만들기

    • controller
      @GetMapping("/messages")
      public RsData<List<ChatMessage>> getMessages(){
          return new RsData<>("S-1", "성공했습니다.", messageList);
      }
  2. 특정 아이디부터메세지 받아오기

    ?fromId=3으로 오면 4부터 메세지 보여주기

    defaultValue 속성을 이용하여 파라미터가 없을 경우 기본값을 설정할 수 있다. → string만 가능하다.

    ⭐️ required = false를 사용한다. Long이 null값으로 들어온다.

    @GetMapping("/messages")
    public RsData<List<ChatMessage>> getMessages(@RequestParam(required = false) Long fromId){
        log.info("fromId : {}", fromId);
        long idx = -1;
        if (fromId != null){
            for (long i = messageList.size()-1; i >= 0; i--){
                if (messageList.get((int) i).getId() <= fromId){
                    idx = i;
                    break;
                }
            }
        } else {
            return new RsData<>("S-1", "성공했습니다.", messageList);
        }
        return new RsData<>("S-1", "성공했습니다.", messageList.subList((int) (idx+1), messageList.size()));
    }
  3. 데이터 6개를 넣고 requestparam 없이 조회를 하게되면 모든 메세지가 나온다.

  4. request param을 지정해주게 되면 그 번호 이후의 아이디를 가진 메세지들만 가져온다.

  5. for 문을 stream()으로 바꿔보았다.
    Longstream.range()에서 내림차순으로 하기 위해서는 sorted() 또는 map()을 이용해야 한다.

    idx = LongStream.range(0, messageList.size())
              .map(i -> messageList.size()-1-i)
              .filter(i -> messageList.get((int) i).getId() <= fromId)
              .findFirst()
              .orElse(-1);

○ 클라이언트 만들기

  1. html form 만들기

    return false : 원래 버튼을 누르면 redirct가 되는 데 이를 막고 ajax로 몰래 chat으로 이동해야 한다.

    <a href="주소" onclick="**return false;**">링크</a>
    <!-- 위와 같다. -->
    <a href="주소" onclick="event.preventDefault(); event.stopPropagation();">링크</a>
    <div class="chat">
      <form action="" target="_blank" onsubmit="return false;">
            <input name="authorName" type="text" placeholder="작성자">
            <input name="content" type="text" placeholder="내용">
            <input type="submit" value="작성">  
      </form>  
    </div>
  2. **채팅폼이 발송되기전에 폼체크**

    <form action="" target="_blank" onsubmit="Chat__submitWriteMessageForm(this); return false;">
    function Chat__submitWriteMessageForm(form){
      if (form.authorName.value.trim().length == 0){
        form.authorName.focus();
        alert("작성자를 입력해주세요.");
        return;
      }
      if (form.content.value.trim().length == 0){
        form.content.focus();
        return;
      }
      // POST http://localhost:8080/chat/messages
      form.content.value = "";  
    }

JsonPlaceholder

https://jsonplaceholder.typicode.com/

개발자 테스트용으로 응답을 받을 수 있다. free fake API testing and prototyping.

ajax를 쓰기 위해서는 라이브러리를 복붙해서 쓴다.

  1. 유틸리티 가져오기

    function fetchPost(url, data) {
        return fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json"
            },
            body: JSON.stringify(data),
        })
            .then(response => response.json())
    }
    
    function fetchGet(url, data) {
        let query = Object.keys(data)
            .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k]))
            .join('&');
    
        return fetch(url + "?" + query, {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                "Accept": "application/json"
            }
        })
            .then(response => response.json())
    }
  2. 유틸리티를 이용해서 요청을 ajax로 보내기

    💡 이 함수는 비동기로 처리된다.
    보통 1초 정도 걸리기 때문에 비동기처리를 안 하면, 그 동안 자바스크립트가 다 중단된다.
    function Chat__submitWriteMessageForm(form){
    	...
    	// ajax를 사용해서 fetch로 보내기 (json placeholder 이용)
      fetchPost("https://jsonplaceholder.typicode.com/posts", {
                         authorName: form.authorName.value,
                         content: form.content.value
                         }).then((data) => console.log(data));
    	form.content.value = "";
    }

문제 1

: 아래 엔드포인트에 ajax 방식으로 요청해서 받아온 응답을 console.log 로 출력해주세요.

  • 엔드포인트 : GET https://jsonplaceholder.typicode.com/posts
<button onclick="getMessage(); return false;">목록 가져오기</button>
function getMessage() {
  fetchGet("https://jsonplaceholder.typicode.com/posts", {})
  .then((data) => console.log(data));
}

문제 2

: 아래 엔드포인트에 ajax 방식으로 요청해서 받아온 응답을 console.log 로 출력해주세요.

  • 엔드포인트 : GET https://jsonplaceholder.typicode.com/comments?postId=1
<form action="" target="_black" onsubmit="getAMessage(this); return false;">
  <input name="postId" type="text" placeholder="0">
  <input type="submit" value="메세지 가져오기">
</form>
function getAMessage(form){
  fetchGet("https://jsonplaceholder.typicode.com/comments",{
    postId: form.postId.value
  }).then((data) => console.log(data))
}

○ thymeleaf로 적용하기

  1. thymeleaf 수정하기

    • application.yml
      spring:
        thymeleaf:
          cache: false
          prefix: file:src/main/resources/templates/
        devtools:
          livereload:
            enabled: true
          restart:
            enabled: true
  2. room.html 작성하기

    • controller
      @GetMapping("/room")
      public String showRoom(){
          return "chat/room";
      }
    • room.html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <title>Title</title>
      </head>
      <body>
      <div class="chat">
        <form class="chat__write-message" onsubmit="Chat__writeMessage(this); return false;">
          <input type="text" placeholder="작성자" name="authorName">
          <input type="text" placeholder="내용을 입력해주세요." name="content">
          <input type="submit" value="작성">
        </form>
        <div class="chat__message-box">
          <ul class="chat__message-ul">
      
          </ul>
        </div>
      </div>
      
      <script>
        function fetchPost(url, data) {
          return fetch(url, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "Accept": "application/json"
            },
            body: JSON.stringify(data),
          })
                  .then(response => response.json())
        }
      
        function fetchGet(url, data) {
          let query = Object.keys(data)
                  .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k]))
                  .join('&');
      
          return fetch(url + "?" + query, {
            method: "GET",
            headers: {
              "Content-Type": "application/json",
              "Accept": "application/json"
            }
          })
                  .then(response => response.json())
        }
      </script>
      
      <script>
        function Chat__writeMessage(form) {
          form.authorName.value = form.authorName.value.trim();
      
          if (form.authorName.value.length == 0) {
            alert("작성자를 입력해주세요.");
            form.authorName.focus();
      
            return;
          }
      
          form.content.value = form.content.value.trim();
      
          if (form.content.value.length == 0) {
            form.content.focus();
      
            return;
          }
      
          fetchPost("/chat/messages", {
            authorName: form.authorName.value,
            content: form.content.value
          })
                  .then(console.log);
      
          form.content.value = '';
          form.content.focus();
        }
      </script>
      </body>
      </html>
  3. 동작해보기

    1. http://localhost:8080/chat/room 에서 작성자와 내용을 입력하고 작성 버튼을 누르면 console에 찍힌다.
    2. http://localhost:8080/chat/messages에 들어가면 작성한 메세지가 나온다.

  4. 채팅 메세지들 html로 띄우기

    #로드하는 버튼 생성
    <button onclick="Chat__loadMore();">로드</button>
    // 채팅 메세지들 읽기 시작
      // 현재 클라이언트가 받은 메세지 번호를 입력해야 합니다.
      // 그래야 메세지 요청시에 필요한 부분만 가져오게 됩니다.
      let Chat__lastLoadedId = 0;
    
      function Chat__loadMore() {
        fetchGet("/chat/messages", {
          fromId: Chat__lastLoadedId
        })
                .then(body => {
                  Chat__drawMessages(body.data);
                });
      }
    
      const Chat__elMessageUl = document.querySelector('.chat__message-ul');
    
      function Chat__drawMessages(messages) {
        if (messages.length == 0) return;
    
        // 메세지를 그리기 전에 Chat__lastLoadedUuid 변수를 갱신합니다.
        Chat__lastLoadedId = messages[messages.length - 1].id;
    
        messages.forEach((message) => {
          Chat__elMessageUl
                  .insertAdjacentHTML(
                          "afterBegin",
                          `<li>${message.authorName} : ${message.content}</li>`
                  );
        });
      }
    
      // 채팅 메시지들 읽기 끝
  5. polling 적용하기
    a. 위의 로드 버튼을 지운다.
    b. Chat__loadMore 호출 → Chat__drawMessages 호출 → 안에 Chat__loadMore를 둬서 재귀 호출이 되게 한다. 그냥 재귀로 하면 1초에 수십번 호출이 가므로 시간을 정해서 0.5초에 한번씩 실행이 되게 한다.

    function Chat__drawMessages(messages) {
    if (messages.length > 0) {
      // 메세지를 그리기 전에 Chat__lastLoadedUuid 변수를 갱신합니다.
      Chat__lastLoadedId = messages[messages.length - 1].id;
    }
    
    messages.forEach((message) => {
      Chat__elMessageUl
              .insertAdjacentHTML(
                      "afterBegin",
                      `<li>${message.authorName} : ${message.content}</li>`
              );
    });
    //Chat__loadMore();               //즉시 실행
    setTimeout(Chat__loadMore, 500);  //지금은 아니고 0.5초 후에 실행
    }
    // 채팅 메시지들 읽기 끝
    Chat__loadMore();
  6. 실행

○ SSE 적용하기

SSE가 하는 역할 : 브라우저에게 새 메세지가 들어왔다고 노티를 해준다.

묻지마 호출을 빼고 한 번만 호출하는데 SSE가 노티를 하면 호출을 하도록 한다.

클라이언트 원래 요청과는 별개로 SSE 요청을 보낸다.

서버에서는 이 SSE 요청을 SSE emitter에 모아놓는다.

원하는 변경이 생기면 emitter에서 SSE를 요청했던 브라우저에게 노티를 해준다.

클라이언트에 추가

대부분의 브라우저에서 sse를 지원한다. 아래 코드를 통해 sse를 갖고 있으면 언제든 서버에서 응답을 받을 수 있다.

클라이언트에서는 EventSource 라는 인터페이스로 SSE 연결 요청을 할 수 있다.

const sse = new EventSource("/sse/connect");

어떤 요청에 따라서 이벤트를 발생시킬 수 있다.

chat__messageAdded 지령이 내려오면, Chat__loadMore(); 을 수행한다.

sse.addEventListener('chat__messageAdded', e => {
  Chat__loadMore();
})

백엔드 작업

spring에서는 SSE 통신을 지원하는 SseEmitter API를 제공한다. 이를 이용하여 SSE 구독 요청에 대한 응답을 할 수 있다.

  1. Map을 쉽게 만드는 helper 함수

    public class Ut {
        public static <K, V> Map<K, V> mapOf(Object... args) {
            Map<K, V> map = new LinkedHashMap<>();
    
            int size = args.length / 2;
    
            for (int i = 0; i < size; i++) {
                int keyIndex = i * 2;
                int valueIndex = keyIndex + 1;
    
                K key = (K) args[keyIndex];
                V value = (V) args[valueIndex];
    
                map.put(key, value);
            }
    
            return map;
        }
    }
  2. controller에 SseEmitters DI

    ArrayList 처럼 sseEmitter를 모아놓은 클래스

    • controller
      @Controller
      @RequestMapping("/chat")
      @Slf4j
      @RequiredArgsConstructor
      public class ChatController {
          **private final SseEmitters sseEmitters;**
          private List<ChatMessage> messageList = new ArrayList<>();
      
      		...
      
          @PostMapping("/messages")
          @ResponseBody
          public RsData<WriteMessageResponse> writeMessage(@RequestBody WriteMessageRequest request){
              ChatMessage message = new ChatMessage(request.getAuthorName(), request.getContent());
              messageList.add(message);
              **sseEmitters.noti("chat__messageAdded");   //메세지가 추가되면 noti**
              return new RsData<>("S-1", "메세지가 작성되었습니다.", new WriteMessageResponse(message.getId()));
          }
    • SseEmitters
      SseEmitter를 생성할 때 비동기 요청이 완료되거나 타임아웃 발생 시 실행할 콜백을 등록할 수 있다. 타임아웃이 발생하면 브라우저에서 재연결 요청을 보내면, 새로운 Emitter 객체가 생기므로 기존의 Emitter객체는 제거해줘야 한다. onCompletion 콜백으로 자기 자신을 지우도록 한다.
      이 때 콜백이 SseEmitter를 관리하는 다른 스레드에서 실행될 수 있다. 따라서 thread-safe한 자료구조인 **CopyOnWriteArrayList를 이용한다.** 
      
      ```jsx
      @Component
      @Slf4j
      public class SseEmitters {
          private final List<SseEmitter> emitters = new **CopyOnWriteArrayList**<>();
      
          public SseEmitter add(SseEmitter emitter) {
              this.emitters.add(emitter);
              emitter.onCompletion(() -> {
                  **this.emitters.remove(emitter);**
              });
              emitter.onTimeout(() -> {
                  emitter.complete();
              });
      
              return emitter;
          }
      
          public void noti(String eventName) {
              noti(eventName, Ut.mapOf());
          }
      
          public void noti(String eventName, Map<String, Object> data) {
              emitters.forEach(emitter -> {
                  try {
                      emitter.send(
                              SseEmitter.event()
                                      .name(eventName)
                                      .data(data)
                      );
                  } catch (ClientAbortException e) {
      
                  } catch (IOException e) {
                      throw new RuntimeException(e);
                  }
              });
          }
      }
      ```
    • SseController
      SseEmitter를 생성할 때 만료시간을 생성자로 설정할 수 있다. 스프링 부트의 내장 톰캣을 사용하면 디폴트값은 30초이다. 만료시간이 되면 브라우저에서 자동으로 서버에 재연결 요청을 한다.
      Emitter를 생성하고 나서 만료 시간까지 아무 데이터를 보내지 않으면 재연결 요청 시 503 에러가 발생할 수 있다. 따라서 **처음 SSE 연결시에는 더미 데이터를 전달**해준다.
      
      ```jsx
      @Controller
      @RequestMapping("/sse")
      @RequiredArgsConstructor
      public class SseController {
          private final SseEmitters sseEmitters;
      
          @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
          public ResponseEntity<SseEmitter> connect() {
              SseEmitter emitter = new SseEmitter();
              sseEmitters.add(emitter);
              try {
                  emitter.send(SseEmitter.event()
                          .name("connect")
                          .data("connected!"));
              } catch (IOException e) {
                  throw new RuntimeException(e);
              }
              return ResponseEntity.ok(emitter);
          }
      }
      ```
  3. 실행해보기

    요청이 계속 가는 것이 아니라 메세지가 들어오면 메세지를 가져온다.

    connect는 30초에 한번씩 끊기고 가져온다.

✅ OSIV와 SSE

OSIV는 기본적으로 켜져있는데, SSE와 만나게 되면 connection을 계속 잡고 있게 된다. 그러면 pool이 바닥날 수 있다. 아예 OSIV를 끌 수는 없으므로, SSE 요청인 부분만 선택적으로 끌 수 있다.
같이 켜 놓지 않는 것이 좋다.
---> 추가 공부 필요

참고: https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/

profile
훈이야 화이팅

0개의 댓글