[mini-project] express.js 와 Socket IO를 이용한 DM 앱

김민재·2024년 4월 11일

mini_project

목록 보기
2/5

express와 socket.io를 이용해서 DM 앱을 구현

1. 기본 뼈대 폴더 구성 및 모듈 설치

  • npm i nodemon express socket.io mongoose dotenv

2. HTML / CSS 파일 구성 부트스트랩 사용

// public/index.html
<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src=""></script>
    <script
      src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
      integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
      integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
      crossorigin="anonymous"
    ></script>
    <script src="/socket.io/socket.io.js"></script>
    <script src="./js/main.js" defer></script>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
      crossorigin="anonymous"
    />
    <link rel="stylesheet" href="style.css" />
    <title>Chat App</title>
  </head>
  <body>
    <div>
      <!-- header -->
      <div class="bg-primary p-2 d-flex">
        <h5 class="text-white">채팅방</h5>
      </div>

      <!-- User Login Form -->
      <div class="login-container p-5">
        <form
          method="post"
          class="user-login d-flex justify-content-center flex-column"
        >
          <input
            type="text"
            class="form-control mb-3"
            placeholder="유저 이름을 적어주세요."
            name="username"
            id="username"
            required
          />
          <button type="submit" class="btn btn-primary">입장</button>
        </form>
      </div>

      <div class="chat-body d-flex d-none">
        <!-- Sidebar -->
        <div class="col-4">
          <div class="sidebar border-end">
            <div class="title p-2 bg-success bg-opacity-50">
              나의 이름: <span id="user-title"></span>
            </div>
            <div class="user-title p-2 border-bottom">
              <span id="users-tagline"></span>
            </div>
            <div class="users"></div>
          </div>
        </div>

        <!-- Main -->
        <div class="col-8">
          <div class="chat-container">
            <!-- Active user title -->
            <div class="title p-2 bg-success bg-opacity-50">
              상대방 이름: <span id="active-user">&nbsp;</span>
            </div>

            <!-- Message area -->
            <div class="messages p-2"></div>

            <!-- Message form -->
            <div
              class="msg-form d-flex justify-content-center border-top align-items-center p-2 bg-success bg-opacity-50 d-none"
            >
              <form method="post" class="msgForm w-100">
                <div class="d-flex">
                  <input
                    type="text"
                    class="form-control"
                    name="message"
                    id="message"
                    placeholder="메시지 보내기..."
                    required
                  />
                  <button
                    type="submit"
                    style="min-width: 70px"
                    class="ms-2 btn btn-success"
                  >
                    전송
                  </button>
                </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  </body>
</html>

// public/css/style.css
.message.left {
  margin-right: 20% !important;
  border-top-left-radius: 0 !important;
}
.message.right {
  margin-left: 20% !important;
  border-bottom-right-radius: 0 !important;
}

.msg-time {
  display: block;
  opacity: 0.7;
  font-size: 0.7rem;
}

.messages {
  height: calc(80vh + 11px);
  overflow-y: auto;
}

.sidebar {
  height: calc(80vh + 112px);
}

.table tr td {
  cursor: pointer;
}

3. express.js와 socketio 연동

const express = require("express");
const path = require("path");

const app = express();
const port = 3000;

const http = require("http");
const { Server } = require("socket.io");
const server = http.createServer(app);
const io = new Server(server);

app.use(express.json());
app.use(express.static(path.join(__dirname, "../public")));

server.listen(port, () => {
  console.log(`server open ${port}`);
});

4. socketio 기본 구조 작성

// server index.js
// 유저를 담을 곳
let users = [];
// socket 전체 연동
io.on("connection", async (socket) => {
  // 유저의 데이타를 객체로 users에 담음
  let userData = {};
  users.push(userData);
  // socket room에 보냄
  io.emit("users-data", { users });

  // 클라이언트에서 보내온 메시지
  socket.on("message-to-server", () => {});

  // 데이터베이스에서 메시지 가져오기
  socket.on("fetch-messages", () => {});

  // 유저가 방에서 나갔을 때
  socket.on("disconnect", () => {});
});

