1. socket 인스턴스 생성
const io = SocketIO(server, { path: '/socket.io' });
2. namespace 생성
const room = io.of('/room');
const chat = io.of('/chat');
3. room 네임스페이스 접속
room.on('connection', (socket) => {
console.log('room 네임스페이스 접속');
socket.on('disconnect', () => {
console.log('room 네임스페이스 접속 해제');
});
});
4. chat 네임스페이스 접속
chat.on('connection', (socket) => {
console.log('chat 네임스페이스 접속');
const userColor = socket.request.session.color;
userMap[userColor] = socket.id;
const { referer } = socket.request.headers;
console.log(referer);
const roomId = new URL(referer).pathname.split('/').at(-1);
console.log(roomId);
socket.on('join', async (data) => {
console.log(data); //data와 roomId가 같음
socket.join(data);
//join 이후에 실행해야만 인원수등을 제대로 가져올 수 있음 (갱신)
const currentRoom = chat.adapter.rooms.get(roomId);
const userCount = currentRoom?.size || 0;
const systemLog = `${socket.request.session.color}님이 입장하셨습니다. 현재 인원: ${userCount}`;
await createSystemChatLog(roomId, systemLog);
socket.to(data).emit('join', {
user: 'system',
chat: systemLog,
});
// 메인 페이지(/room 네임스페이스)에 'updateCount' 이벤트로 알리기
room.emit('updateCount', { roomId, userCount });
chat.emit('updateCount', {
roomId,
occupantCount: userCount,
});
updateUserList(roomId);
});
// ============ leaveRoom 이벤트 추가 ============
socket.on('leaveRoom', async (roomId, message, done) => {
// 1) 소켓이 해당 방을 떠남
socket.leave(roomId);
delete userMap[userColor];
// 2) 남은 인원 파악
const userCount = chat.adapter.rooms.get(roomId)?.size || 0;
console.log(`방 ${roomId} 인원: ${userCount}`);
if (userCount === 0) {
// 마지막 인원이므로 방 삭제
await removeRoom(roomId);
// 메인 페이지(/room 네임스페이스)에 'removeRoom' 이벤트
room.emit('removeRoom', roomId);
console.log('방 제거 완료');
// 클라이언트 콜백에 "success: true" 전달
done({ success: true });
} else {
const remainingUsers = Object.keys(userMap);
const firstUser = remainingUsers[0];
const resultMessage = message ?? '퇴장하셨습니다';
const systemLog = `${socket.request.session.color}님이 ${resultMessage}. 현재 인원: ${userCount}`;
await createSystemChatLog(roomId, systemLog);
socket.to(roomId).emit('exit', {
user: 'system',
chat: systemLog,
targetColor: firstUser,
});
chat.emit('updateCount', { roomId, occupantCount: userCount });
done({ success: false });
updateUserList(roomId);
}
});
socket.on('disconnect', async () => {
console.log('chat 네임스페이스 접속 해제');
const currentRoom = chat.adapter.rooms.get(roomId);
const userCount = currentRoom?.size || 0;
if(userCount === 0) {
await removeRoom(roomId);
room.emit('removeRoom', roomId);
console.log('방 제거 요청 성공');
}
});
function updateUserList(roomId) {
const currentRoom = chat.adapter.rooms.get(roomId);
if(!currentRoom) return;
const userColors = [];
for (const clientId of currentRoom) {
const clientSocket = chat.sockets.get(clientId);
const color = clientSocket.request.session.color;
userColors.push(color);
}
// 방에만 보내거나, 특정 네임스페이스 전체에 broadcast할 수 있음
chat.to(roomId).emit('userList', { users: userColors });
}
});
chat.on('connection', ...)
- chat 네임스페이스에 접속할 때 호출된다.
- 채팅방에 입장 시에 호출
socket.on('이벤트 이름', ...)
- 클라이언트로부터 수신받은 소켓 실행
- 클라이언트와 이벤트 이름이 동일해야 해당 소켓이 실행된다.
socket.join(roomId)
- roomId에 해당하는 이름에 방이 생성
- 이미 방이 있는 경우엔 소켓이 추가된다.
socket.leave(roomId)
- roomId에 해당하는 이름의 방에서 소켓을 제거한다.
const roomSockets = chat.adapter.rooms.get(roomId);
Socket.IO v3 버전부터 기본 adapter는 내부적으로 JavaScript의 Map 객체를 사용하여 방 정보를 관리합니다.
즉, chat.adapter.rooms는 Map 객체이고, 여기서 .get() 메서드는 Map의 표준 메서드로, 특정 키(여기서는 roomId)에 해당하는 값을 반환합니다.
반환된 값은 해당 방에 속한 소켓 ID들이 담긴 Set 객체가 됩니다.
5. 컨트롤러 구현
5.1. 방 생성
exports.createRoom = async (req, res, next) => {
try {
const newRoom = await Room.create({
title: req.body.title,
max: req.body.max,
owner: req.session.color,
password: req.body.password,
});
const io = req.app.get('io');
io.of('/room').emit('newRoom', newRoom);
if (req.body.password) {
res.redirect(`/room/${newRoom._id}?password=${req.body.password}`);
} else {
res.redirect(`/room/${newRoom._id}`);
}
} catch (error) {
console.error(error);
next(error);
}
};
newRoom DB를 생성하고 객체로 받아온다.
io.of('/room').emit('newRoom', newRoom);
는 서버에서 클라로 '/room' 네임스페이스에 접속해있는 'newRoom' 이벤트로 전송한다. 보낼 때는 newRoom DB 객체를 같이 전송한다.
5.2. 방 입장
exports.enterRoom = async (req, res, next) => {
try {
const room = await Room.findOne({ _id: req.params.id });
if(!room) {
return res.redirect('/?error=존재하지 않는 방입니다.');
}
if(room.password && room.password !== req.query.password) {
return res.redirect('/?error=비밀번호가 틀렸습니다.');
}
const io = req.app.get('io');
const { rooms } = io.of('/chat').adapter;
// 현재 인원 계산
const occupantCount = rooms.get(req.params.id)?.size || 0;
if(room.max <= occupantCount) {
return res.redirect('/?error=허용 인원을 초과했습니다.');
}
const chats = await Chat.find({ room: room._id }).sort('createdAt');
res.render('chat', { title: `GIF 채팅방 생성 (현재 인원: ${occupantCount}명)`, chats, room, user: req.session.color });
} catch (error) {
console.error(error);
next(error);
}
};
5.3. 채팅 전송
exports.sendChat = async (req, res, next) => {
try {
const chat = await Chat.create({
room: req.params.id,
user: req.session.color,
chat: req.body.chat,
});
req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat);
res.send('ok');
} catch (error) {
console.error(error);
next(error);
}
}
req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat);
해당 부분은 chat 네임스페이스의 req.params.id(roomId) 즉, 채팅방 내에 있는 인원들에게 전송한다.
5.4. 귓속말 전송
exports.sendWhisper = async (req, res, next) => {
try {
const whisper = await Whisper.create({
room: req.params.id,
toUser: req.body.toUser,
fromUser: req.session.color,
chat: req.body.chat,
});
const targetSocketId = userMap[req.body.toUser];
const senderSocketId = userMap[req.session.color];
req.app.get('io')
.of('/chat')
.to(targetSocketId)
.emit('whisper', whisper);
req.app.get('io')
.of('/chat')
.to(senderSocketId)
.emit('whisper', whisper);
res.send('ok');
} catch (error) {
console.error(error);
next(error);
}
}
req.app.get('io').of('/chat').to(targetSocketId).emit('whisper', whisper);
채팅과 다르게 chat 네임스페이스내의 소켓들 중 특정 targetSocketId(귓속말 받을 id) 에게 전송한다.
5.5. 강퇴
exports.kickUser = async (req, res, next) => {
try {
const currentUser = req.session.color; // 현재 로그인한 사용자 정보 (예: 아이디 혹은 색상)
const roomId = req.params.id;
const targetSocketId = userMap[req.body.kickUserColor];
// 방 정보를 조회 (예: DB에서 roomId로 방 정보를 가져온다)
const room = await Room.findById(roomId);
// 현재 사용자가 방장이 아니라면 거부
if (room.owner !== currentUser) {
console.error('권한이 없습니다.');
return res.status(403).send('권한이 없습니다.');
}
// 강퇴 대상 사용자 처리
if (!targetSocketId) {
console.error('해당 사용자를 찾을 수 없습니다.');
return res.status(404).send('해당 사용자를 찾을 수 없습니다.');
}
req.app.get('io')
.of('/chat')
.to(targetSocketId)
.emit('kickUser', { message: '강퇴당하셨습니다' });
delete userMap[req.body.kickUserColor];
// 업데이트된 사용자 목록을 객체의 키 배열로 만듭니다.
const updatedUsers = Object.keys(userMap);
// 전체 네임스페이스 또는 특정 방에 사용자 목록 업데이트를 broadcast 합니다.
req.app.get('io')
.of('/chat')
.emit('userList', { users: updatedUsers });
} catch (error) {
console.error(error);
next(error);
}
}
강퇴는 두 개의 전송을 한다.
req.app.get('io')
.of('/chat')
.to(targetSocketId)
.emit('kickUser', { message: '강퇴당하셨습니다' });
chat 네임스페이스내의 소켓 중 강퇴 선택된 사용자에게 'kickUser' 이벤트를 전송한다.
// 업데이트된 사용자 목록을 객체의 키 배열로 만듭니다.
const updatedUsers = Object.keys(userMap);
// 전체 네임스페이스 또는 특정 방에 사용자 목록 업데이트를 broadcast 합니다.
req.app.get('io')
.of('/chat')
.to(roomId)
.emit('userList', { users: updatedUsers });
chat 네임스페이스내의 그리고 chat 룸에 포함된 소켓들에게 'userList' 이벤트를 전송한다.
//강퇴
socket.on('kickUser', function (data) {
socket.emit('leaveRoom', roomId, data.message, (data) => {
window.location.href = '/';
});
});
클라이언트에서는 kickUser를 받은 후 다시 서버 소켓으로 'leaveRoom' 이벤트를 보낸다.
그러면 위의 서버 소켓 중 'leaveRoom' 이 호출되며 해당 유저를 강퇴하게 된다.
5.6. 방장 위임
exports.delegateUser = async (req, res, next) => {
try {
//위임 대상이 있으면 해당 사용자에 방장 위임
const targetColor = req.body.delegateUserColor;
const targetSocketId = userMap[targetColor];
const roomId = req.params.id;
// 방 정보를 조회 (예: DB에서 roomId로 방 정보를 가져온다)
const room = await Room.findById(roomId);
// 선택된 위임 대상이 이미 방장인 경우
if (room.owner === targetColor) {
return;
}
//위임 대상이 없으면 진행되지 않도록
if (!targetSocketId) {
console.error('해당 사용자를 찾을 수 없습니다.');
return res.status(404).send('해당 사용자를 찾을 수 없습니다.');
}
// 새로운 방장(예: 위임 대상)으로 owner 값을 변경합니다.
room.owner = targetColor;
// 변경된 room 정보를 DB에 저장합니다.
await room.save();
const systemLog = `${targetColor}님이 방장으로 위임되셨습니다.`;
// 업데이트된 사용자 목록을 객체의 키 배열로 만듭니다.
const updatedUsers = Object.keys(userMap);
req.app.get('io')
.of('/chat')
.to(roomId)
.emit('delegateUser', {
systemLog,
targetColor,
users: updatedUsers,
});
} catch (error) {
console.error(error);
next(error);
}
}
DB에서 해당 방의 객체를 가져온 후, room.owner 를 변경한다. 그리고 await room.save() 하여 DB를 저장한다.
// 업데이트된 사용자 목록을 객체의 키 배열로 만듭니다.
const updatedUsers = Object.keys(userMap);
req.app.get('io')
.of('/chat')
.to(roomId)
.emit('delegateUser', {
systemLog,
targetColor,
users: updatedUsers,
});
마찬가지로 'delegateUser'라는 이벤트로 클라이언트에 송신한다.
클라이언트에서는 해당 이벤트를 받아 유저를 갱신하면 방장 위임이 완료되게 된다.
'Node.js' 카테고리의 다른 글
Socket.io - 통신 방법, 네임스페이스, Room, broadcast, private (0) | 2025.02.03 |
---|---|
Socket.io - 정의, 설치, 작동 원리, 특징 (0) | 2025.02.03 |
데이터베이스 - Mongoose (0) | 2025.01.20 |
데이터베이스 - MongoDB CRUD 작업하기 (0) | 2025.01.20 |
데이터베이스 - MongoDB 데이터베이스 및 컬렉션 생성 (0) | 2025.01.18 |