실시간인 것처럼 작동하는 방법
💡 클라이언트의 요청이 있기 전까지는 서버는 어떠한 데이터도 줄 수 없다.롱 폴링은 클라이언트에서 서버에 요청을 날리면 서버에서 바로 응답을 해주는 것이 아니라, 업데이트 내용이 생기면 응답을 해주는 방식이다. 클라이언트에서는 응답을 받으면 다시 서버로 업데이트된 사항을 요청한다.
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);
}
}
ChatMessageIdGenerator
class 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.yml
spring:
thymeleaf:
cache: false
prefix: file:src/main/resources/templates/
devtools:
livereload:
enabled: true
restart:
enabled: true
room.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);
}
});
}
}
```
SseController
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);
}
}
```
실행해보기
요청이 계속 가는 것이 아니라 메세지가 들어오면 메세지를 가져온다.
connect는 30초에 한번씩 끊기고 가져온다.
OSIV는 기본적으로 켜져있는데, SSE와 만나게 되면 connection을 계속 잡고 있게 된다. 그러면 pool이 바닥날 수 있다. 아예 OSIV를 끌 수는 없으므로, SSE 요청인 부분만 선택적으로 끌 수 있다.
같이 켜 놓지 않는 것이 좋다.
---> 추가 공부 필요
참고: https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/