이번시간에는 간단한 채팅 기능을 구현해보도록 하겠다.
채팅을 구현하기 위해서는 클라이언트와 서버가 실시간으로 데이터를 주고 받아야한다. 따라서 그 기능을 구현하기 위해 대표적으로 폴링, SSE, 소켓 방식을 사용한다.
Long Polling
- 클라이언트가 서버에게 Request를 하면 이벤트가 발생하기 전까지 기달리다가 이벤트가 발생하면 Response를 보내고 연결을 끊는다.
- 기존 폴링 방식보다는 서버 부담이 덜 하지만, 이벤트 발생 간격이 좁다면 차이가 없다.
1. 채팅방 입장하면 클라이언트는 서버에게 Request를 보냄
2. 서버는 클라이언트에게 현재 저장되어 있는 전체 메세지를 Response해주고 연결해제함(채팅방 보면 이전 데이터를 출력해줘야 하기 때문에)
3. 클라이언트에서는 현재 메세지 ID번호를 서버에게 Request함
4. 서버에서는 해당 ID를 기준으로 ID다음의 번호를 가진 메세지만을 Response함(만약 ID가 없다면 전체 출력)
예) 현재 채팅방에 3개의 메세지가 존재한 상태에서 유저가 채팅방에 접속하면 3개의 메세지를 전체 응답받고나서 클라이언트는 ID=4번인 메세지를 다시 요청한다. 만약 그 사이에 다른 유저가 채팅을 작성하여 서버에 메세지가 6개가 저장되어 있다고 한다면 서버는 ID가 4번다음인 값 즉, ID=5, ID=6번인 데이터를 반환해준다.
5. 유저가 브라우저를 닫기 전까지 3~4번 과정을 반복함
1. 클라이언트가 채팅 메세지를 작성하면 서버로 해당 데이터를 전송함
2. 서버에서는 해당 데이터를 DB에 저장한다.
3. 브라우저를 닫기 전까지 반복함
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class Response<T>{
private String resultCode;
private T resultData;
public static <T> Response<T> success(T resultData){
return new Response<>("Success", resultData);
}
public static Response<Void> error(String resultCode){
return new Response<>(resultCode,null);
}
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class ChatMessage {
private Long id;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime localDateTime;
private String authorName;
private String content;
public ChatMessage(String authorName, String content){
this(ChatMessageIdGenerator.genNextId(),LocalDateTime.now(),authorName,content);
}
}
class ChatMessageIdGenerator {
private static long id = 0;
public static long genNextId() {
return ++id;
}
}
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime localDateTime;
을 사용했고 ID의 값은 DB와 연동하지 않았으므로 임으로 호출할때마다 +1로 카운트를 해주었다.@AllArgsConstructor
@Getter
@NoArgsConstructor
public class ChatRequest {
private String authorName;
private String content;
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class ChatResponse {
private LocalDateTime localDateTime;
private String authorName;
private String content;
}
@Controller
@RequestMapping("/chat")
@Slf4j
public class ChatController {
private List<ChatMessage> chatMessage = new ArrayList<>();
/* jdk 16버전 부터 record라는 것을 지원한다. 이전에는 request와 response를 따로 클래스를 만들어서
@AllArgsConstructor,@Getter 어노테이션을 사용하여 DTO 클래스를 만들었었는데 record는 이 모든것을 가지고 있는 클래스이다.
즉, record를 사용하면 아래처럼 바로 사용이 가능해진다. 생성자를 통해 값만 입력받는 경우 DTO를 만들필요가 없다.
public record WriteMessageRequest(String authorName, String content) {
}
public record WriteMessageResponse(long id) {
}
*/
// (1) 유저에게 값을 입력받아 리스트에 저장후 결과 반환
@PostMapping("/writeMessage")
@ResponseBody
public Response<List<ChatMessage>> writeMessage(@RequestBody ChatRequest chatRequest){
ChatMessage cm = new ChatMessage(chatRequest.getAuthorName(),chatRequest.getContent());
chatMessage.add(cm);
log.info(chatRequest.getAuthorName());
return Response.success(chatMessage);
}
// (2) 특정 게시물 번호 이후 게시물만 출력
@GetMapping("/messages/{id}")
@ResponseBody
public Response<List<ChatMessage>> findmessage(@PathVariable Long id){
List<ChatMessage> messages = chatMessage;
// 번호가 입력되었다면
if (id != null) {
// 해당 번호의 채팅메세지가 전체 리스트에서의 배열인덱스 번호를 구한다.
// 없다면 -1
int index = IntStream.range(0, messages.size()) // 리스트에 저장된 데이터만큼 반복 IntStream.range() => for문 처럼 반복
.filter(i -> chatMessage.get(i).getId() == id) // filter() => if문 처럼 조건문 동작
.findFirst() // filter조건에 true인 값을 리턴함 즉, index에 true값 저장
.orElse(-1); // 조건에 해당하지 않다면 -1을 반환함 즉, index에 -1넣음
if (index != -1) {
// 만약에 index가 있다면, 0번 부터 index 번 까지 제거한 리스트를 만든다.
// index가 1이고 리스트에 저장되어 있는 데이터가 3개일때 subList(2,3) 사이의 값이 출력된다. 즉, 2번째 3번째 값 messages에 덮어씌우기 (subList는 1부터 시작)
messages = messages.subList(index + 1, messages.size());
}
}
return Response.success(messages);
}
}
❓ record 클래스란?
- jdk 16버전부터 새로 생긴 클래스로 이전에는 DTO 클래스 안에 @AllArgsConstructor, @Getter 어노테이션을 사용하여 아래와 같이
@AllArgsConstructor @Getter public class ChatRequest { private String authorName; private String content; }
데이터를 통신할 수 있는 구조로 만들었는데 record가 이 모든 역할을 대신해준다. 따라서 위의 코드를 아래의 코드로 줄일 수 있다.
public record ChatRequest(String authorName, String content) { }
<div class="chat">
<form action="https://www.naver.com" target="_blank" onsubmit="Chat__submitWriteMessageForm(this); return false;">
<input name="authorName" type="text" placeholder="작성자">
<input name="content" type="text" placeholder="내용">
<input type="submit" value="작성">
</form>
</div>
<hr>
<a href="https://www.naver.com" target="_blank" onclick="return false;">네이버로 이동</a>
console.clear();
// 유틸리티 시작
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());
}
// 유틸리티 끝
// 비지니스 시작
// html에서 submit버튼 클릭시 동작
function Chat__submitWriteMessageForm(form) {
// console.log("submit 실행됨");
// 입력란에 좌우에 존재하는 공백제거
form.authorName.value = form.authorName.value.trim();
// 작성자 이름이 공백이라면 alert 출력
if (form.authorName.value.length == 0) {
form.authorName.focus();
alert("작성자를 입력해주세요");
return;
}
form.content.value = form.content.value.trim();
// 내용이 공백이라면 alert 출력
if (form.content.value.length == 0) {
form.content.focus();
alert("내용을 입력해주세요");
return;
}
// html에서 작성한 이름과, 내용의 데이터를 서버 api주소로 보낸다. 이때, 비동기식 방식으로 반환값은 없음
fetchPost("http://localhost:8020/chat/writeMessage", {
authorName: form.authorName.value,
content: form.content.value
}).then((data) => console.log(data));
form.content.value = ""; // 내용칸 입력한 내용 비우기
}
// 비지니스 끝
(1) fetchPost 함수 : Post 형식으로 반환해주는 코드로 HTML에서 input을 통해 입력한 데이터를 서버 API 주소로 JSON형식으로 보내준다.
(2) fetchGet 함수 :
(3) 비즈니스 Chat__submitWriteMessageForm 함수 : HTML에서 submit버튼 클릭시 동작하는 함수로 작성자와 내용을 입력하는 공간의 공백여부를 조사하고 모두 true라면 fetchPost함수를 호출하여 서버 api주소로 해당 데이터를 보내준다.