(23.08.02 ~ 23.08.17) Socket.io를 활용해서 실시간 스터디 서비스를 만들다.
*기능 구현 정리 목차 3으로 이동*
* 프로젝트 깃허브
https://github.com/2023-AISCHOOL-JSA/LegendA-CoreProject
우리 조의 핵심프로젝트 주제는 "개발 입문자를 위한 실시간 스터디 서비스"이다. 나는 프로젝트에서 Back-end로 사용자 간의 실시간 통신을 담당해서 전체적인 실시간통신, 채팅방 생성, 채팅 기능을 담당했다. 그래서 실시간 통신을 할 때 사용되는 webSocket을 독학하기로 했다.
1. WebSocekt, Socket.io 배우기
* webSocket, socket.io 강의
https://nomadcoders.co/noom/lobby
webSocket이 무엇인지 배우기 위해 노마드 코더의 zoom클론 코딩을 보며 webSocket과 Soscket.io를 공부했다.
webSocket은 웹 소켓 서버로 연결해서 클라이언트와 서버 간의 데이터를 주고받는 통신 프로토콜이다. 기존의 HTTP통신과 다르게 클라이언트와 서버는 능동적으로 요청과 응답으로 메시지를 보낼 수 있다. 양방향 통신이다. WebSocket에서는 데이터를 보낼 때 데이터 타입을 변환시켜야 한다는 불편한 점이 있다. 그리고 webSocket을 지원하지 않는 브라우저도 있다.
// socket.io 설치
npm install socket.io
그래서 나는 Socket.io라는 자바스크립트 라이브러리를 사용해서 실시간 통신을 구현하기로 했다. Socket.io를 사용해서 서버를 구현할 땐 그림 1처럼 작성하면 socket.io 서버를 사용하겠다는 의미이다. 사용자가 클라이언트에 접속하면 자동으로 각 사용자는 Socket 객체를 가지게 된다. Socket에는 사용자마다 고유의 Socket ID를 가진다. 고유한 ID이기 때문에 이후에 해당 사용자에게만 데이터(EX. 메시지)를 보내거나 해당 사용자가 어떤 방에 속해있는지 파악할 수 있는 중요한 정보이다. Socket.io는 클라이언트와 서버 간의 이벤트로 데이터를 주고받는다. 이벤트를 전송할 때 객체형태의 데이터도 함께 보낼 수 있다. 이벤트를 보낼 때는 emit함수를 사용해서 개발자가 임의의 이벤트명을 지어서 보낼 데이터와 함께 보낸다. 이벤트를 받을 때는 on 함수를 사용해서 emit에서 사용한 이벤트명을 똑같이 사용해서 받아준다. 자세한 설명은 이후에 설명하겠다.
2. Socket.io 응용 (Namespace, Room, emit, on)
2 - 1. Namespace와 Room으로 방 만들기
내가 원하던 것은 방 생성 or 생성된 방에 입장 시, URL의 맨 뒷부분에 방을 고유하게 구분 짓는 제목(이후에 번호로 변경함)이 추가되면서 방에 들어가는 것을 원해서 JS에서 구현할 수 있는 방법을 2~3일 동안 찾고 고민하다가 React의 SPA방식으로 구현할 수 있다길래 어쩔 수 없이 포기했다. 포기한 이유는 배우지 않은 React를 배울 시간도 없었을뿐더러 팀원들과 React를 사용하지 않을 거라고 이미 이야기가 된 상황이었기 때문에 나는 다른 방법을 찾아야 했다. 그리고 우리의 서비스는 2가지로 나누어져서 큰 틀의 방의 종류도 2가지가 되어야 한다. a에서 쓰일 방들과 b에서 쓰일 방들을 구분 짓는 방법을 검색했더니 socket.io에서 기본적으로 지원하는 Namespace 기능을 사용하면 되는 것이었다.
Namespace는 정확히 내가 원하던 기능이었다. 같은 종류의 방들을 하나로 묶어주는 것을 원했는데 Namespace를 찾고 진짜 개운했다. 아무튼 서버에 Namespace를 사용할 거라고 추가해야 하기 때문에 io 서버를 내가 원하는 대로 분리하는 작업이 필요하다.
// 네임스페이스로 io 서버 분리 /CodeChat, /CodeArena
const ChatNamespace = io.of("/CodeChat");
const ArenaNamespace = io.of("/CodeArena");
io의 /CodeChat과 /CodeArena라는 네임스페이스로 나누겠다라는 의미이다. io 서버가 1개일 때를 그냥 2개로 만들고 연결할 때의 변수를 네임스페이스 서버를 정의할 때 사용한 변수로 사용하기만 하면 된다.
// chat 방 입장 enter_room 감지하기
socket.on(
"enter_room",
({ nickname: nickname, room_number, room_host, conn_user }) => {
console.log("입장방", room_number);
socket["room_number"] = room_number; // 소캣 객체에 "room_number"이라는 속성 추가
const roomInfo = rooms.get(room_number);
if (roomInfo) {
roomInfo.userCount = (roomInfo.userCount || 0) + 1;
rooms.set(room_number, roomInfo);
}
console.log("enter_room이벤트의 room_number : ", room_number);
console.log(
"enter_room 이벤트 join 하기 직전의 인원수",
countRoomUsers(room_number)
);
if (countRoomUsers(room_number) >= 4) {
// 들어가기 전에 방의 인원이 4명이면 입장 불가
socket.emit("user_full");
} else {
console.log("enter_room의 room_number", room_number);
socket.join(room_number); // 들어가기 전에 방의 인원이 3의 이하면 입장
console.log("입장한 방의 방장 닉네임", room_host); // 입장한 방의 방장 닉네임 room_host
ChatNamespace.to(room_number).emit("welcome", { nickname });
}
// 소켓에 닉네임 저장
socket["nickname"] = nickname;
console.log("입장한 후 소켓이 들어간 방", socket.id);
ChatNamespace.to(room_number).emit("user_count", {
user_count: countRoomUsers(room_number),
});
ChatNamespace.to(room_number).emit("userConnectInfo", {
data: conn_user,
roomNum: room_number,
});
}
);
내가 작성한 유저가 방에 입장할 때 실행되는 코드이다. 실질적으로 유저(socket)을 방에 입장시키는 함수는 join 함수이다. join 함수는 매개변수로 어떤 값을 기준으로 방에 입장할 것인지를 받는다. 무슨 소리냐면 방의 제목을 매개변수로 하면 방의 제목이 같으면 같은 방에 입장한다는 것이다. 하지만 방의 제목을 기준으로 방을 구분하려고 하면 문제가 생긴다. 왜냐하면 서로 다른 방을 만드려고 했는데 우연히 방의 제목이 같다면 사용자의 의도는 서로 다른 방인데 서버는 서로 같은 방으로 인식하기 때문에 결국 하나의 방만 생성된 것이다. 그래서 방을 고유하게 구분 지을 수 있는 방을 생성(입장 X)할 때 방의 고유한 번호를 DB에 저장해서 enter_room 이벤트를 받을 때 DB에서 넘어온 room_number가 와서 그 방 번호로 방에 입장하게 되는 것이다.
2 - 2. emit, on으로 채팅기능 만들기
채팅기능은 크게 3가지로 분류할 수 있다.
- 내 메시지
- 상대 메시지
- 공지 ex) OO님이 입장하였습니다.
const addNotice = (message) => {
console.log("addNotice 함수 실행");
const $div = document.createElement("div"); // 공지 메시지를 담을 div 생성
$div.id = 'notice' // div 태그에 id 주기
$div.textContent = message; // div 태그에 message 추가 ex) 입장, 퇴장문구
$c_main_content.appendChild($div); // 공지 메시지를 담은 div를 채팅방 채팅 태그안에
scrollToBottom() // 메세지가 화면 위를 넘어도 채팅방의 가장 아래를 보게
};
누군가 입장하면 뜨는 공지 메시지는 방에 존재하는 모든 유저에게 보내야하기 때문에 비교적 다른 메시지에 구현하기 쉽다고 생각했기에 가장 먼저 구현했다. 방에 입장하면 서버에서 emit함수를 사용해서 welcome 이벤트로 클라이언트로 보내서 클라이언트에선 이벤트를 받고 공지를 띄우는 함수를 실행시킨다. 실행되는 함수는 위 코드블럭을 참조한다.
const handleMessageSubmit = (event) => {
console.log("handleMessageSubmit 함수 실행");
event.preventDefault();
const message = $form_input.value; // 메시지 입력값 가져오기
// 메세지의 공백이나 줄바꿈을 빈 문자열로 바꿔서 빈문자열만 있으면 보내지않기
console.log("공백 거르기전", message);
let checkMessage = message.replace(/\s| /gi, '');
if(checkMessage !== ""){
console.log("메시지 공백 거르기", message);
arenaSocket.emit("new_message", { currentNickname, message: message });
}
$form_input.value = ""; // 입력 창 초기화
scrollToBottom()
};
클라이언트에서 채팅을 보내면 실행될 함수이다. input에 입력된 메세지를 가져와서 input에 들어간 문자열이 공백으로만 이루어져 있거나 줄 바꿈으로만 이루어져 있으면 실행되지 않게 조건을 걸어준다. (도배 방지)
정상적인 메세지인 것을 확인하면 클라이언트 → 서버로 보낸 사람의 닉네임과 메시지 입력 내용을 보낸다. 보낸 이후에 사용자의 채팅 입력칸을 빈칸으로 초기화하여 사용자에게 편의성을 주었다. 이제 나는 고민을 했다. 어떻게 하면 내가 보낸 메시지와 상대가 보낸 메시지를 구분해서 화면에 띄우게 할 것인가. 내가 보낸 메시지는 오른쪽, 나를 제외한 상대가 보낸 메시지는 왼쪽에 배치하고 싶었다. 몇 시간 고민하다가 갑자기 띵! 하면서 해결했다. 방법은 바로 서버 → 클라이언트로 메시지를 다시 보내서 화면에 띄울 때 my_message, other_message로 총 2개의 이벤트로 나눠서 보내는 것이다. my_message는 이벤트를 보내면 이벤트를 보낸 사용자에게만 이벤트를 보내고, other_message는 이벤트를 보내면 이벤트를 보낸 사용자를 제외하고 방에 존재하는 다른 사용자에게만 이벤트를 보내는 방법이다. 그렇게 되면 이벤트를 보낸 사람은 other_message를 받지 못하고, 똑같이 다른 사용자들은 my_message 이벤트를 받지 못하는 것이다. 메시지를 보내는 특정 방에만 이벤트를 보내야 하기 때문에 join 할 때 방을 고유하게 구분해 주던 방 번호를 socket에 저장되어 있었기 때문에 거기서 가져온다. socket에 저장된 방 번호는 이벤트를 발생시킨 socket의 방번호이기 때문에 틀릴 수 없다.
arenaSocket.on("my_message", ({ currentNickname, message }) => {
console.log("내 new_message이벤트 프론트에서 받음");
const $div = document.createElement("div");
const $Div = document.createElement('div')
$Div.id ='my_M'
$div.id = "my_message"
$div.textContent = `${message}`;
$Div.appendChild($div);
$c_main_content.appendChild($Div);
scrollToBottom()
});
arenaSocket.on("other_message", ({ currentNickname, message }) => {
console.log("다른사람 new_message이벤트 프론트에서 받음");
const $div = document.createElement("div");
$div.id = "other_message"
$div.textContent = `${currentNickname} : ${message}`;
$c_main_content.appendChild($div);
scrollToBottom()
});
공지 메시지를 채팅방에 올렸던 방식과 동일하게 채팅방에 띄워준다. id 값을 다르게 줘서 id에 맞는 CSS를 주었기 때문에 내 메시지는 오른쪽, 상대 메시지는 왼쪽에 보이게 된다.
3. 결론 (내가 구현한 기능)
Main : Back
Sub : DB
Socke.io, Node.js를 사용
- CodeChat, CodeArena 채팅방 생성 및 입장 감지 기능
- 방의 정보를 채팅방 내부 페이지에 실시간으로 연동 ex) 방 제목, 방 인원수
- 방 인원수 조건 걸기 (4명 초과 금지)
- 방을 생성한 사람이 방장 기능
- 방장의 시작(Start) 기능, 이외 사람들의 준비(Ready) 기능 (전부 Ready여야 Start가능)
- 방 생성, 삭제를 실시간으로 반영하는 채팅방 리스트
- 방 리스트에 방 인원수 실시간 연동 (유저 입·퇴장 실시간 감지)
- 실시간 채팅 기능 (나와 상대방, 공지 메시지 구분)
- 방 입장, 퇴장 문구
- 채팅방 퇴장 감지 기능 (뒤로가기, 페이지 닫기)
- 시연 영상 편집
서버를 2개를 구현해야하다보니까 발표 전전날까지 코드를 짜야할 만큼 되게 바쁜 프로젝트였다. 배우지 않은 실시간 통신인 WebRTC, Socket.io를 사용하자는 의견을 내가 냈기 때문에 안 배운 내용을 독학하고 프로젝트를 했어야 해서 프로젝트 초기에 '진짜 될까?'라는 생각보다 '해내야 해, 해야 하는 방법 밖에 없어'라는 생각으로 임했다. 그렇기에 부담감이 컸고 내 마음처럼 해결되지 않는 버그 때문에 스트레스도 많았다. 디버깅하는 시간이 6~70%를 차지했다고 생각한다. 하지만 결국 완성했기 때문에 새로운 지식, 디버깅 실력이 프로젝트를 하기 전보다 월등히 늘었다는 것을 체감한다.
결과는 비록 장려상이지만 고생한만큼 얻어가는 것이 많았기에 나는 굉장히 만족한 프로젝트였다. 프로젝트를 하고 나서 이제는 실생활에서 프로그래밍으로 이루어진 것들이 '이건 어떤 코드로 이루어졌을까?', '이거 이 기능 있으면 더 편할 거 같은데'와 같은 생각이 든다. 아무튼 프로젝트하느라 고생한 나와 팀원들에게 고생했다고 말해주고 싶다.