// client main.js
// 서버 socket과 연동하는 부분
const socket = io("http://localhost:3000", {
  autoConnect: false, // 서버에 접속할 때 socket가 바로 연결하는 게 아닌 따로 connect 함수를 작성
  // 로그인하고 연결을 해야하기 때문에
});

// socket이 통신을 할 때마다 어떤 이벤트나 args가 컨솔에 찍을 수 있게 개발할 때 편하다.
socket.onAny((event, ...args) => {
  console.log(event, args);
});

5. 메시지를 DB에 넣기위해 몽고디비 연결

// server index.js
mongoose
  .connect(process.env.MONGO_DB)
  .then(() => {
    console.log("mongodb connected");
  })
  .catch((err) => {
    console.log(err);
  });

6. 메시지 모델 작성하기

// models/messages.js
const mongoose = require("mongoose");

// 메시지 스키마
const messageSchema = mongoose.Schema({
  // A USER와 B USER의 아이디를 합쳐서 만든 토큰 따로 로직이 존재한다.
  userToken: {
    type: String,
    required: true,
  },
  // 메시지
  // 뭐라 보냇고 뭐라 왔는지가 messages에 들어간다.
  messages: [
    {
      // 누구에게 왔는지
      from: {
        type: String,
        required: true,
      },
      // 무슨 내용인지
      message: {
        type: String,
        required: true,
      },
      // 작성한 시간
      time: {
        type: String,
        required: true,
      },
    },
  ],
});

const messageModel = mongoose.model("Message", messageSchema);
module.exports = messageModel;

7. 유저가 채팅방에 입장할 시 유저에 대한 데이터 생성

// client main.js

const chatBody = document.querySelector(".chat-body");
const userTitle = document.getElementById("user-title");
const loginContainer = document.querySelector(".login-container");
const userTable = document.querySelector(".users");
const userTagline = document.querySelector("#users-tagline");
const title = document.querySelector("#active-user");
const messages = document.querySelector(".messages");
const msgDiv = document.querySelector(".msg-form");

// 유저가 로그인 입력했을 때 세션에 저장
const loginForm = document.querySelector(".user-login");
loginForm.addEventListener("submit", (e) => {
  e.preventDefault(); // 새로고침 막음
  const username = document.getElementById("username");
  createSession(username.value.toLowerCase());
  username.value = ""; // input 빈값
});

// 세션에 유저 저장 및 HTML 보여주기
const createSession = async (username) => {
  let options = {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username }),
  };
  // 서버에서 세션 데이터 받아오기 (app.post('/session'))
  await fetch("/session", options)
    .then((res) => res.json())
    .then((data) => {
      // 원래는 자동 connect를 false로 해놔서 유저가 입장할 때만 연결 시킨다.
      socketConnect(data.username, data.userID);

      localStorage.setItem("session-username", data.username);
      localStorage.setItem("session-userID", data.userID);

      loginContainer.classList.add("d-none");
      chatBody.classList.remove("d-none");
      userTitle.innerHTML = data.username;
    })
    .catch((err) => console.log(err));
};

// 소켓 유저 연결
const socketConnect = async (username, userID) => {
  socket.auth = { username, userID };
  // 원래는 자동 connect를 false로 해놔서 유저가 입장할 때만 연결 시킨다.
  await socket.connect();
};

// 서버에서 입장 된 유저들을 받아옴
socket.on("users-data", ({ users }) => {
  // 본인 제거
  const index = users.findIndex((user) => user.userID === socket.id);
  // -1이 아니라면 존재함
  if (index > -1) {
    // 내 위치에서 한개를 지움
    users.splice(index, 1);
  }

  // 생성하기 user table list
    userTable.innerHTML = "";
  let ul = `<table class="table table-hover">`;
  for (let user of users) {
    // 동적으로 만듦
    ul += `<tr class="socket-users" onclick="setActiveUser(this,'${user.username}', '${user.userID}')">
    <td>${user.username}<span class="text-danger ps-1 d-none" id="${user.userID}">!</span></td>
            </tr>
    `;
  }
  ul += `</table>`;
  if (users.length > 0) {
    userTable.innerHTML = ul;
    userTagline.innerHTML = "접속 중인 유저";
    userTagline.classList.remove("text-danger");
    userTagline.classList.add("text-success");
  } else {
    userTagline.innerHTML = "접속 중인 유저 없음";
    userTagline.classList.remove("text-success");
    userTagline.classList.add("text-danger");
  }
});

