웹소켓 3편에서는 NextJS, Express, Socket.io를 통해서 Polling, 웹소켓 방식의 간단한 실시간 채팅을 만들어보고 둘의 차이점을 알아보자
예제를 진행하기 위해서는 프론트, 백엔드 둘 다 구성해야 한다.
프론트 : TypeScript, NextJS, TailwindCSS
백엔드 : TypeScript, Express
아래 과정은 백엔드 프로젝트 디렉토리 내부에서 진행해야 한다!!
위 예시의 경우에는 be
디렉토리 내부에서 진행하면 된다!
npm init -y
npm i @types/node express cors
tsconfig.json
파일 생성
{
"compilerOptions": {
"types": ["node"],
"moduleDetection": "force",
"module": "CommonJS",
"target": "ES6",
"esModuleInterop": true
}
}
"src"디렉토리 생성 후 server.ts
파일 생성
const express = require("express");
const cors = require("cors");
const http = require("http");
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const server = http.createServer(app);
let chatMessages = []; //채팅을 저장하기 위한 변수
app.post("/message", (req, res) => {
const { message } = req.body;
if (message) {
chatMessages.push(message);
res.status(200).json({ success: true, message: "Message received" });
} else {
res.status(400).json({ success: false, message: "No message provided" });
}
});
app.get("/message", async (req, res) => {
res.status(200).json(chatMessages);
});
server.listen(5000, () => console.log("Server running on port 5000"));
"server.ts"파일이 존재하는 "src" 디렉토리에서 nodemon server.ts
➜ src nodemon server.ts
[nodemon] 3.1.3
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node server.ts`
Server running on port 5000
위 로그가 터미널에 나온다면 제대로 실행된 것이다!
npx create-next-app "프로젝트 명"
import "./globals.css";
export const metadata = {
title: "Next.js",
description: "Generated by Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="h-svh flex justify-center items-center w-full">
{children}
</body>
</html>
);
}
"use client";
import { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
export default function page() {
const [chatLog, setChatLog] = useState([]);
const messageRef = useRef<HTMLInputElement>(null);
const submitMessageApiHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (messageRef.current?.value) {
try {
const result = await fetch("http://localhost:5000/message", {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: messageRef.current?.value,
}),
});
if (result.ok) {
console.log("submit Message success!!!");
}
messageRef.current.value = "";
} catch (error) {
console.log(error);
}
}
};
const getMessagesApiHandler = async () => {
try {
const result = await fetch("http://localhost:5000/message", {
method: "get",
});
if (result.ok) {
const data = await result.json();
setChatLog(data);
}
} catch (error) {
console.log(error);
}
};
useEffect(() => {
const messagePolling = setInterval(getMessagesApiHandler, 100);
return () => {
clearInterval(messagePolling);
};
}, []);
return (
<div className="w-3/5 sm:w-2/5 h-3/5 border-[2px] border-black flex flex-col justify-between items-center bg-yellow-200">
<div className="text-center py-2 text-2xl text-white bg-green-500 w-full">
Chatting
</div>
<ul className="w-full h-full flex flex-col gap-2 px-4 py-2 overflow-y-auto">
{chatLog.map((message) => (
<li className="bg-white p-2 break-words whitespace-pre-line w-fit max-w-[60%] rounded-xl shadow-md">
{message}
</li>
))}
</ul>
<form
className="w-full flex border-t-[1px] border-t-black"
onSubmit={submitMessageApiHandler}
>
<input
type="text"
className="w-5/6 outline-none py-1 px-2 bg-green-900 text-xl text-white"
ref={messageRef}
/>
<button type="submit" className="bg-white w-1/6">
전송
</button>
</form>
</div>
);
}
위 코드에서 주의깊게 볼 점은 useEffect
훅 안에 있는 setInterval
함수이다.
const getMessagesApiHandler = async () => {
try {
const result = await fetch("http://localhost:5000/message", {
method: "get",
});
if (result.ok) {
const data = await result.json();
setChatLog(data);
}
} catch (error) {
console.log(error);
}
};
useEffect(() => {
const messagePolling = setInterval(getMessagesApiHandler, 100);
return () => {
clearInterval(messagePolling);
};
}, []);
setInterval
함수는 두번째 인자로 정의한 시간만큼 첫번째 인자의 함수를 반복해서 실행시켜주는 역할을 한다.
즉, 100ms마다 메세지를 가져오는 api를 실행해서 실시간 채팅처럼 보이게 하는 방식이고 이 방식을 Polling 방식이라고 한다.
위와같이 Polling 방식으로 구현한 실시간 채팅도 겉으로 보기에는 큰 문제없이 동작하는거처럼 보인다.
다만, 여기서 개발자 도구의 네트워크 탭을 보면 Polling 방식이 실시간으로 보이기 위해서 어떤 식으로 진행되는지 볼 수 있다.
실시간 채팅으로 보이기 위해서 setInterval
함수를 이용해서 100ms마다 API호출을 하다보니 개발자 도구의 네트워크 탭에 API호출이 무한대로 이뤄지는걸 확인할 수 있다.
또한, 메세지데이터들이 최신으로 업데이트 되지 않은 상황인데도 끊임없이 API호출을 보내서 불필요한 API호출이 발생하고 있다.
만약 이러한 상황이 한명의 클라이언트가 아닌 다수의 클라이언트에서 발생한다면 서버는 과부하에 걸릴 것이고 끝나지 않는 오버헤드가 발생할 것이다.
HTTP통신의 특징인 단방향 통신으로 인해서 클라이언트는 서버로 하나의 요청을 계속해서 보내고 보낸 요청들은 응답을 보내고 난 후 연결이 끊어지는 방식으로 인해서 위와같은 결과가 나타나게 된다.
그렇다면 웹소켓으로 구현한 실시간 채팅은 어떨까?
npm init -y
npm i @types/node express cors socket.io
tsconfig.json
파일 생성
{
"compilerOptions": {
"types": ["node"],
"moduleDetection": "force",
"module": "CommonJS",
"target": "ES6",
"esModuleInterop": true
}
}
"src"디렉토리 생성 후 server.ts
파일 생성
import express, { Application } from "express";
import cors from "cors";
import http from "http";
import { Server, Socket } from "socket.io";
const app: Application = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const server = http.createServer(app);
let chatMessages: Array<{ message: string }> = [];
const io = new Server(server, {
cors: {
origin: "*",
},
});
io.on("connection", (socket: Socket) => {
socket.on("message", (data: { message: string }) => {
chatMessages.push(data);
io.emit("message", data);
});
});
server.listen(5000, () => console.log("Server running on port 5000"));
nodemon server.ts
➜ src nodemon server.ts
[nodemon] 3.1.3
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node server.ts`
Server running on port 5000
위 로그가 터미널에 나온다면 제대로 실행된 것이다!npx create-next-app "프로젝트 명"
npm i socket.io-client
socket.io을 이용하기 위한 클라이언트 전용 socket.io패키지 설치
import "./globals.css";
export const metadata = {
title: "Next.js",
description: "Generated by Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="h-svh flex justify-center items-center w-full">
{children}
</body>
</html>
);
}
"use client";
import { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
const chatSocket: Socket = io("ws://localhost:5000");
type TChatMessage = {
message: string;
};
export default function page() {
const [chatLog, setChatLog] = useState<TChatMessage[]>([]);
const messageRef = useRef<HTMLInputElement>(null);
const submitMessageApiHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (messageRef.current?.value) {
chatSocket.emit("message", { message: messageRef.current.value });
messageRef.current.value = "";
}
};
const getMessagesSocketHandler = (data: TChatMessage) => {
setChatLog((prevChatLog) => [...prevChatLog, data]);
};
useEffect(() => {
chatSocket.on("message", getMessagesSocketHandler);
return () => {
chatSocket.off("message", getMessagesSocketHandler);
};
}, []);
return (
<div className="w-3/5 sm:w-2/5 h-3/5 border-[2px] border-black flex flex-col justify-between items-center bg-yellow-200">
<div className="text-center py-2 text-2xl text-white bg-green-500 w-full">
Chatting
</div>
<ul className="w-full h-full flex flex-col gap-2 px-4 py-2 overflow-y-auto">
{chatLog.map((message, index) => (
<li
className="bg-white p-2 break-words whitespace-pre-line w-fit max-w-[60%] rounded-xl shadow-md"
key={index}
>
{message.message}
</li>
))}
</ul>
<form
className="w-full flex border-t-[1px] border-t-black"
onSubmit={submitMessageApiHandler}
>
<input
type="text"
className="w-5/6 outline-none py-1 px-2 bg-green-900 text-xl text-white"
ref={messageRef}
/>
<button type="submit" className="bg-white w-1/6">
전송
</button>
</form>
</div>
);
}
위와같이 웹소켓으로 구현한 실시간 채팅역시 Polling 방식과 마찬가지로 제대로 동작하는걸 확인할 수 있다.
다만, 차이점을 보기 위해서 개발자도구의 네트워크 탭을 보면 Polling 방식과의 차이점을 확인할 수 있다.
웹소켓은 네트워크 탭에 socket.io
라는 네트워크 항목이 생기면서 해당 항목의 상태가 101
로 연결된 상태를 확인할 수 있다.
여기서 101
은 소켓이 정상적으로 연결된 걸 의미한다.
소켓 네트워크 탭을 누른 후 "메세지"탭을 클릭하면 소켓이 연결된 시점으로부터 어떠한 메세지를 주고 받았는지 로그가 작성되어 있다.
즉, Polling 방식과는 달리 소켓이 연결된 시점으로부터 연결이 끊기지 않고 데이터를 지속적으로 주고 받았다는 걸 확인할 수 있다. 이말은 Polling 방식처럼 지속적으로 요청을 보내지 않아도 된다는 뜻이다.
// 클라이언트
import { io, Socket } from "socket.io-client";
const chatSocket: Socket = io("ws://localhost:5000");
// 서버
import { Server, Socket } from "socket.io";
const io = new Server(server, {
cors: {
origin: "*",
},
});
io.on("connection", (socket: Socket) => {
socket.on("message", (data: { message: string }) => {
chatMessages.push(data);
io.emit("message", data);
});
});
위 로직은 프론트, 백엔드에서 소켓을 정의 or 연결하는 로직이다.
// 클라이언트
import { io, Socket } from "socket.io-client";
const chatSocket: Socket = io("ws://localhost:5000");
클라이언트에서 io
메서드의 첫번째 인자로 정의된 소켓 프로토콜(ws) 주소로 연결을 시도한다.
연결에 성공한다면 "chatSocket"변수는 해당 소켓에 이벤트를 송,수신하는 역할로 사용하게 된다.
// 서버
import { Server, Socket } from "socket.io";
const app: Application = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
},
});
io.on("connection", (socket: Socket) => {
socket.on("message", (data: { message: string }) => {
chatMessages.push(data);
io.emit("message", data);
});
});
io.on
의 첫번째 인자인 connection
은 클라이언트로부터 소켓이 연결되었을 때 두번째 인자인 콜백함수에 연결된 클라이언트의 소켓 송,수신에 관련해서 로직을 정의할 수 있다.
io
은 전체 서버와 연결된 Socket.io 서버객체를 나타낸다. 즉, 서버에서 발생하는 모든 클라이언트와의 통신을 관리한다.
socket
은 특정 클라이언트와의 개별 연결을 나타낸다. 즉, 한 클라이언트와의 상호작용을 처리하는데 사용된다.
// 클라이언트
const submitMessageApiHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (messageRef.current?.value) {
chatSocket.emit("message", { message: messageRef.current.value });
messageRef.current.value = "";
}
};
const getMessagesSocketHandler = (data: TChatMessage) => {
setChatLog((prevChatLog) => [...prevChatLog, data]);
};
useEffect(() => {
chatSocket.on("message", getMessagesSocketHandler);
return () => {
chatSocket.off("message", getMessagesSocketHandler);
};
}, []);
///서버
io.on("connection", (socket: Socket) => {
socket.on("message", (data: { message: string }) => {
chatMessages.push(data);
io.emit("message", data);
});
});
// 클라이언트
chatSocket.emit("message", { message: messageRef.current.value });
위 로직은 클라이언트에서 소켓에 이벤트를 송신하는 함수이다.
위에서 클라이언트의 emit
메서드는 첫번째 인자로 정의된 "message"라는 이벤트
에다가 두번째 인자로 정의된 객체 데이터를 송신하는 것이다.
// 서버
socket.on("message", (data: { message: string }) => {
chatMessages.push(data);
io.emit("message", data);
});
즉, 서버에서 동일한 이벤트("message") 이름을 가진 on
메서드를 정의하였다면 서버에서 해당 이벤트on
메서드의 두번째 콜백함수의 인자로 전달되게 된다.
여기서 on
메서드 두번째 인자의 "data"부분에 클라이언트에서 송신한 데이터가 담기게 된다.
// 클라이언트
const getMessagesSocketHandler = (data: TChatMessage) => {
setChatLog((prevChatLog) => [...prevChatLog, data]);
};
useEffect(() => {
chatSocket.on("message", getMessagesSocketHandler);
return () => {
chatSocket.off("message", getMessagesSocketHandler);
};
}, []);
위 로직은 서버에서 소켓을 통해 보낸 데이터를 클라이언트에서 수신하는 영역이다.
클라이언트에서 정의한 소켓변수인 "chatSocket"을 통해서 해당 소켓의 이벤트로 온 데이터를 수신한다.
// 서버
io.emit("message", data);
즉, 서버에서 동일한 이벤트("message") 이름을 가진 emit
메서드를 정의하였다면 클라이언트의 해당 이벤트 on
메서드의 두번째 함수로 데이터가 전달되게 된다.
여기서 주의할 점은 클라이언트에서 소켓이벤트를 받을 때 useEffect
의 클린업을 통해서 해당 이벤트를 off
메서드를 통해서 해당 소켓 이벤트 리스너를 제거하는걸 확인할 수 있다.
클린업함수를 통해서 소켓 이벤트 리스너를 제거하는 이유로는 해당 리스너가 계속해서 메모리에 남을 수 있어서 메모리 누수가 발생할 수 있다.
또한 재랜더링 될 때마다 동일한 소켓 이벤트 리스너가 여러번 등록될 수 있기에 클린업 함수로 소켓 이벤트 리스너를 제거해줘야 한다.
만약 클린업 함수로 제거하지 않는다면 이전에 등록된 소켓 이벤트 리스너가 제거되지 않아서 중복된 이벤트 리스너가 위와같이 동작하면 동일한 동작을 여러번 수행 할 수 있다.
예제 코드에서는 모든 로직을 "app/pages.tsx"파일에다가 정의하였지만 소켓을 적용하려는 실제 프로젝트에서는 아래와 같이 소켓 정의함수, 이벤트 처리 함수 등등 나눠서 파일을 관리하면 소켓 로직을 관리하기에 용이하다고 생각한다..!
이번 포스팅에서는 실시간 데이터 통신 방법인 Polling
방식과 웹소켓
방식을 이용한 실시간 채팅을 직접 구현해보고 어떤 차이가 있는지 알아봤다. Polling
방식은 상대적으로 구현하기 쉽지만 단방향 통신으로 실시간 데이터 통신을 구현하기에 서버의 과부하가 일어날 수 있다는 단점이 있고 웹소켓
방식은 양방향 통신으로 한번 연결하면 연결이 끊기지 않는 장점이 있다.
다만, 웹소켓
에도 장점만 있는건 아니다. 초기 연결 하는데 오버헤드가 발생할 수 있고 비정상적인 연결 종료 처리와 다수의 프로세스를 통해 서버를 운영할 시 소켓설정에 손이 굉장히 많이 간다는게 단점이다.
그럼에도 불구하고 웹소켓
방식은 기존의 HTTP 통신과는 달리 서비스 확장성을 크게 향상시키기 때문에 한 번쯤은 꼭 공부해 보고 구현해 보면 많은 도움이 될 거라 생각한다.
만약, socket.io를 통해서 실시간 데이터 통신에 대해서 처음부터 구현해보고 싶다면 아래 사이트에서 튜토리얼을 따라하는걸 강추한다!!!!!
https://socket.io/docs/v4/tutorial/introduction
다음 웹소켓
마지막 포스팅에서는 이번 포스팅에서 간단히 설정하여 사용한 웹소켓
의 옵션을 다양하게 정의해 보며 사용해 보고 on
, emit
메서드 외에 다른 메서드에 대해서 알아보자!