티스토리 뷰
유투브를 보다가 코딩알려주는누나의 웹소켓 강의를 발견!!
마침 서버에 관심을 갖고 있던터라 바로 강의를 듣고 구현을 완료했다 👏👏👏👏👏
처음 node.js와 mongodb를 "제대로" 사용하는거라 중간에 node.js기초 강의도 듣고 mongodb를 사용하는 방법(예전에 배운것과 너무 달랐다..ㄷㄷ)도 제대로 알게된것 같다.
https://www.youtube.com/watch?v=uE9Ncr6qInQ&t=0s
강의가 node.js에 대한 기초 지식을 어느정도 갖고있는 사람들을 위한 느낌이라 내가 구현하면서 추가로 진행한 부분을 정리하도록 하겠다.
🚀 웹 소켓
사용자의 브라우저와 서버 사이의 인터액티브 통신 세션을 설정할 수 있게 하는 기술
웹 소켓 API를 통해 서버로 메세지를 보내고 서버의 응답을 위해 서버를 폴링하지 않고도 이벤트 중심 응답을 받는 것이 가능하다
HTTP 통신의 경우 단방향 통신이기 때문에 클라이언트에서 요청을 보내야만 서버로부터 응답을 보낼수 있기 때문에 연결의 지속성이 없다.
하지만 웹 소켓은 양뱡향 통신이기 때문에 클라이언트와 서버가 응답과 요청을 서로 주고 받아 연결이 끊어지지 않는다.
Socket.IO
웹 소켓 연결을 통해 클라이언트와 서버 간 실시간 양방향 통신을 가능하게 하는 자바스크립트 라이브러리
socket.io는 클라이언트와 서버 간의 실시간 통신을 위한 사용하기 쉬운 인터페이스를 제공해 실시간 업데이트 또는 양방향 통신이 필요한 응용 프로그램을 구축하는데 널리 사용된다.
socket.io를 사용해 웹 소켓을 구현했다!
진행 순서
1. 백엔드 세팅 : 데이터베이스 세팅, 웹 소켓 세팅
2. 프론트엔드 세팅 : 웹 소켓 세팅
3. 백엔드 프론트엔드 연결 테스트
4. 유저 로그인 기능
5. 메세지 주고 받기
1. 백엔드 세팅
node.js 프로젝트를 초기화하고 package.json 파일을 생성한다.
npm init -y
프로젝트에서 사용할 라이브러리를 설치한다.
- express -> 서버 만들기(데이터베이스 올릴 서버)
- mongoose -> mongodb를 더 쉽게 사용할 수 있는 라이브러리
- cors -> cors 에러를 우회할수 있도록 돕는 라이브러리
- dotenv -> 환경변수를 관리하는 라이브러리
- http -> http 서버를 만들기 위한 라이브러리(웹 소켓을 올릴 서버)
- socket.io
여기서 mongodb를 데이터베이스로 사용하기 때문에 설치를 미리 해주어야 한다.
+ nodemon을 사용해 서버를 키면 업데이트 부분을 바로 확인할 수 있음
📍MongoDB 설치하기
Homebrew를 이용해 설치
터미널에 MongoDB Homebrew tap을 추가한다.
brew tap mongodb/brew
mongodb를 설치한다.
brew install mongodb/brew/mongodb-community
(엄----청 오래 걸린다)
설치 후 mongodb를 실행한다.
brew services start mongodb-community
mongodb가 실행되고 있는지 확인하려면 아래 명령어를 입력한다.
brew services list
실행이 되고 있는지 확인한 후 mongo 명령어를 입력해 mongodb에 접속할 수 있다.
❗️ 이때 command not found가 뜬다면 mongodb-community-shell을 추가로 설치한다.
brew install mongodb-community-shell
mongo에 접속한 상태에서 나가고 싶다면 exit 명령어를 입력한다.
▪︎ 데이터 베이스 세팅
저장해야할 데이터는 2가지
- 유저 정보
- 메세지(채팅) 정보
mongoose에서 제공하는 schema를 사용해 유저 스키마와 챗 스키마를 만든다.
- schema: 데이터 설계도
// Models/user.js
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, "User must type name"],
unique: true,
},
token: {
type: String,
},
online: {
type: Boolean,
default: false,
},
});
module.exports = mongoose.model("User", userSchema);
// Models/chat.js
const mongoose = require("mongoose");
const chatSchema = new mongoose.Schema(
{
chat: String,
user: {
id: {
type: mongoose.Schema.ObjectId,
ref: "User",
},
name: String,
},
},
{ timestamps: true }
);
module.exports = mongoose.model("Chat", chatSchema);
user의 경우에는 name, token, online 필드를 가지고 chat은 chat, user 필드를 가진다.
스키마 설정이 완료되면 데이터베이스에 연결한다.
express로 서버를 만든 후 cors 에러를 풀어주기 위해 cors 메소드를 사용한다.
mongoose의 connect 메소드로 데이터베이스와 연결해준다.
// app.js
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
const app = express();
app.use(cors());
mongoose
.connect(mongodb://127.0.0.1:27017/kakao-talk)
.then(() => console.log("connected to database"));
module.exports = app;
❗️ mongodb에 사용할 데이터베이스를 미리 만들어주어야 한다.
이 강의에서는 뒤에 /kakao-talk이라는 엔드포인트를 사용하기 때문에 미리 mongodb에 kakao-talk이라는 데이터베이스를 추가했다.
mongodb에 데이터베이스를 추가할때는 use 명령어를 사용한다.
use kakao-talk
use 명령어는 뒤에 붙는 데이터베이스 이름을 가진 db에 입장하는 것으로 존재하지 않는 db의 경우에는 새로 생성한 후 입장한다.
환경변수로 데이터베이스 주소 관리하기
.env 파일을 만들어 데이터베이스 주소를 담아 사용한다.
DB= mongodb://127.0.0.1:27017/kakao-talk
환경변수를 사용하기 위해 dotenv를 로드한 후 process.env 객체를 통해 접근한다.
require("dotenv").config();
mongoose
.connect(process.env.DB)
.then(() => console.log("connected to database"));
module.exports = app;
▪︎ 웹 소켓 세팅
http 서버를 만들어 그 위에 웹 소켓과 데이터베이스를 올린다.
// index.js
const { createServer } = require("http");
const app = require("./app");
const { Server } = require("socket.io");
// http 서버 만들기
// createServer에 app을 올려 서버를 만듬
const httpServer = createServer(app);
// 웹소켓 서버 만들기
// httpServer를 올림
const io = new Server(httpServer, {
// cors 세팅 해주기 <- 클라이언트 주소를 허락해야 접근할 수 있음
cors: {
origin: "http://localhost:3000",
},
});
웹 소켓 서버를 만들때는 옵션에 cors에러에 대한 세팅을 해준다.
왜? <- 서버 주소와 클라이언트 주소가 다르기 때문!!
서버를 만들고 나면 listen 메소드로 서버를 틀어놓으면 된다.
여기서 통신하는 코드는 한 파일에 모두 담기엔 길기 때문에 따로 분리해 작성한다.
웹 소켓 통신하기
Socket.IO에서 제공하는 함수 중 두 가지를 사용한다.
- .emit( ) : 말하는 함수 -> 전송
- .on( ) : 듣는 함수 -> 수신
처음에는 연결 요청을 "들어야"하기 때문에 .on( )을 사용해 연결에 대한 요청을 인식한다.
연결이 완료되면 socket이라는 매개변수에 연결된 사람의 정보를 담아 보내준다.
// utils/io.js
module.exports = function (io) {
// 연결이 되었는지 먼저 듣기!
io.on("connection", async (socket) => {
// socket에 연결된 사람의 정보를 담아 보내줌
console.log("client is connected", socket.id);
});
};
2. 프론트엔드 세팅
프론트엔드 프로젝트는 강의에서 주어진 깃헙 레포지토리를 사용
프론트엔드 프로젝트에서도 Socket.IO를 사용하기 위해 설치한다.
npm install socket.io-client
프론트엔드에서 백엔드 서버와 연결하기 위해 io를 가져와 백엔드 서버의 주소를 넘겨준다
// src/server.js
import { io } from "socket.io-client";
// 백엔드 서버와 연결
const socket = io("http://localhost:5001");
export default socket;
App 컴포넌트에 socket을 가져와 연결 테스트를 진행한다.
// src/App.js
import socket from "./server";
3. 백엔드 프론트엔드 연결 테스트하기
npm start로 프론트엔드 프로젝트를 실행하고 nodemon으로 서버를 켜준다.
여기에서 나는 계속 zsh: command not found: nodemon 이라는 오류가 계속 발생해
package.json 파일의 script 부분에 실행 명령어를 추가했다.
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
},
npm run dev로 서버를 키고 연결이 되었으면 터미널에 메세지가 뜬다.
+ 연결이 끊겼을 때의 동작 추가하기
소켓에 연결된 이후 브라우저를 닫아 소켓과의 연결이 끊어질때의 동작을 추가한다.
프론트엔드의 소켓에서 연결이 끊겼다는 것을 "들어야" 동작을 수행하기 때문에 .on( )메소드를 사용한다.
module.exports = function (io) {
// 연결이 되었는지 먼저 듣기!
io.on("connection", async (socket) => {
// socket에 연결된 사람의 정보를 담아 보내줌
console.log("client is connected", socket.id);
// socket 연결이 끊겼을 때
socket.on("disconnect", () => {
console.log("user is disconnect");
});
});
};
4. 유저 로그인 구현
로그인은 컴포넌트가 렌더링될 때 프롬프트를 띄워 유저 이름을 받아오는 것으로 진행했다.
▪︎ 프론트엔드에서 유저 이름 넘겨주기
userName을 socket으로 넘겨주는 함수를 작성한다.
function App() {
const askUserName = () => {
const userName = prompt("당신의 이름을 입력하세요");
console.log(userName);
// socket에 로그인한 userName 보내기
socket.emit("login", userName, (res) => {
console.log(res);
});
};
useEffect(() => {
askUserName();
}, []);
...
}
최초 렌더링 시 askUserName 함수가 실행되고 프롬프트에 입력된 문자를 socket에 "전송"한다.
- .emit(대화제목, 보낼 내용, 콜백함수)
전달된 콜백함수는 emit요청이 처리가 되면 실행된다.
▪︎ 백엔드에서 로그인 처리하기
"login"에 대한 통신이 들어왔을 때의 처리할 동작을 정의한다.
로그인에 대한 함수는 통신과는 상관이 없는 부분이기 때문에 따로 Controllers폴더로 빼준다.
userSchema로 유저 정보에 대한 데이터 구조를 정해두었기 때문에 소켓으로 전달받은
userName으로 정보를 저장하고 소켓 id를 토큰으로 저장한다.
socket id는 어디서? io에서 넘겨받음!!
여기서 유저 정보가 이미 존재하는 재방문 유저인 경우에는 유저 정보를 새로 만들지 않아야 한다.
유저 정보 저장 순서
1. 이미 있는 유저인지 확인
2. 없다면(첫 방문) 새로 유저 정보 만들기
3. 이미 있는 유저라면 연결 정보의 token값만 바꿔주기
유저 정보를 확인하기 위해 user Model에 동일한 userName이 있는지를 확인한다.
userController라는 객체를 만들어 여기에 함수를 담아 사용한다.
// Controllers/user.controller.js
const userController = {};
const User = require("../Models/user");
userController.saveUser = async (userName, sid) => {
// 이미 있는 유저인지 확인
// const로 선언시 'Assignment to constant variable'라는 오류가 뜸
let user = await User.findOne({ name: userName });
// 없다면 새로 유저정보 만들기
if (!user) {
user = new User({
name: userName,
token: sid,
online: true,
});
}
// 이미 있는 유저라면 연결정보 token값만 바꿔주기
user.token = sid;
user.online = true;
await user.save();
return user;
};
module.exports = userController;
강의와 다른 부분은 user를 선언할 때 const로 선언할 시 Assignment to constant variable라는 오류가 뜬다.
때문에 이 부분은 let키워드를 사용한 코드로 수정했다.
❗️node.js는 비동기적으로 처리하기 때문에 동기적으로 처리해야하는 함수의 경우에는 처리를 해준다.
userName이 존재한다면 token만 변경하고 user가 없다면 새로운 유저 정보를 생성하는 동작을 실행한다.
생성 또는 변경된 유저 정보를 저장한 후 리턴!
생성된 userController의 saveUser 함수를 사용해 로그인을 구현하자!
socket에서 전송한 'login'통신을 "듣고" 유저 정보를 저장해야 하기 때문에 .on( )메소드를 사용한다.
프론트엔드에서 userName과 콜백함수를 파라미터로 받아와 userController의 saveUser에 userName을 넘겨주고
socket에서 id를 가져와 전달한다.
이때 콜백 함수에 요청이 성공했을 때와 실패했을 때 각각 다른 응답을 보내주기 위해 try catch문을 사용했다.
// utils/io.js
const userController = require("../Controllers/user.controller");
module.exports = function (io) {
io.on("connection", async (socket) => {
// userName을 받아 login 요청이 들어올 때
socket.on("login", async (userName, cb) => {
try {
// 유저정보를 저장
const user = await userController.saveUser(userName, socket.id);
cb({ ok: true, data: user });
} catch (err) {
cb({ ok: false, error: err.message });
}
});
socket.on("disconnect", () => {
console.log("user is disconnect");
});
});
};
코드를 저장한 후 프롬프트에 이름을 작성하고 확인을 누르면 콘솔창에 결과가 뜬다.
▪︎ 클라이언트에서 유저 정보 저장하기
서버에서 보낸 유저 정보를 클라이언트에서 state로 관리한다.
.emit( )으로 보낸 콜백함수는 통신이 완료되면 실행되기 때문에 응답을 받아와 데이터를 state에 저장한다.
const [user, setUSer] = useState(null);
const askUserName = () => {
const userName = prompt("당신의 이름을 입력하세요");
// socket에 로그인한 userName 보내기
socket.emit("login", userName, (res) => {
// 응답으로 받아온 유저정보를 state에 저장
if (res?.ok) {
setUSer(res.data);
}
});
};
5. 메세지 주고 받기
먼저 프론트엔드에서 메세지 전송을 위한 UI를 만들어준다.
강의에서 InputField 컴포넌트를 제공했기 때문에 이를 사용했다!
// components/InputField.jsx
const InputField = ({message,setMessage,sendMessage}) => {
return (
<div className="input-area">
<div className="plus-button">+</div>
<form onSubmit={sendMessage} className="input-container">
<Input
placeholder="Type in here…"
value={message}
onChange={(event) => setMessage(event.target.value)}
multiline={false}
rows={1}
/>
<Button
disabled={message === ""}
type="submit"
className="send-button"
>
전송
</Button>
</form>
</div>
)
}
InputField에서 전달받는 props는 message, setMessage, sendMessage로 부모 컴포넌트인 App 컴포넌트에서 정의한다.
message와 setMessage는 state를 선언해 넘겨주고
sendMessage는 소켓에 'sendMessage'라는 대화 제목과 채팅 내용(message), 그리고 콜백함수를 전달한다.
function App() {
const [user, setUSer] = useState(null);
const [message, setMessage] = useState("");
const [messageList, setMessageList] = useState([]);
...
const sendMessage = (event) => {
// 새로고침 막기
event.preventDefault();
socket.emit("sendMessage", message, (res) => {
console.log("sendMessage res", res);
});
setMessage("");
};
return (
<div>
<div className="App">
<InputField
message={message}
setMessage={setMessage}
sendMessage={sendMessage}
/>
</div>
</div>
);
}
▪︎ 백엔드에서 채팅 메세지 처리하기
메세지 저장 컨트롤러 또한 Controllers 폴더에서 관리한다.
chatSchema로 정의한 데이터 구조에 따라 메세지 저장에 필요한 것은 chat(메세지 내용)과 유저 정보이다.
유저 정보는 받지 않았는데 어떻게 알지????
⇨ socket에서 유저 정보를 알 수 있다!
어떻게? socket id를 알기 때문!!!!!!
따라서 메세지를 저장하기 전에 socket id로 유저 정보를 가져온 다음에 메세지를 저장한다.
유저 정보는 userController에서 user의 토큰이 socket id와 일치한 유저를 찾아 리턴해 준다.
// Controllers/user.controller.js
userController.checkUser = async (sid) => {
const user = await User.findOne({ token: sid });
if (!user) throw new Error("user not found");
return user;
};
chatController객체를 만들어 saveChat 함수를 작성한다.
saveChat함수는 메세지와 io에서 리턴받은 user를 받아와 사용한다.
const Chat = require("../Models/chat");
const chatController = {};
chatController.saveChat = async (message, user) => {
const newMessage = new Chat({
chat: message,
user: {
id: user._id,
name: user.name,
},
});
await newMessage.save();
return newMessage;
};
module.exports = chatController;
새로운 메세지를 저장한후 리턴한다.
생성된 chatController의 saveChat 함수를 사용해 메세지를 저장한다.
useController의 checkUser에 socket id를 전달해 유저 정보를 가져온 다음
chatController의 saveChat에 메세지와 유저 정보를 전달해 새로운 메세지를 생성한다.
const chatController = require("../Controllers/chat.controller");
module.exports = function (io) {
io.on("connection", async (socket) => {
...
// 메세지가 전송되었을 때
socket.on("sendMessage", async (message, cb) => {
try {
// socket id로 유저찾기
const user = await userController.checkUser(socket.id);
// 메세지 저장
const newMessage = await chatController.saveChat(message, user);
// 새로운 메세지가 생기면 접속중인 유저에게 모두 알림
io.emit("message", newMessage);
cb({ ok: true });
} catch (err) {
cb({ ok: false, error: err.message });
}
});
});
};
새로운 메세지가 생성되면 채팅방에 존재하는 모든 유저들에게도 공유되어야하기 때문에 클라이언트에 "말해야"한다.
따라서 .emit( )을 사용해 메세지를 전달한다.
▪︎ 서버에서 전달받은 메세지를 클라이언트에서 처리하기
클라이언트에서 보낸 메세지를 state로 관리한다.
서버에서 받은 메세지를 messageList에 담아준다.
기존에 존재한 메세지 리스트의 뒤에(차례대로) 합쳐줘야하기 때문에 .concat( )을 사용해 합쳐준다.
function App() {
const [messageList, setMessageList] = useState([]);
...
useEffect(() => {
// 메세지가 전송되면 서버에서 받아오기
socket.on("message", (message) => {
setMessageList((prevState) => prevState.concat(message));
});
}, []);
...
}
새로운 메세지가 들어오면 컴포넌트가 리렌더링되기 때문에 렌더링될 때 메세지를 받아온다.
또, 프론트엔드에서는 메세지 리스트를 보낸 사람과 받는 사람에 따라 다르게 UI를 구성해야 한다.
(여기서는 강의에서 주어진 MessageContainer 컴포넌트를 사용)
const MessageContainer = ({ messageList, user }) => {
return (
<div>
{messageList.map((message, index) => {
return (
<Container key={message._id} className="message-container">
// 메세지의 유저 정보에 따라 다르게 렌더링
{message.user.name === "system" ? (
<div className="system-message-container">
<p className="system-message">{message.chat}</p>
</div>
) : message.user.name === user.name ? (
<div className="my-message-container">
<div className="my-message">{message.chat}</div>
</div>
) : (
<div className="your-message-container">
<img
src="/profile.jpeg"
className="profile-image"
style={
(index === 0
? { visibility: "visible" }
: messageList[index - 1].user.name === user.name) ||
messageList[index - 1].user.name === "system"
? { visibility: "visible" }
: { visibility: "hidden" }
}
/>
<div className="your-message">{message.chat}</div>
</div>
)}
</Container>
);
})}
</div>
);
};
+ 시스템 메세지 보여주기
로그인후 채팅방 입장시 시스템 메세지를 보여준다.
따라서 로그인 통신이 들어왔을 때 시스템 메세지를 만들어 함께 보내준다.
module.exports = function (io) {
// 연결이 되었는지 먼저 듣기!
io.on("connection", async (socket) => {
// userName을 받아 login 요청이 들어올 때
socket.on("login", async (userName, cb) => {
try {
// 유저정보를 저장
const user = await userController.saveUser(userName, socket.id);
// 웰컴 메세지
const welcomeMessage = {
chat: `${user.name} is joined to this room`,
user: { id: null, name: "system" },
};
io.emit("message", welcomeMessage);
cb({ ok: true, data: user });
} catch (err) {
cb({ ok: false, error: err.message });
}
});
...
// socket 연결이 끊겼을 때
socket.on("disconnect", () => {
console.log("user is disconnect");
});
});
};
서버에서 "말하는"것이기 때문에 .emit( )으로 통신을 보낸다.
구현한 채팅창!
'코딩 > 코딩노트' 카테고리의 다른 글
[Remix]Remix 공식문서 파헤치기 2탄 - 데이터의 흐름과 상태 관리 (0) | 2024.04.15 |
---|---|
[Remix] Remix 공식 문서 파헤치기 1탄 - Remix가 뭔데?!(route 집중 파보기) (0) | 2024.04.11 |
[React] useContext로 데이터에 쉽게 접근하기 (0) | 2024.03.28 |
[React] React Hooks를 알아보자 (부제: 프로젝트 되돌아보기) (0) | 2024.03.24 |
[React] React와 JSX (0) | 2024.03.21 |