// 새로고침해도 로컬에 유저가 있다면  보여주기
const sessionUsername = localStorage.getItem("session-username");
const sessionUserID = localStorage.getItem("session-userID");

if (sessionUsername && sessionUserID) {
  socketConnect(sessionUsername, sessionUserID);

  loginContainer.classList.add("d-none");
  chatBody.classList.remove("d-none");
  userTitle.innerHTML = sessionUsername;
}



// server index.js
// 유저 세션 데이타  클라이언트와 연결
app.post("/session", (req, res) => {
  let data = {
    username: req.body.username,
    userID: randomID(),
  };
  res.send(data);
});
const randomID = () => {
  return crypto.randomBytes(8).toString("hex");
};

// 소켓 미들웨어
io.use((socket, next) => {
  const username = socket.handshake.auth.username;
  const userID = socket.handshake.auth.userID;
  if (!username) {
    return next(new Error("Invalid username"));
  }
  // 클라이언트 socketConnect()
  // socket connection 할 때 socket 데이터에서 받아올 수 있다. (io.on(connection))
  socket.username = username;
  socket.id = userID;
  next();
});

// 유저를 담을 곳
let users = [];
// socket 전체 연동
io.on("connection", async (socket) => {
  // 연결된 사람들만 미들웨어에서 데이터 받아옴
  let userData = { username: socket.username, userID: socket.id };
  users.push(userData);
  // socket room에 보냄
  io.emit("users-data", { users });

  // 클라이언트에서 보내온 메시지
  socket.on("message-to-server", () => {});

  // 데이터베이스에서 메시지 가져오기
  socket.on("fetch-messages", () => {});

  // 유저가 방에서 나갔을 때
  socket.on("disconnect", () => {});
});

8. 유저 클릭 시 메시지 데이터 가져오기 및 프론트엔드

// client main.js
// 입장한 유저 클릭 시
const setActiveUser = (element, username, userID) => {
  // 유저 클릭 시 상대방 이름에 표시
  title.innerHTML = username;
  title.setAttribute("userID", userID);

  // 사용자 목록 활성 및 비활성 클래스 이벤트 핸들러
  const list = document.getElementsByClassName("socket-users");
  // 유저 클릭 시 색 표시
  for (let i = 0; i < list.length; i++) {
    list[i].classList.remove("table-active");
  }

  element.classList.add("table-active");

  // 사용자 선택 후 메시지 대화 내용 영역 표시
  msgDiv.classList.remove("d-none");
  messages.classList.remove("d-none");
  messages.innerHTML = "";
  // 디비에서 대화 내용 가져오기
  socket.emit("fetch-messages", { receiver: userID });
  const notify = document.getElementById(userID);
  notify.classList.add("d-none");
};

9. 클라이언트 상대에게 메시지 보내기

// 클라이언트 main.js
// 메시지 전송 시
const msgForm = document.querySelector(".msgForm");
const message = document.getElementById("message");

msgForm.addEventListener("submit", (e) => {
  e.preventDefault();

  const to = title.getAttribute("userID");
  // 지역 현재 시간, hour, minute, 12시간 형식
  const time = new Date().toLocaleString("en-US", {
    hour: "numeric",
    minute: "numeric",
    hour12: true,
  });

  // 메시지 payload 만들기
  const payload = {
    from: socket.id,
    to,
    message: message.value,
    time,
  };
 // 서버에 메시지 payload 전송
  socket.emit("message-to-server", payload);
// 브라우저에 내용 표시 및 스타일링
  appendMessage({ ...payload, background: "bg-success", position: "right" });

  message.value = "";
  message.focus();
});

