본문 바로가기

Node.js/실험실

[Node.js] Zoom 클론코딩 - 채팅방편

0. 들어가며

앞서 채팅편을 보지 않으셨다면 먼저 보고 오시는 것을 추천합니다.

1. [Node.js] Zoom 클론코딩 - 세팅편

2. [Node.js] Zoom 클론코딩 - 채팅편

 

1. Socket IO 

Socket IO실시간, 양방향, event 기반 통신을 제공해주는 framework이다. 

webSocket과 같은 역할을 하는 것처럼 보이는데, 브라우저가 webSocket을 지원하지 않아도 socket IO는 다른 방법을 사용해서 기능을 제공한다. 

 

좀 더 넓은 범위에서 사용할 수 있다는 뜻이다. 

 

정리하면,

  1. websocket의 부가기능이 아닌, 필요에 따라 Socket IO가 websocket을 사용하거나 안 할 수 있다. 
  2. 프론트와 백엔드 간 실시간 통신을 가능하게 해주는 프레임워크 혹은 라이브러리다. 
  3. 탄력성이 있어 websocket을 이용한 연결에 실패해도 다른 방법으로 통신한다. 
  4. websocket보다 기능이 더 많기 때문에 용량이 더 무겁다. ( 1mb 정도? )

2. Socket IO를 이용한 통신하기

yarn add socket.io

Socket IO를 사용하기 위해서 서버에 패키지를 설치해준다. 

 

// import WebSocket from "ws";
=> 
import SocketIO from "socket.io";

// ...

const server = http.createServer(app);

// const wss = new WebSocket.Server({ server });
=>
const io = SocketIO(server);

ws를 사용한 코드를 지우고, Socket IO를 사용해서 서버를 만들었다. 

 

기존 Front-End는 ws를 기본적으로 제공하기 때문에 특별한 패키지를 설치할 필요가 없었지만, 

Socket IO는 ws보다 많은 기능을 제공하기 때문에 ws 만으로는 통신을 할 수 없다. 

 

