본문 바로가기
노드

노드 12, 13장

by ㅎㅁ0517 2022. 3. 4.

원래는 클라이언트가 서버한테 요청하는 단방향 통신이었지만 웹 소켓을 이용하면 서버가 클라이언트에게 요청을 보내는 등의 실시간 양방향 통신이 가능해짐. 서버센트 이벤트는 서버가 클라이언트에게 요청을 보내는 단방향 통신임. 

 

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); }
});
 
여기서 adater은 socket 인스턴스와 room사이의 연결을 저장하고 모두 또는 일부의 클라이언트에게 이벤트를 보내는 서버 쪽의 요소임

 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>
다만 새로 고침을 하면 소켓이 끊어졌다 재열결되기에 혼자 있을 때 새로 고침을 하면 퇴장이 되어 0명이 되서 방이 지워지는 문제가 있었음.
 
같은 브라우저 탭으로 접속하니 자꾸 같은 사용자로 인식하고 마지막 로그인 했던 사용자로 모든 탭의 사용자가 바뀜. 세션이 브라우저 탭끼리 공유되어 세션 쿠키의 값이 가장 나중에 로그인 된 브라우저의 쿠키 값으로 바뀌기에 그런 것이라고 생각함. 테스트를 할 때 다 다른 브라우저를 사용해야 함.
 
또한 image와 같은 파일 형식은 바디파서가 처리하지 못하기 때문에 이를 multer 모듈을 통해 받아오는 것을 볼 수 있음.
<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>
 

'노드' 카테고리의 다른 글

프로젝트 정리~~~  (2) 2022.05.30
노드 9장 추가  (0) 2022.03.04
노드 10장  (0) 2022.03.03
노드 8장  (0) 2022.03.03
노드 7장, 9장  (1) 2022.03.03

댓글