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>
<div class="bg-primary p-2 d-flex">
<h5 class="text-white">채팅방</h5>
</div>
<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">
<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>
<div class="col-8">
<div class="chat-container">
<div class="title p-2 bg-success bg-opacity-50">
상대방 이름: <span id="active-user"> </span>
</div>
<div class="messages p-2"></div>
<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 = " ";
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",
});
}
});
}
});