doctype html
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(http-equiv="X-UA-Compatible", content="IE=edge")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title Document
    body 
        header 
            h1 
        main 

        script(src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.0/socket.io.js") 
        script(src="/public/js/socket.js")

Socket IO를 추가로 cdn 방식으로 Front-End 프로젝트에 불러왔다. 

앞서 말한 것처럼 ws는 브라우저에 설치되어 있지만, Socket IO는 새롭게 추가를 해야 하기 때문에 좀 더 무겁다. 

 

만약 서버에서 실행하는 ssr 프로젝트라면 

script(src="/socket.io/socket.io.js")

백엔드에 socket.io를 설치함으로 기본적으로 제공하는 socket.io 패키지가 있어서 cdn으로 받아올 필요는 없다. 

 

// socket.js

const socket = io(window.location.host);

프론트에서 서버와 통신하기 위해서 socket.js에 io에 매개변수로 url을 넣어주었다. 

ws와는 다르게 따로 ws://같은 내용을 주소에 추가해줄 필요는 없고 서버 주소만 추가해주면 바로 통신할 수 있다. 

 

만약 백엔드에서 실행할 경우 /socket.io/socket.io.js 경로에서 패키지를 가져왔다면, 

const socket = io();

만 사용해도 자동으로 서버와 통신이 된다.

 

const io = SocketIO(server);

// ...

io.on("connection", (socket) => {
  console.log(socket);
});

서버에서도 마찬가지로 Front-End와 통신하기 위해서 connection 키워드를 on 매서드에 넣어주면 연결될 때, 

소켓 정보를 콘솔에 출력한다. 

 

3. Socket IO를 사용해서 채팅방 만들기

doctype html
html(lang="en")
    head
        meta(charset="UTF-8")
        meta(http-equiv="X-UA-Compatible", content="IE=edge")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title Zoom Clone
    body 
        header 
            h1 Zoom 
        main 
            div#welcome
                form 
                    input(placeholder="room name", required, type="text")
                    button Enter Room

        //- script(src="/socket.io/socket.io.js") 
        script(src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.0/socket.io.js") 
        script(src="/public/js/socket.js")

간단한 채팅방 div를 만들어 주었다. 

 

const socket = io(window.location.host);

const welcome = document.getElementById("welcome");
const form = welcome.querySelector("form");

function handleSubmit(e) {
  e.preventDefault();

  const input = form.querySelector("input");

  socket.emit("room", { payload: input.value }, (msg) => {
    console.log(msg);
  });
  input.value = "";
}

form.addEventListener("submit", handleSubmit);

서버에게 채팅방을 만들거나 만들어진 채팅방에 들어가기 위해서 form을 querySelector로 가져오고, 

handleSubmit 함수를 만들었다. 

 

여기서 특이한 부분이 있는데, ws를 사용할 땐 

socket.send(" ... ");

사용해서 서버에게 메시지를 보냈는데, 방을 만들거나, 들어갈 경우 메시지는 필요 없기 때문에 emit 함수를 사용했다. 

ws와 다르게 무조건 메시지를 보낼 필요가 없이 필요에 따라 event를 만들 수 있다. 

 

예를 들어, "delete", "apple", "banana" 등등의 명칭으로도 만들 수 있다. 

 

그리고 두 번째 매개변수로 서버에 넘길 데이터를 줄 수 있다. 

여기서 또 ws와 다른 점이 있는데, ws는 object를 받을 수 없어 string으로 변환한 뒤에 넘겨줬는데,

Socket IO는 그럴 필요 없이 object를 바로 전달할 수 있다. 

 

3번째로 emit()의 3번째 매개변수로 함수가 전달되고 있다. 

 socket.emit("room", { payload: input.value }, (msg) => {
    console.log(msg);
 });

이 함수는 서버에 그대로 전달되며, 필요에 따라 서버에서 호출하면 Front-End에서 실행이 된다. 

정말 특이한 방법으로 작업이 완료될 때라던지, 언제든 서버에서 해당 함수를 호출해서 Front-End에서 실행할 수 있다. 

 

const io = SocketIO(server);

io.on("connection", (socket) => {
  socket.on("room", (msg) => {
    console.log(msg);

    setTimeout(() => {
      done("Server Done!");
    }, 1000);
  });
});

서버에서도 "room"으로 Front-End에서 전달한 데이터를 받을 수 있다. 

 

그리고 Front-End에서 넘겨준 3번째 함수를 호출하면, Front-End에서 호출된 함수가 실행된다. 

 

정리하자면, 

  1. 서버에 전달할 때 반드시 메시지를 보낼 필요 없이 필요에 따라 event를 만들어줄 수 있다. 
  2. object를 전달할 때 string으로 변환할 필요 없이 바로 넣어서 전달할 수 있다. 
  3. 필요에 따라 3번째 매개변수로 함수 등을 전달할 수 있는데, 서버에서 호출 시 Front-End에서 실행된다. 

 

4. Socket IO를 사용해서 채팅방 안에서 통신하기

io.on("connection", (socket) => {
  socket.on("room", (msg) => {
    console.log(msg);

    socket.join(msg);

    setTimeout(() => {
      done("Server Done!");
    }, 1000);
  });
});

Socket IO는 기본적으로 그룹(Room)을 만드는 기능을 제공한다. 그것도 아주 간단하게..

 

socket.join("명칭");

을 사용하면 간단하게 그룹을 만들 수 있다. 

" 명칭 " 외에도 [ "명칭 1", "명칭 2" ] 방식으로 배열을 사용하면 여러 곳의 그룹에 동시에 들어갈 수 있다. 

 

소켓이 접속한 그룹은 

console.log(socket.rooms)

통해서 조회가 가능하다. 

 

그리고 방을 나갈 수 있는데, 

socket.leave("명칭");

사용해서 그룹을 나갈 수 있다. 

 

// ...

    body 
        header 
            h1 Zoom 
        main 
            div#welcome
                form 
                    input(placeholder="room name", required, type="text")
                    button Enter Room

            div#room
                h3 Title
                ul 
                form
                    input(placeholder="message", required, type="text")
                    button Send
      
// ...

본격적으로 채팅방을 만들기 위해서 room이라는 div를 만들어주고 안에 입력 폼과 전송 버튼을 만들었다. 

 

// ...

const room = document.getElementById("room");

room.hidden = true;

// ...

그리고 방에 들어가기 전까진 보이면 안 되기 때문에 hidden true 옵션을 주었다. 

 

let roomName;

function showRoom() {
  welcome.hidden = true;
  room.hidden = false;

  const h3 = room.querySelector("h3");
  h3.innerText = `Room ${roomName}`;
}

function handleSubmit(e) {
  e.preventDefault();

  const input = form.querySelector("input");

  socket.emit("room", { payload: input.value }, showRoom);
  roomName = input.value;
  input.value = "";
}

방을 접속했을 때는 room이 보이고, 채팅방 생성 및 입장 form이 사라져야 해서 showRoom을 만들고 넘겨줬다. 

그리고 방 제목을 기억했다가 showRoom에서 방에 접속하면 Title을 변경해준다. 

 

io.on("connection", (socket) => {
  socket.on("room", (msg, showRoom) => {
    console.log(msg);

    socket.join(msg);

    showRoom();
  });
});

서버에선 방 생성 작업이 완료되면, showRoom을 호출해서 Front-End에 showRoom 함수가 실행된다. 

 

5. Socket IO를 사용해서 채팅방에서 접속 시 메시지 보내기

io.on("connection", (socket) => {
  socket.on("room", (msg, showRoom) => {
    console.log(msg);

    socket.join(msg);

    showRoom();

    socket.to(msg).emit("welcome");
  });
});

생성된 방의 모든 사람에게 통신을 할 때는 to 함수를 사용하면 된다. 

 

socket.to("그룹명").emit(' ~ ');

to 안에 그룹명을 적으면 해당 그룹 전체에게 특정한 작업을 할 수 있는데, 

그룹명에 다른 사용자의 Socket ID를 넣는다면, 특정 사용자에게만 전달되는 개인 메시지 용도로 사용할 수 있다. 

 

※ Socket ID는 socket.rooms를 하면 확인할 수 있다. 

 

// ...
function addMessage(message) {
  const ul = room.querySelector("ul");
  const li = document.createElement("li");

  li.innerText = message;
  ul.appendChild(li);
}

socket.on("welcome", () => {
  addMessage("Someone joined!");
});

Front-End도 마찬가지로 socket을 통해서 welcome 이벤트를 받아오고, 왔을 경우 메시지를 출력해준다. 

 

6. Socket IO를 사용해서 메시지 보내기

io.on("connection", (socket) => {
  socket.on("room", (msg, showRoom) => {
    socket.join(msg);

    showRoom();

    socket.to(msg).emit("welcome");
  });
  socket.on("disconnecting", () => {
    socket.rooms.forEach((room) => socket.to(room).emit("bye"));
  });
});

socket의 " disconnecting " 이벤트는 사용자와 연결이 끊어지기 직전에 발생하는 이벤트이다. 

" disconnect " 이벤트는 연결이 끊어지면 발생하는 이벤트로 둘은 다른 이벤트이다. 

 

즉, 사용자와 연결이 끊어지기 직전에 호출되어서 접속한 그룹을 반복해서 접속한 방에 " bye " 이벤트를 발생시킨다.

 

// ...

socket.on("welcome", () => {
  addMessage("Someone joined!");
});

socket.on("bye", () => {
  addMessage("Someone left..");
});

welcome과 마찬가지로 bye 이벤트를 만들어 사용자들에게 뿌려준다.

 

// ...

function handleMessageSubmit(e) {
  e.preventDefault();

  const input = room.querySelector("input");

  socket.emit("new_message", input.value, roomName, () => {
    addMessage(`You: ${input.value}`);
    input.value = "";
  });
}

function showRoom() {
  welcome.hidden = true;
  room.hidden = false;

  const h3 = room.querySelector("h3");
  h3.innerText = `Room ${roomName}`;

  const form = room.querySelector("form");
  form.addEventListener("submit", handleMessageSubmit);
}

// ...

사용자가 메시지를 보낼 때 발생하는 이벤트를 처리한다. 

 

" new_message "라는 이벤트를 만들고 작성한 내용을 넘겨준다.  그리고 socket.to 함수는 자신에게는 발생하지 

않기 때문에 addMessage를 사용해 서버에게 전송하면 추가해준다. 

 

또한 어느 방에서 보낸 메시지인지도 알아야 하기 때문에 방 이름도 함께 보내준다. 

 

io.on("connection", (socket) => {
  socket.on("room", (msg, showRoom) => {
    socket.join(msg);

    showRoom();

    socket.to(msg).emit("welcome");
  });
  socket.on("disconnecting", () => {
    socket.rooms.forEach((room) => socket.to(room).emit("bye"));
  });
  socket.on("new_message", (msg, room, done) => {
    socket.to(room).emit("new_message", msg);
    done();
  });
});

서버에서 " new_message"에 대한 이벤트를 받아서 방에 있는 모든 사용자에게 메시지를 넘겨주고,

done 함수를 호출해서 전송한 사용자에게의 addMessage를 실행한다. 

 

// ... 

socket.on("bye", () => {
  addMessage("Someone left..");
});

socket.on("new_message", (msg) => {
  addMessage(msg);
});

이제 마지막으로 사용자에게 전달받은 " new_message " 이벤트에 대한 처리를 한다. 

 

7. NickName 추가하기

   div#room
        h3 Title
        ul 
        form#name
            input(placeholder="nickname", required, type="text")
            button Save
        form#message
            input(placeholder="message", required, type="text")
            button Send

방 안에서 message 말고 nickname을 추가로 입력할 수 있는 form을 하나 만들어주고, 각 form에 id를 추가했다. 

 

function handleMessageSubmit(e) {
  e.preventDefault();

  const input = room.querySelector("#message input");

  socket.emit("new_message", input.value, roomName, () => {
    addMessage(`You: ${input.value}`);
    input.value = "";
  });
}

function handleNickNameSubmit(e) {
  e.preventDefault();

  const input = room.querySelector("#name input");

  socket.emit("nickname", input.value, roomName, () => {
    input.value = "";
  });
}

function showRoom() {
  welcome.hidden = true;
  room.hidden = false;

  const h3 = room.querySelector("h3");
  h3.innerText = `Room ${roomName}`;

  const msgForm = room.querySelector("#message");
  msgForm.addEventListener("submit", handleMessageSubmit);

  const nameForm = room.querySelector("#name");
  nameForm.addEventListener("submit", handleNickNameSubmit);
}

마찬가지로 form 이 추가된 만큼 거기에 맞춰 showRoom 함수를 변경하였다. 

 

io.on("connection", (socket) => {
  socket["nickname"] = "Anon";

  // ...
  
  socket.on("nickname", (nickname) => {
    socket["nickname"] = nickname;
  });
});

ws에서 사용한 것처럼 socket ["nickname"]을 전달받은 nickname으로 변경했다. 

이렇게 변경하면 이제 사용자가 접속했을 때, 접속 종료할 때, 메시지 보낼 때 닉네임을 추가해서 전달할 수 있다. 

  socket.on("room", (msg, showRoom) => {
    socket.join(msg);

    showRoom();

    socket.to(msg).emit("welcome", socket["nickname"]);
  });
  socket.on("disconnecting", () => {
    socket.rooms.forEach((room) =>
      socket.to(room).emit("bye", socket["nickname"])
    );
  });
  socket.on("new_message", (msg, room, done) => {
    socket.to(room).emit("new_message", `${socket["nickname"]}: ${msg}`);
    done();
  });

맞춰서 welcome과 bye에는 nickname을 바로 보내주고, message는 메시지에 직접 넣어서 처리할 수 있다. 

 

8. 깃허브

https://github.com/SeoJaeWan/zoom-clone

 

GitHub - SeoJaeWan/zoom-clone

Contribute to SeoJaeWan/zoom-clone development by creating an account on GitHub.

github.com

 

반응형