// 메시지 보냈을 때 내용 표시
const appendMessage = ({ message, time, background, position }) => {
  let div = document.createElement("div");
  div.classList.add(
    "message",
    "bg-opacity-25",
    "m-2",
    "px-2",
    "py-1",
    background,
    position
  );
  div.innerHTML = `<span class="msg-text">${message}</span><span class="msg-time">${time}</span>`;
  messages.append(div);
  messages.scrollTop(0, messages.scrollHeight);
};

10. 서버로 클라이언트 메시지가 전송되면 서버로 받고 클라이언트에게 다시 전달

// 서버 index.js
  // 클라이언트에서 보내온 메시지
  // 클라이언트에서 온 메시지 상대방에게 전달
  const { saveMessages } = require("./utils/messages");
  
  socket.on("message-to-server", (payload) => {
    // 클라이언트에서 온 메시지를 서버로 받고 서버에서 상대방에게 메시지 전달
    io.to(payload.to).emit("message-to-client", payload);
    saveMessages(payload);
  });

// utils messages.js
const messageModel = require("../models/messages.model");

// 수신자와 송신자 판별
const getToken = (sender, receiver) => {
  const key = [sender, receiver].sort().join("");
  return key;
};

const saveMessages = async ({ from, to, message, time }) => {
  try {
    const token = getToken(from, to);
    const data = {
      from,
      message,
      time,
    };

    // await를 사용하여 updateOne 메서드가 Promise를 반환하도록 변경
    await messageModel.updateOne(
      { userToken: token },
      {
        $push: { messages: data },
      }
    );

    console.log("메시지 전송");
  } catch (error) {
    console.error("메시지 전송 중 오류 발생:", error);
  }
};

module.exports = { saveMessages };

11. socket 나간 유저

// 서버 index.js
  // 유저가 방에서 나갔을 때
  socket.on("disconnect", () => {
    // 내가 나갔으니 나를 제외한 사람들 보여주기
    users = users.filter((user) => user.userID !== socket.id);
    // 사이드바에서 리스트 없애기
    io.emit("users-data", { users });
    // 대화 중이라면 대화창 없애기
    io.emit("user-away", socket.id);
  });
  
// 클라이언트 main.js
// 상대 나갈 시 채팅방 없애기
socket.on("user-away", (userID) => {
  const to = title.getAttribute("userID");
  if (to === userID) {
    title.innerHTML = "&nbsp;";
    msgDiv.classList.add("d-none");
    messages.classList.add("d-none");
  }
});

12. db에서 메시지 내용 가져오기

// utils/messages.js
const fetchMessages = async (io, sender, receiver) => {
  try {
    const token = getToken(sender, receiver);
    // 디비에서 토큰에 해당하는 메시지를 찾음
    const foundToken = await messageModel.findOne({ userToken: token });

    if (foundToken) {
      // 메시지를 찾았을 경우 클라이언트에게 메시지 전송
      io.to(sender).emit("stored-messages", { messages: foundToken.messages });
    } else {
      // 토큰에 해당하는 메시지가 없을 경우 새로운 메시지 생성
      const data = {
        userToken: token,
        messages: [],
      };
      const newMessage = new messageModel(data);
      await newMessage.save();
      console.log("새로운 메시지 생성");
    }
  } catch (error) {
    console.error("메시지 가져오기 중 오류 발생:", error);
  }
};

// 서버 index.js
  // 데이터베이스에서 메시지 가져오기
  socket.on("fetch-messages", ({ receiver }) => {
    fetchMessages(io, socket.id, receiver);
  });
  
  // 클라이언트 main.js
  // 서버에서 디비에 저장된 메시지 가져오기
socket.on("stored-messages", ({ messages }) => {
  if (messages.length > 0) {
    messages.forEach((msg) => {
      const payload = {
        message: msg.message,
        time: msg.time,
      };
      if (msg.from === socket.id) {
        appendMessage({
          ...payload,
          background: "bg-success",
          position: "right",
        });
      } else {
        appendMessage({
          ...payload,
          background: "bg-secondary",
          position: "left",
        });
      }
    });
  }
});

소스코드: 깃허브

profile
개발 경험치 쌓는 곳

0개의 댓글