실시간인 것처럼 작동하는 방법
💡 클라이언트의 요청이 있기 전까지는 서버는 어떠한 데이터도 줄 수 없다.
롱 폴링은 클라이언트에서 서버에 요청을 날리면 서버에서 바로 응답을 해주는 것이 아니라, 업데이트 내용이 생기면 응답을 해주는 방식이다. 클라이언트에서는 응답을 받으면 다시 서버로 업데이트된 사항을 요청한다.

https://ssup2.github.io/theory_analysis/Web_Polling_Long_Polling_Server-sent_Events_WebSocket/
실시간을 구현할 때, 상황에 따라
순서로 구현해본다.


전체 코드 : https://github.com/coastby/prac_1219_chatting_app
ChatController@RestController
@RequestMapping("/chat")
public class ChatController {
    @PostMapping("/messages")
    public String writeMessage(){
        return "메세지가 작성되었습니다.";
    }
}RsData@Getter
@AllArgsConstructor
public class RsData<T> {
    private String resultCode;
    private String msg;
    private T data;
}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);
    }
}ChatMessageIdGeneratorclass ChatMessageIdGenerator{
    private static Long id = 0L;
    public static long genNextId(){
        return id++;
    }
}@PostMapping("/writeMessage")
public RsData<ChatMessage> writeMessage(){
    ChatMessage message = new ChatMessage("hoon", "hi");
    return new RsData<>("S-1", "메세지가 작성되었습니다.", message);
}```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()));
    }
}
```
controller 만들기
@GetMapping("/messages")
public RsData<List<ChatMessage>> getMessages(){
    return new RsData<>("S-1", "성공했습니다.", messageList);
}  
특정 아이디부터메세지 받아오기
?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()));
}
데이터 6개를 넣고 requestparam 없이 조회를 하게되면 모든 메세지가 나온다.

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

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);

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>
**채팅폼이 발송되기전에 폼체크**
<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 = "";  
}
https://jsonplaceholder.typicode.com/
개발자 테스트용으로 응답을 받을 수 있다. free fake API testing and prototyping.
ajax를 쓰기 위해서는 라이브러리를 복붙해서 쓴다.
유틸리티 가져오기
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())
}
유틸리티를 이용해서 요청을 ajax로 보내기
💡 이 함수는 비동기로 처리된다.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 = "";
}
: 아래 엔드포인트에 ajax 방식으로 요청해서 받아온 응답을 console.log 로 출력해주세요.
https://jsonplaceholder.typicode.com/posts<button onclick="getMessage(); return false;">목록 가져오기</button>
function getMessage() {
  fetchGet("https://jsonplaceholder.typicode.com/posts", {})
  .then((data) => console.log(data));
}
: 아래 엔드포인트에 ajax 방식으로 요청해서 받아온 응답을 console.log 로 출력해주세요.
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 수정하기
application.ymlspring:
  thymeleaf:
    cache: false
    prefix: file:src/main/resources/templates/
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: trueroom.html 작성하기
@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>동작해보기
	
채팅 메세지들 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>`
              );
    });
  }
  // 채팅 메시지들 읽기 끝
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();
실행

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 구독 요청에 대한 응답을 할 수 있다.
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;
    }
}
controller에 SseEmitters DI
ArrayList 처럼 sseEmitter를 모아놓은 클래스
@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를 관리하는 다른 스레드에서 실행될 수 있다. 따라서 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);
            }
        });
    }
}
```SseControllerEmitter를 생성하고 나서 만료 시간까지 아무 데이터를 보내지 않으면 재연결 요청 시 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);
    }
}
```실행해보기
요청이 계속 가는 것이 아니라 메세지가 들어오면 메세지를 가져온다.
connect는 30초에 한번씩 끊기고 가져온다.

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