회사에서 본격적으로 socket.io를 사용해보기 전에 공부하는 의미로 채팅 어플리케이션을 만들어보라는 제안을 받았다. 구글링해서 블로그도 여럿 찾아보고, 챗GPT의 도움도 받으면서 만든 과정을 기록해보려 한다.✍
(지금 글을 정리하면서 계산해보니, 최소한의 기능만 구현해서 그런지 최종 완성까지 3일 걸린듯 하다.)

프로젝트 생성 및 의존성 설치
이게 과정이 생각보다 매우 길었다.
아침에 출근하자마자 시작했는데 전처리 과정에만 오전 시간을 다 썼던듯 하다.😂
1. 일단 디렉토리 구조를 최상위 폴더 아래에 /server, /client로 나누기
socket.io는 서버와 클라이언트가 실시간으로 양방향 통신이 가능하도록 만들어주는 JS 라이브러리니까, '/socket-chat'이라는 최상위 폴더를 만들고 그 아래에 서버와 클라이언트로 디렉토리를 나눴다. 클라이언트는 React와 Tailwind로, 서버는 Express로 진행했다. 사실 서버는 다른 언어를 써도 되지만, 사수님이 채팅 기능 자체가 되기만 하면 된다고 하셔서 그냥 블로그에 예제로 가장 많이 나와있던 node.js 기반의 Express를 사용했다.
※ Socket.io에 대한 설명은 여기 - https://hjinn0813.tistory.com/162
WebSocket과 socket.io의 기본 개념
회사에서 새로운 프로젝트에 socket.io를 사용할 예정이니까 미리 공부해두라고 해서 기록하는 글.
hjinn0813.tistory.com
2. /client 디렉토리가 열린 상태에서, 현재 폴더에 프로젝트 생성
자동으로 19버전으로 설치됐는데, 아직 안정성이 떨어진다고 판단하여 18버전으로 다운그레이드 시켰다.
실제로 프로젝트 설치 과정에서 React 19와 @testing-library/react@13.4.0이 서로 호환되지 않아 의존성 충돌이 발생했어서, 다운그레이드 시키길 잘했다고 생각했다. 명령어는 아래와 같다.
# React 프로젝트 생성 (현재 폴더)
npx create-react-app .
# React 18버전으로 다운그레이드
npm install react@18 react-dom@18
3. React Router, Tailwind 설치
클라이언트는 메인 페이지에서 닉네임 입력하고 [입장] 버튼을 클릭하면 채팅방 페이지로 이동해서 채팅을 할 수 있는, 최대한 간단한 구조로 만들기로 했다. 그러면 라우팅이 필요하니까 React Router를 설치했고, 디자인은 자주 쓰던 scss의 경우 /styles 디렉토리 만들어서 개별 파일을 작성해야하니까, 쉽게 편하게 만들려고 Tailwind를 선택했다.
명령어로 tailwind.config.js 자동 생성해서 일부 수정하고, postcss.config.js는 직접 작성해야 되는거라서 예전에 Tailwind 공부할때 만들었던 파일을 그대로 가져와서 사용했다. 사용한 명령어는 아래와 같다.
# React Router 설치
npm install react-router-dom
# Tailwind 설치
npm install -D tailwindcss postcss autoprefixer
# tailwind.config.js 자동 생성
npx tailwindcss init
4. 클라이언트 영역에 socket.io 설치, 브랜치 이름 변경
그리고 이번 작업의 목적이자 가장 중요했던 socket.io 설치.
클라이언트와 서버 디렉토리에 각각 다른 명령어로 설치하는데, 클라이언트 측 명령어는 아래와 같다.
추가적으로 브랜치 이름이 master인게 싫어서 main으로 변경했다. git push origin main이라는 명령어가 손에 익어서.. 변경하는게 훨씬 낫다는 생각에 클라이언트, 서버, 최상위 폴더 모두 브랜치 이름을 바꿨다.
# 클라이언트에 socket.io 설치
npm install socket.io-client
# 브랜치 이름 변경
git branch -m master main
5. /server 디렉토리에 의존성 설치
클라이언트 측에 React, React Router, Tailwind, Socket.io-client를 설치했으니 이제 서버 측에도 필요한 언어들을 설치할 차례였다. node.js 기반인 Express로 진행하기로 했으니 설치했고, 서버와 클라이언트 사이에 혹시 CORS 에러가 발생할까봐 미리 설치했다. 이번 작업의 목적인 socket.io까지 설치 완료.
# 서버 측 의존성 설치 명령어
npm install express
npm install cors
npm install socket.io
6. 최상위 디렉토리에 concurrently와 nodemon 설치
마지막으로 최상위 디렉토리인 '/socket-chat'에 concurrently와 nodemon을 설치했다.
concurrently는 여러 명령어를 동시에 실행할 수 있게 해주는 도구이고, nodemon은 서버 파일 변경을 감지하여 자동으로 서버를 재시작해주는 도구이다. 최상위 폴더에 이게 있으면, 최상위 폴더가 열린 상태에서 npm start로 서버와 클라이언트를 동시에 실행시킬 수 있다.
# concurrently와 nodemon 설치
npm install concurrently nodemon --save-dev
7. gitignore 파일 생성
그리고 내 기억에 /server와 /socket-chat에 .gitignore 파일이 없어서 직접 생성했던 것 같다.
디렉토리 구조상 최상위 폴더와 서버, 클라이언트 디렉토리 모두에 /node-modules가 만들어졌는데 이걸 그냥 뒀다가 github에 올리게 되면 곤란하기 때문에 미리 만들었다.
여기까지가 본격적인 작업 전의 과정이었다..
클라이언트 측 코드 작성
- 구조: 메인 페이지에서 닉네임 입력하고 [입장] 버튼 클릭하면 채팅방 페이지로 이동하여 채팅 가능.
- 기능: 유저 입장/퇴장시 메시지 출력, 채팅방에 있을 때 메시지 송/수신 등으로 단순화했다.
- 사용언어: React, Tailwind
본론으로 들어가 코드를 설명하기에 앞서, 내가 겪었던 몇 가지 우여곡절이 있었다. 먼저 개발 과정에서 입장/퇴장 메시지는 닉네임이 나오는데, 메시지 송/수신에서 닉네임이 안 나오는 문제가 있었다. 어쩌다보니 해결이 됐는데, 왜 그런 문제가 있었고 어떻게 해결했는지는 까먹었다..😂
다음으로는 입장 메시지만 두 번 출력되는 문제가 있었다. 클라이언트 측 채팅방 페이지의 코드가 문제인가 했는데, 그게 아니라 내가 클라이언트 측의 메인 페이지에서도 서버에 연결시키고, 채팅방 페이지에서도 서버에 연결시켜서 소켓이 이중으로 연결된게 문제였다. 이 부분을 한 곳에서만 연결하도록 수정했더니 드디어 내가 원하는 기능이 모두 구현된 채팅 어플리케이션이 되었다. 코드를 하나씩 해석해보자.👀
메인 페이지
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function Home() {
const navigate = useNavigate();
const [nickname, setNickname] = useState('');
// 닉네임 설정 핸들러 함수
const handleChange = (e) => {
setNickname(e.target.value);
};
// 채팅방 입장 핸들러 함수
const handleJoin = () => {
if (nickname.trim()) {
navigate('/chat', { state: { nickname } });
}
};
return (
<div className="main_wrap">
<div className="main_title">
채팅방 입장하기
</div>
<div className="main_container">
<input
type="text"
placeholder="닉네임을 입력해주세요!"
value={nickname}
onChange={handleChange}
/>
<button
type="submit"
onClick={handleJoin}
>
입장
</button>
</div>
</div>
);
}
- 기본적인 구조는 메인 페이지에서 닉네임만 받아서 채팅방 페이지로 보내주고, 채팅방 페이지는 메인에서 받은 닉네임을 가지고 소켓을 연결한다. 그렇기 때문에 가장 먼저 닉네임 상태관리를 위해 useState, 채팅방 페이지로 이동시키기 위해 useNavigate를 불러왔고 함수형 컴포넌트 안에서 const 키워드로 정의했다.
- handleChange()는 닉네임을 설정하는 핸들러 함수. 입력창에 사용자가 입력한 값을 e.target.value로 받아와서, 그 값을 setNickname 함수를 통해 nickname 상태에 저장한다.
- handleJoin()은 채팅방 입장하는 핸들러 함수. 만약에 닉네임이 있으면 양끝의 빈칸을 잘라내고 navigate()를 통해 채팅방으로 이동시킨다. 이때 state 객체로 닉네임을 같이 보내주는 이유는 페이지 대 페이지 관계이기 때문이다.
(페이지 대 컴포넌트 관계일 때만 props로 보낸다.) - 아래쪽 return문 내부는 설명할 내용이 딱히 없으니 패스. 아주 간결하게 만들었다.
채팅방 페이지
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { io } from 'socket.io-client';
// 함수형 컴포넌트 내부에서는
const socketRef = useRef(null);
const location = useLocation();
const nickname = location.state?.nickname;
const [msgs, setMsgs] = useState([]);
const [msg, setMsg] = useState('');
// 시간 포맷팅 함수
const formatTime = () => {
const now = new Date();
return now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
};
소캣 객체의 상태관리를 위해 useRef, 대화내용과 메시지 상태관리를 위해 useState, 메인 페이지에서 닉네임을 가져오기 위해 useLocation, 채팅방 입/퇴장과 메시지 송수신 관리를 위해 useEffect를 불러왔고 const 키워드로 설정해줬다.
/* 마운트시 소켓 연결 (퇴장 전까지 연결 유지) */
useEffect(() => {
// 메인에서 닉네임 못 받아왔을때 에러 처리
if (!nickname) {
console.error('닉네임이 없습니다!');
return;
}
// 소켓 연결 한번만 설정
if (!socketRef.current) {
socketRef.current = io('http://localhost:5000'); // 서버 연결
socketRef.current.emit('join', nickname); // 이벤트 보내기
}
// 클린업 함수: 소켓 연결 해제
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null; // 소켓 객체 초기화
}
};
}, [nickname]);
다음으로는 채팅방에 입장했을 때(마운트 되었을 때) 소켓을 연결하는 코드를 useEffect로 작성했다.
- if(!nickname)은 만약에 메인에서 닉네임을 받아오지 못했을 경우에 대한 에러 처리 방법이다.
- if(!socketRef.current)가 실제로 소켓을 연결하는 부분이다. io()로 서버와 연결하고, emit()로 'join' 이벤트와 닉네임을 보낸다.
- return()은 클린업 함수로, 소켓 연결을 해제하고 소켓 객체를 초기화시키는 코드를 담고 있다.
- 해당 useEffect()에서 의존성 배열로 닉네임을 보내주는 이유는, 닉네임이 변경될 때마다 로직이 실행되어야 하기 때문이다.
💡 참고로 여기에서 socketRef.current로 접근하는 이유
useRef는 객체 형태로 반환되고, socketRef 객체에는 여러 속성이 있을 수 있다.
그 객체 안에 current라는 속성이 있고, current 속성은 실제 값이 저장되는 곳이다.
→ 그러므로 socketRef.current를 통해 소켓 객체에 접근해야 값을 변경할 수 있다.
socketRef는 값을 변경해도 리렌더링을 발생시키지 않고, 그 값(여기서는 소켓 객체)을 유지할 수 있게 해준다.
/* 소켓 연결되면(입장하면) 메시지 받기 */
useEffect(() => {
if (socketRef.current) {
socketRef.current.on('message', (data) => {
setMsgs((prev) => [...prev, { ...data, time: formatTime() }]);
});
}
// 클린업 함수: 언마운트시(퇴장하면) 수신 해제
return () => {
if (socketRef.current) {
socketRef.current.off('message');
}
};
}, []);
- if (socketRef.current)는 만약 소켓이 연결되어 있으면, on()으로 '메시지' 이벤트를 기다렸다가 data라는 객체로 받아온다. data에는 닉네임과 메시지 본문이 담겨있다. 받은 메시지는 setMsgs()라는 함수로 상태관리가 되는데, 전개연산자를 통해 그동안의 채팅내용을 배열로 받아와서 계속 쌓아준다. 추가로 time 필드도 설정해서 메시지 발신 시간도 표시하고 있다.
- return()은 클린업 함수로, 유저가 퇴장하거나 컴포넌트가 언마운트될때 실행된다. 만약 소켓이 연결되어 있으면 off()로 더이상 메시지를 받지 않도록 설정했다.
- 여기에서는 의존성 배열로 빈 배열을 사용하고 있어서 컴포넌트가 마운트될 때, 즉 유저가 채팅방에 입장했을 때 한번만 실행되고 더이상 실행되지 않는다.
// 소켓이 있을 때만(입장했을 때만) 메시지 보내기
const handleSendMsg = () => {
if (msg.trim() && socketRef.current) {
socketRef.current.emit('message', { text: msg, nickname });
setMsg(''); // 메시지 전송 후 입력창 비우기
}
};
다음으로는 소켓이 있을 때(유저가 입장했을 때)에만 메시지를 보내는 코드다.
→ 만약 메시지가 있고 소켓이 있다면, emit()해서 메시지 이벤트랑 닉네임이랑 실제 유저가 작성한 메시지를 소켓으로 보내주고, setMsg()로 메시지의 상태 관리를 해서 메시지 전송이 끝났으면 입력창을 비워준다.
return (
<>
<div className="chat_wrap">
{msgs.map((msg, index) => (
<div key={index}>
<div>
<strong>{msg.nickname}</strong>: {msg.text}
</div>
<div>{msg.time}</div>
</div>
))}
</div>
{/* 메시지 입력창과 보내기 버튼 고정 */}
<div className="chat_input">
<div>
<strong>{nickname}: </strong>
</div>
<input
type="text"
value={msg}
onChange={(e) => setMsg(e.target.value)}
placeholder="메시지를 입력하세요"
/>
<button onClick={handleSendMsg}>
보내기
</button>
</div>
</>
);
다음으로는 return문 내부 코드인데 이건 설명할 내용이 딱히 없다. 상단의 chat_wrap에서는 그동안의 채팅 내용을 map()으로 돌리면서 보여주고 있고, 아래쪽의 chat_input은 입력창과 전송 버튼이다. 그러므로 socket.io로 만드는 실시간 채팅 어플리케이션의 작동 원리는,
- 유저가 클라이언트에서 메시지 입력하고 전송하면, emit() 함수로 message 이벤트와 데이터를 서버로 보낸다.
- 서버는 소켓에서 받은 메시지를 확인하고 같은 채팅방에 있는 모든 사용자에게 이 메시지를 broadcast한다.
- 클라이언트는 서버가 broadcast한 페이지를 on()으로 받아와서 출력한다.
서버 측 코드 작성
서버는 앞서 말했듯 node.js 기반인 Express로 작성했다. 서버 측에서는 server.js 파일 하나만 만들어서 클라이언트와 연결, 입/퇴장 메시지 처리, 메시지 송/수신, 서버 실행 코드만 작성하면 되는거라 편했다.
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const cors = require('cors');
const app = express(); // 인스턴스 생성
app.use(cors()); // 모든 출처의 요청 허용
const server = http.createServer(app); // http 서버 생성
const io = socketIo(server, { cors: { origin: '*' } }); // socket.io 서버 설정
const PORT = 5000;
const connectedUsers = new Set(); // 소켓 중복연결 방지
- 일단 가장 먼저 필요한 모듈을 const 키워드와 require()로 불러왔다.
- "인스턴스 생성"은 Express 애플리케이션을 생성하는 코드로, 쉽게 말해 웹 서버를 만들 준비를 하는 단계이다.
- CORS 설정은 해당 프로젝트에서 클라이언트와 서버를 모두 작성하기 때문에, 혹시 브라우저를 실행했을 때 해당 에러가 발생할 가능성이 있어서 미리 설정해주었다.
- const server = http.createServer(app);
→ http 서버를 생성하고, Express 애플리케이션을 사용할 수 있도록 연결한다.
이를 통해 http 서버는 express 애플리케이션을 통해 요청을 처리한다. - const io = socketIo(server, { cors: { origin: '*' } });
→ 실제 소켓 연결을 설정하여, 앞서 만든 http 서버 객체와 CORS 설정 객체를 전달한다.
CORS 설정은 모든 출처에서 오는 요청을 허용하여 에러가 발생하지 않도록 했다. - connectedUsers = new Set();
→ 현재 소켓에 연결된 사용자들의 소켓 ID를 저장하기 위한 빈 집합을 만든다.
Set()은 중복된 값을 허용하지 않기 때문에 중복된 소켓 ID를 방지할 수 있다.
// 클라이언트와 연결
io.on('connection', (socket) => {
// 소켓 아이디가 있으면 중복연결 방지
if (connectedUsers.has(socket.id)) {
console.log('중복 연결 방지:', socket.id);
return;
}
connectedUsers.add(socket.id); // 연결된 소켓 ID 저장
console.log('새로운 유저 연결:', socket.id);
// 메시지 수신
socket.on('message', (data) => {
const nickname = data.nickname || socket.nickname;
// 클라이언트에서 전달된 닉네임을 우선 사용
io.emit('message', { ...data, nickname });
});
// 이하 생략
});
- io.on('connection', (socket) => {});
→ on() 메서드로 'connection' 이벤트를 보내서 클라이언트와 연결되었을 때 실행할 로직을 작성한다. - if (connectedUsers.has(socket.id)) {});
→ 중복 연결을 방지하기 위한 방어 로직이다.
만약 connectedUsers에 이미 연결된 소켓 아이디가 있으면, return으로 로직 작동을 종료시킨다. - connectedUsers.add(socket.id);
→ connectedUsers라는 집합에 현재 연결된 소켓 아이디를 저장한다.
한번 소켓이 연결되면 아이디를 저장하여, 연결이 해제되기 전까지 계속 사용한다. - 메시지 송/수신 로직은 socket.on() 메서드에 'message' 이벤트를 보내는 것으로 진행한다. 해당 메서드 내에서 emit() 메서드를 사용하여 메시지 발신도 같이 처리하고 있다. (A가 메시지를 보내면 B가 받아야하니까)
참고로 여기에서 data는 클라이언트가 보낸 메시지 객체를 의미한다. emit()에서 data를 전개연산자로 보내는 이유는, 클라이언트가 보낸 메시지 내용 외에 닉네임을 추가해서 다시 모든 클라이언트에게 보내야 하기 때문이다.
// 유저 입장 메시지 처리
socket.on('join', (nickname) => {
if (socket.nickname) return; // 닉네임이 이미 설정됐다면 실행 방지
socket.nickname = nickname; // 소켓에 닉네임 저장
console.log('입장 닉네임:', nickname);
socket.broadcast.emit('message', {
text: `${nickname} 님이 입장하셨습니다👏`,
nickname,
});
});
// 퇴장 메시지 처리
socket.on('disconnect', () => {
connectedUsers.delete(socket.id); // 연결 해제 시 소켓 ID 삭제
if (socket.nickname) {
socket.broadcast.emit('message', {
text: `${socket.nickname} 님이 퇴장하셨습니다😥`,
nickname: socket.nickname,
});
}
console.log('유저 퇴장:', socket.id);
});
- 유저가 입/퇴장했는지 확인하고 메시지를 출력시키는 부분도 on() 메서드에 이벤트를 보내서 진행한다.
- 먼저 입장 메시지는 'join' 메서드와 함께, 콜백함수의 인자로 닉네임을 같이 보낸다. 누가 입장하는지 확인해서 [00님이 입장하셨습니다.] 와 같은 메시지를 출력해야하기 때문이다. 해당 로직에서 우선 만약 소켓에 이미 닉네임이 저장되어 있으면 그 이후로 코드를 실행하지 않게 방어로직을 작성했다. 소켓의 중복연결을 방지하기 위한 부분이기도 하다.
- 그 다음으로 소켓에 닉네임을 저장하고, socket.broadcast.emit()으로 메시지 본문과 닉네임이 담긴 객체를 'message' 이벤트와 함께 보내서 [00님이 입장하셨습니다!] 라는 메시지가 출력되도록 한다.
- 퇴장 메시지 출력 로직도 마찬가지 원리인데, 여기서는 연결 해제이므로 'disconnect' 이벤트를 사용한다. 여기에서는 입장 메시지와 다르게 콜백함수의 인자로 닉네임을 보내지 않는다. 입장 메시지를 관리하는 로직에서 이미 socket.nickname에 닉네임을 저장했기 때문에, 퇴장 메시지 로직에서는 굳이 닉네임을 보내지 않아도 socket.nickname을 통해 누가 퇴장했는지 알 수 있다.
- 해당 콜백함수 내부에서 connectedUsers.delete(socket.id);는 만약에 연결된 소켓 아이디가 있으면 삭제하라는 의미이다.
- if (socket.nickname) → 만약에 유저가 퇴장할때 닉네임이 설정되어 있으면 socket.broadcast.emit()으로 퇴장 메시지를 보낸다. 여기에도 객체 형태로 메시지 본문이랑 닉네임을 보낸다.
// 서버 실행
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
마지막으로 서버 실행 코드를 작성한다.
listen()은 서버를 실행시키는 메서드이고, 앞서 server 변수가 http 서버를 생성한다고 정의했으니까, server.listen()은 http 서버를 실행시키는 코드가 된다. 이 코드가 없으면 서버가 실제로 실행되지 않는다.
그렇게 하여 모든 기능이 정상적으로 구동되도록 어플리케이션을 만들고 나니까 아주 뿌듯하다.
이제 이걸 실서비스에 접목해야 되는데.. 매일이 도전의 연속이구만..!😂
'💻 Frontend > React' 카테고리의 다른 글
CRA 명령어 지원 종료, vite 개념 정리 (0) | 2025.03.05 |
---|---|
DB에 CRUD가 되는 게시판 만들기 - Frontend (1) | 2024.11.05 |
React Query에서 데이터 길이 확인하기 (0) | 2024.11.03 |
React Query, Axios 실습 (코드 리팩토링) (0) | 2024.10.18 |
Axios 기본 개념 정리 (0) | 2024.10.10 |