원래는 클라이언트가 서버한테 요청하는 단방향 통신이었지만 웹 소켓을 이용하면 서버가 클라이언트에게 요청을 보내는 등의 실시간 양방향 통신이 가능해짐. 서버센트 이벤트는 서버가 클라이언트에게 요청을 보내는 단방향 통신임.
socket io은 웹소켓을 편리하게 사용할 수 있도록 도와주는 라이브러리임.
//연결
const io= SocketIO(server, { path: '/socket.io'});
//Sets the path value under which engine.io and the static files will be served. Defaults to /socket.io.
const room= io.of('/room');
const chat= io.of('/chat');
//소통
io.of('/room').emit('newRoom', newRoom);
req.app.get('io').of('/room').emit('removeRoom', req.params.id)
socket.to(roomId).emit('exit', {user: 'system', chat:`${req.session.color}님이 퇴장함`, name: `${req.session.color}`});
req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat);
<script src="/socket.io/socket.io.js"></script>
<script>
//연결
const socket= io.connect('http://localhost:8005', { path: '/socket.io'});
const socket= io.connect('http://localhost:8005/room', { path: '/socket.io'});
const socket= io.connect("http://localhost:8005/chat", { path: '/socket.io'});
//소통
socket.on('newRoom', function (data) {
socket.on('removeRoom', function (data) {
socket.on('chat', function (data) {
socket.on('join', function(data) {
socket.on('exit', function(data) {
</script>
위와 같은 방법으로 연결하고 아래와 같이 소통한다.
io.on("connection", (socket) => {
console.log(socket.rooms); // Set { <socket.id> }
socket.join("room1");
console.log(socket.rooms); // Set { <socket.id>, "room1" }
socket.join(roomId);
socket.to(roomId).emit('join', { user:'system', chat:`${req.session.color}님이 입장함`, name: `${req.session.color}`});
});
서버에서 room.on('connection', (socket)=>{} 이렇게 사용할 때 socket은 특정 네임스페이스를 가지고 브라우저 클라이언트와 소통함. 네임 스페이스를 마음대로 자유롭게 정의하거나 join, leave할 수 있고 eventEmitter을 상속받음.
router.get('/room/:id', async (req, res, next)=>{
try {
const room= await Room.findOne({ _id: req.params.id });
const io= req.app.get('io');
if(!room) return res.redirect('/?error=존재하지 않는 방입니다');
if(room.password && room.password !== req.query.password)
return res.redirect('/?error=비밀번호가 틀렸습니다');
//chat에 접속중인 소켓 목록이 나옴, chat.html을 render하고 chat socket을 가짐 = 방에 들어가 있음
const { rooms }= io.of('/chat').adapter;
if(rooms && rooms[req.params.id] && room.max <= rooms[req.params.id].length)
return res.redirect('/?error=허용인원을 초과함');
const chats= await Chat.find({ room: room._id }).sort('createdAt');
return res.render('chat', { room, chats, user: req.session.color});
} catch (err) { console.error(err); next(err); }
});
This Adapter is a server-side component which is responsible for:
- storing the relationships between the Socket instances and the rooms
- broadcasting events to all (or a subset of) clients
https://socket.io/docs/v3/rooms/
Rooms | Socket.IO
A room is an arbitrary channel that sockets can join and leave. It can be used to broadcast events to a subset of clients:
socket.io
// main namespace
const rooms = io.of("/").adapter.rooms;
const sids = io.of("/").adapter.sids;
// custom namespace
const rooms = io.of("/my-namespace").adapter.rooms;
const sids = io.of("/my-namespace").adapter.sids;
Basically, it consists in two ES6 Maps:
- sids: Map<SocketId, Set<Room>>
- rooms: Map<Room, Set<SocketId>>
Calling socket.join("the-room") will result in:
- in the sids Map, adding "the-room" to the Set identified by the socket ID
- in the rooms Map, adding the socket ID in the Set identified by the string "the-room"
공식 문서를 보면 rooms는 Map 객체를 반환하지만 내 코드에서 rooms를 출력하면
{
'/chat#PmAfjnYapM7nVtroAAAC': Room { sockets: { '/chat#PmAfjnYapM7nVtroAAAC': true }, length: 1 },
'62a952b419bc05804cbcfe49': Room { sockets: { '/chat#PmAfjnYapM7nVtroAAAC': true }, length: 1 }
}
위와 같이 그냥 object가 출력되며 map처럼 get 메소드 등을 사용할 수 없었음. 공식문서는 3,4 버전 문서이지만 내 코드는 2 버전을 사용하고 있어 생긴 차이인듯 함.
module.exports = (server, app, sessionMiddleware) => {
const io = new SocketIO(server, { path: '/socket.io' }); // socket.io를 불러와 express와 연결
// SocketIO의 두 번째 인수로 옵션 객체를 넣어 서버에 관한 여러가지 설정 가능
// path: 클라이언트가 접속할 경로 설정(클라이언트에서도 이 경로와 일치하는 path를 넣어야 함)
app.set('io', io); // 라우터에서 io 객체를 쓸 수 있게 저장, req.app.get('io')로 접근 가능
const room = io.of('/room'); // of 메서드: Socket.IO에 다른 네임스페이스를 부여하는 메서드, 같은 네임스페이스끼리만 데이터 전달
const chat = io.of('/chat');
// 모든 웹 소켓 연결 시마다 실행됨
// 세션 미들웨어에 요청 객체(socket.request), 응답 객체(socket.request.res), next 함수를 인수로 넣으면 됨
io.use((socket, next) => { // io 메서드에 미들웨어 장착 가능
cookieParser(process.env.COOKIE_SECRET)(socket.request, socket.request.res, next);
sessionMiddleware(socket.request, socket.request.res, next); // socket.request안에 socket.request.session 객체가 생성됨
// socket.request: 요청 객체, socket.request.res: 응답 객체
});
// 웹 소켓 연결 후 이벤트 리스너를 붙힘
// io(room, chat)와 socket객체가 Socket.IO의 핵심임
// room 네임스페이스
room.on('connection', (socket) => { // 이벤트리스너를 붙혀줌
// connection: 클라이언트가 접속했을 때 발생, 콜백으로 소켓 객체(socket) 제공
console.log('room 네임스페이스에 접속');
socket.on('disconnect', () => {
console.log('room 네임스페이스 접속 해제');
});
});
// chat 네임스페이스
chat.on('connection', (socket) => { // 이벤트리스너를 붙혀줌
console.log('chat 네임스페이스에 접속');
const req = socket.request;
const { headers: { referer }} = req; // socket.request.headers.referer: 현재 웹 페이지의 URL 가져올 수 있음, referer: 하이퍼링크를 통해서 각각의 사이트로 방문시 남는 흔적
const roomId = referer // roomId로 같은 채팅방에 있는 사람인지 구분
.split('/')[referer.split('/').length -1]
.replace(/\?.+/, ''); // split과 replace로 방의 id를 가져옴
socket.join(roomId); // 방의 id를 인수로 받음
// chat 네임스페이스에 접속 시 socket.join 메소드 실행 - 방에 들어가는 메서드
socket.to(roomId).emit('join', { // socket.to(방 아이디) 메서드: 특정 방에 데이터를 보낼 수 있음
user: 'system',
chat: `${req.session.color}님이 입장하셨습니다.`,
});
// 접속 해제 시
socket.on('disconnect', () => {
console.log('chat 네임스페이스 접속 해제');
socket.leave(roomId); // chat 네임스페이스에 접속 해제 시 socket.join 메소드 실행 - 방에서 나가는 메서드
// 연결이 끊기면 자동으로 방에서 나가지만, 확실히 하기 위해 추가
const currentRoom = socket.adapter.rooms[roomId]; // socket.adapter.rooms[방 아이디]: 참여 중인 소켓 정보가 들어 있음
const userCount = currentRoom ? currentRoom.length : 0;
if (userCount === 0) { // 방에 인원수가 0명인 경우
const signedCookie = req.signedCookies['connect.sid']; // req.signedCookies 내부의 쿠키들은 모두 복호화되어 있으므로 다시 암호화해서 요청에 담아보내야 함
const connectSID = cookie.sign(signedCookie, process.env.COOKIE_SECRET);
axios.delete(`http://localhost:8005/room/${roomId}`, { // 서버에서 axios 요청 시 요청자가 누구인지에 대한 정보가 없음 (브라우저는 자동으로 쿠키를 같이 넣어 보냄)
// express-session에서는 세션 쿠키인 req.signedCookies['connect.sid']를 보고 현재 세션이 누구에게 속해있는지 판단함
headers: {
Cookie: `connect.sid=s%3A${connectSID}`, // s%3A 뒷 내용이 암호화된 내용, DELETE /room/:id 라우터에서 요청자가 누군지 확인 가능
},
})
.then(() => {
console.log('방 제거 요청 성공');
})
.catch((error) => {
console.error(error);
});
} else { // 방에 인원수가 0명이 아닌 경우 - 방에 있는 사람에게 퇴장 메세지 보냄(system)
socket.to(roomId).emit('exit', {
user: 'system',
chat: `${req.session.color}님이 퇴장하셨습니다.`,
});
}
});
});
};
chat에서 roomId로 특정 방을 지정해서 emit 하여 데이터를 보내는 모습을 볼 수 있음. 이밖에도 사람들이 채팅을 치거나 방을 새로 만들거나 삭제할 때 emit을 사용해서 방을 지정해서 데이터를 보냄. 이러한 데이터를 받아서 아래와 같이 현재 화면에 적용해주면 됨.
<script src="/socket.io/socket.io.js"></script> <!-- socket.io 연결 부분-->
<script>
const socket = io.connect('http://localhost:8005/chat', { // 네임 스페이스: 주소 뒤에 /chat이 붙은 것을 말함, 서버에서 /chat 네임스페이스를 통해 보낸 데이터만 받을 수 있음
path: '/socket.io',
});
// 사용자의 입장을 알리는 메시지 표시
socket.on('join', function(data) { // 사용자의 채팅방 입장에 대한 데이터를 웹 소켓에 전송, 소켓에 join 이벤트 리스너 연결
const div = document.createElement('div');
div.classList.add('system');
const chat = document.createElement('div');
div.textContent = data.chat;
div.appendChild(chat);
document.querySelector('#chat-list').appendChild(div);
});
// 사용자의 퇴장을 알리는 메시지 표시
socket.on('exit', function(data) { // 사용자의 채팅방 퇴장에 대한 데이터를 웹 소켓에 전송될 때 실행됨, 소켓에 exit 이벤트 리스너 연결
const div = document.createElement('div');
div.classList.add('system');
const chat = document.createElement('div');
div.textContent = data.chat;
div.appendChild(chat);
document.querySelector('#chat-list').appendChild(div);
});
</script>
<form action="/chat" id="chat_form" method="post" enctype="multipart/form-data" name="chat_form">
<input type="file" id="chat_gif" name="chat_gif" accept="image/gif">
<input type="text" id="chat_text" name="chat_text">
<button type="submit">submit</button>
</form>
const upload= multer({
storage: multer.diskStorage({
destination(req, file, cb) {
cb(null, 'uploads/');
},
filename(req, file, cb) {
const ext= path.extname(file.originalname); //확장자만 줌 asdf.html이면 '.html'
cb(null, path.basename(file.originalname, ext)+Date.now()+ext);
//originalname Name of the file on the user’s computer
},
}),
limits: { fileSize: 10*1024*1024 },
});
router.post('/room/:id/chat', upload.single('gif'), async (req, res, next)=>{
try {
//formdata를 보냈지만 req에서 못쓰는 이유는 바디파서가 이러한 multipart/formdata를 처리하지 못하기 때문. multer와 같은 모듈을 사용해야만 formdata를 읽을 수 있음
//multer 모듈에서 파일은 req.file에 text field는 req.body.name 으로 넣어줌
console.log('채팅내용', req.body.text);
const chat= await Chat.create({
room: req.params.id,
user: req.session.color,
chat: req.body? req.body.text : null,
gif: req.file? req.file.filename : null
});
req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat);
res.send('ok');
} catch (err) {console.error(err); next(err); }
});
또한 아래와 같이 html 값을 접근 가능함.
<tr data-id="{{room._id}}"> <!--data-@@@의 값을 script에서 dataset.@@@으로 접근 가능-->
<script>
function addBtnEvent(e) {
if(e.target.dataset.password === 'true') {
const password= prompt('비번입력');
location.href= '/room/' + e.target.dataset.id + '?password=' + password;
}
else
location.href= '/room/' + e.target.dataset.id;
}
socket.on('removeRoom', function (data) {
document.querySelectorAll('table tr').forEach((tr)=>{
if(tr.dataset.id === data)
tr.parentNode.removeChild(tr);
})
});
</script>
댓글