Node.js 교과서 : 12. 실시간 GIF 채팅방 만들기(웹소켓, Socket.IO)

전지연·2025년 12월 3일

GDGoC_2025

목록 보기
13/16
post-thumbnail

웹 소켓 : 실시간 양방향 데이터 전송 기술

  • HTTP와 다르게 WS라는 프로토콜 사용

폴링 : 웹 소켓 이전에 HTTP 기술을 사용해 실시간 데이터 전송을 구현한 방식

  • 주기적으로 서버에 새로운 업데이트가 있는지 확인
  • 폴링과 다르게 웹 소켓은 한 번 연결이 이뤄지고 나면 계속 연결된 상태 유지

서버센트 이벤트(SSE) : 한 번 연결되면 계속 연결된 상태 유지, 서버->클라이언트 단방향 통신 기술

  • 클라이언트 -> 서버 통신은 불가


WS 모듈 사용하기

패키지 준비

1. npm init -y로 package.json 생성

2. 필요한 패키지 설치

npm i

npm i cookie-parser dotenv express express-session morgan nunjucks && npm i -D nodemon

3. .env와 app.js, routes/index.js 파일 작성

.envroutes/index.js

app.js

const express = require('express');
const path = require('path');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config();
const indexRouter = require('./routes');

const app = express();
app.set('port', process.env.PORT || 8005);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express: app,
  watch: true,
});

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

app.use('/', indexRouter);

4. WS 8버전 설치

npm i ws@8

5. app.js 수정

맨 밑줄

6. socket.js 생성

socket.js

const webSocket = require('ws');

module.exports = (server) => {
    const wss = new webSocket.Server({ server });

    wss.on('connection', (ws, req) => {
        const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
        console.log('새로운 클라이언트 접속:', ip);

        ws.on('message', (message) => {
            console.log(message.toString());
        });
        ws.on('error', console.error);     //에러 처리
        ws.on('close', () => {             //연결 해제
            console.log('클라이언트 접속 해제:', ip); 
            clearInterval(ws.interval);
        });
        ws.interval = setInterval(() => {
            if(ws.readyState === webSocket.OPEN) {
                ws.send('서버에서 클라이언트로 메시지 전송');
            }
        }, 3000); 
    });
};
  • 이벤트 리스너 : message, error, close
  • setInterval()
    • readyState 상태 확인 - webSocket이 connecting(연결 중) / open(열림) / closing(닫는 중) / closed(닫힘) 상태인지 확인
    • open일 때만 에러 없이 메시지 전송 가능

7. views 안에 클라이언트 생성

index.htmlerror.html

8. 서버 실행

localhost:8005로 접속하고 기다리면 클라이언트와 서버간 3초마다 신호를 주고받는 것을 확인 할 수 있다.


Socket.IO 모듈 사용하기

1. Socket.IO 설치

npm i socket.io

2. socket.js 수정

const SocketIO = require('socket.io');

module.exports = (server) => {
    const io = SocketIO(server, { path: '/socket.io' });

    io.on('connection', (socket) => {
        const req = socket.request;
        const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
        console.log('새로운 클라이언트 접속:', ip, socket.id, req.ip);
        socket.on('disconnect', () => {               // 접속 해제(=close)
            console.log('클라이언트 접속 해제:', ip, socket.id); 
            clearInterval(socket.interval);
        });
        socket.on('reply', (data) => {          // 클라이언트가 보낸 메시지 받기
            console.log(data);
        });
        socket.on('message', (message) => {
            console.log(message.toString());
        });
        socket.on('error', console.error);     //에러 처리
        socket.interval = setInterval(() => {
            socket.send('서버에서 클라이언트로 메시지 전송');
        }, 3000); 
    });
};

3. index.html 수정

4. 연결 확인

localhost:8005 접속해서 개발자 도구 확인

처음에는 http를 사용해 풀링 방식으로 연결하다가 웹소켓으로 자동 전환
=> 접속한 브라우저가 websocket을 지원하는 브라우저인지 확인하는 단계
(이 때문에 index.html에서 ws가 아닌 http로 주소 연결)

풀링 방식을 생략하고 싶으면 index.html에서 transports: ['websocket']을 사용

풀링을 생략하면 브라우저에 접속했을 때 이렇게 뜬다.


실시간 채팅방 만들기

모듈 설치

npm i mongoose multer color-hash@2

schemas/room.js

const mongoose = require('mongoose');

const { Schema } = mongoose;
const roomSchema = new Schema({
  title: {
    type: String,
    required: true,
  },
  max: {
    type: Number,
    required: true,
    default: 10,
    min: 2,
  },
  owner: {
    type: String,
    required: true,
  },
  password: String,
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

module.exports = mongoose.model('Room', roomSchema);
  • title : 방 제목
  • max : 최대 수용 인원, 기본적으로 10명, 최소 2명
  • owner : 방장
  • password : 비밀번호
  • createdAt : 생성 시간

schemas/chat.js

const mongoose = require('mongoose');

const { Schema } = mongoose;
const { Types: { ObjectId } } = Schema;
const chatSchema = new Schema({
  room: {
    type: ObjectId,
    required: true,
    ref: 'Room',
  },
  user: {
    type: String,
    required: true,
  },
  chat: String,
  gif: String,
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

module.exports = mongoose.model('Chat', chatSchema);
  • room 필드 - ObjectId를 통해 room 스키마와 연결

schemas/index.js

몽고디비 연결

const mongoose = require('mongoose');

const { MONGO_ID, MONGO_PASSWORD, NODE_ENV } = process.env;
const MONGO_URL = `mongodb://${MONGO_ID}:${MONGO_PASSWORD}@localhost:27017/admin`;

const connect = () => {
  if (NODE_ENV !== 'production') {
    mongoose.set('debug', true);
  }
  mongoose.connect(MONGO_URL, {
    dbName: 'gifchat',
    useNewUrlParser: true,
    useCreateIndex: true,
  }, (error) => {
    if (error) {
      console.log('몽고디비 연결 에러', error);
    } else {
      console.log('몽고디비 연결 성공');
    }
  });
};

mongoose.connection.on('error', (error) => {
  console.error('몽고디비 연결 에러', error);
});
mongoose.connection.on('disconnected', () => {
  console.error('몽고디비 연결이 끊겼습니다. 연결을 재시도합니다.');
  connect();
});

module.exports = connect;
  • MONGO_URL에 있는 몽고 id과 pw : .env에 작성

views, public 작성

layout.htmlerror.html(수정)

main.html

room.htmlpublic/main.css

chat.html

app.js 수정

const express = require('express');
const path = require('path');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const ColorHash = require('color-hash').default;

dotenv.config();
const webSocket = require('./socket');
const indexRouter = require('./routes');
const connect = require('./schemas');

const app = express();
app.set('port', process.env.PORT || 8005);
app.set('view engine', 'html');
nunjucks.configure('views', {
  express: app,
  watch: true,
});
connect();

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

app.use((req, res, next) => {
  if(!req.session.color) {   // 기존에 컬러 없는 사람의 경우
    const colorHash = new ColorHash();
    req.session.color = colorHash.hex(req.sessionID);        // 세션 아이디를 이용해서 고유한 색상 생성
    console.log(req.session.color, req.sessionID);
  }
  next();
});

app.use('/', indexRouter);

app.use((req, res, next) => {
  const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
  error.status = 404;
  next(error);
});

app.use((err, req, res, next) => {
  res.locals.message = err.message;
  res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
  res.status(err.status || 500);
  res.render('error');
});

const server = app.listen(app.get('port'), () => {
  console.log(app.get('port'), '번 포트에서 대기중');
});

webSocket(server, app);
// app : 세션에 컬러 저장(req.session.color)
// socket.js에서 session에 접근x -> 
// socket.js에서 session을 만들기 위해서 express session middleware를 장착

socket.js 수정

const SocketIO = require('socket.io');

module.exports = (server, app) => {
    const io = SocketIO(server, { path: '/socket.io' });
    app.set('io', io); 

    // app.set : 전체에 저장
    // req.locals : 요청에 저장, 나중에 익스프레스에서 이 IO 객체 쓸거임

    const room = io.of('/room');
    const chat = io.of('/chat');

    // 네임스페이스 별로 커넥션 생성
    room.on('connection', (socket) => {
        console.log('room 네임스페이스 접속');
        socket.on('disconnect', () => {
            console.log('room 네임스페이스 접속 해제');
        });

    });
    chat.on('connection', (socket) => {
        console.log('chat 네임스페이스 접속');
        socket.on('join', (data) => {
            socket.join(data);    // 방에 들어감
            socket.leave(data);   // 방에서 나감
        });
        socket.on('disconnect', () => {
            console.log('chat 네임스페이스 접속 해제');
        });
    });
};

routes/index.js

const express = require('express');
const { renderMain, renderRoom, createRoom, enterRoom, removeRoom } = require('../controllers');

const router = express.Router();

router.get('/', renderMain);
router.get('/room', renderRoom);
router.post('/room', createRoom);
router.get('/room/:id', enterRoom);
router.delete('/room/:id', removeRoom);

module.exports = router;

controllers/index.js

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');

exports.renderMain = async (req, res, next) => {
    try {
        const rooms = await Room.find({});
        res.render('main', { title: 'GIT 채팅방'});
    } catch (error) {
        console.error(error);
        next(error);
    }
};
exports.renderRoom = (req, res, next) => {
    res.render('room', { title: 'GIT 채팅방 생성'});
};
exports.createRoom = async (req, res, next) => {
    try {
        const newRoom = await Room.create({
            title: req.body.title,
            max: req.body.max,
            owner: req.session.color,   // 사용자 id 역할(새로고침해도 남아있음)
            password: req.body.password,
        });  
        // room 스페이스에 있던 사람들에게 실시간으로 알려줘야 함 -> io 사용
        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);
    }
};
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;
        if(room.max <= (rooms.get(req.params.id)?.size)) {
            return res.redirect('/?error=인원수가 초과되었습니다.');
        }
        res.render('chat', {
            room,
            title: room.title,
            chats: [],
            user: req.session.color,
        });
    } catch (error) {
        console.error(error);
        next(error);
    }
};
exports.removeRoom = async (req, res, next) => {
    try {
        await Room.deleteOne({ _id: req.params.id });
        await Chat.deleteMany({ room: req.params.id });
        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
};
  • createRoom : 채팅방을 만드는 컨트롤러

실시간으로 텍스트, GIF 보내기

텍스트 채팅 기능 구현

express 세션 분리

app.js에서 expression-session 미들웨어를 변수로 분리한다.

분리 전분리 후

분리한 sessionMiddleware는 웹소켓에 넘겨준다.

app.js 마지막줄socket.js

socket.js 수정

const SocketIO = require('socket.io');
const {removeRoom} = require('./services/roomService');

module.exports = (server, app, sessionMiddleware) => {
    const io = SocketIO(server, { path: '/socket.io' });
    app.set('io', io); 

    // app.set : 전체에 저장
    // req.locals : 요청에 저장, 나중에 익스프레스에서 이 IO 객체 쓸거임

    const room = io.of('/room');
    const chat = io.of('/chat');

    const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);
    chat.use(wrap(sessionMiddleware));

    // 네임스페이스 별로 커넥션 생성
    room.on('connection', (socket) => {
        console.log('room 네임스페이스 접속');
        socket.on('disconnect', () => {
            console.log('room 네임스페이스 접속 해제');
        });

    });
    chat.on('connection', (socket) => {
        console.log('chat 네임스페이스 접속');
        socket.on('join', (data) => {
            socket.join(data);    // 방에 들어감
            socket.to(data).emit('join', {
                user: 'system',
                chat: `${socket.request.session.color}님이 입장하셨습니다.`,
            });
        });
        socket.on('disconnect', async () => {
            console.log('chat 네임스페이스 접속 해제');
            const { referer } = socket.request.headers;
            const roomId = new URL(referer).pathname.split('/').at(-1);  // 방 아이디 추출
            const currentRoom = chat.adapter.rooms.get(roomId);    // 현재 방 정보
            const userCount = currentRoom?.size || 0;
            if (userCount === 0) {   // 방에 아무도 없으면
                await removeRoom(roomId);   // 방 삭제
                room.emit('removeRoom', roomId);   // room 네임스페이스에 방 삭제 알림
                console.log('방 제거 요청 보냄');
            } else {
                socket.to(roomId).emit('exit', {
                    user: 'system',
                    chat: `${socket.request.session.color}님이 퇴장하셨습니다.`,
                });
            }
            socket.to(roomId).emit('exit', {
                user: 'system',
                chat: `${socket.request.session.color}님이 퇴장하셨습니다.`,
            });
        });
    });
};
  • wrap : 미들웨어에 익스프레스처럼 req, res, next를 제공해주는 함수
    => socket.io 미들웨어(socket, next)를 express 미들웨어(req, res, next)에도 사용 가능
  • chat.on

removeRoom기능 service로 분리

controller/index.js에 있는 removeRoom을 거의 그대로 가져와서 services/index.js에 작성한다.

services/index.js

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');


exports.removeRoom = async (req, res, next) => {
    try {
        await Room.deleteOne({ _id: req.params.id });
        await Chat.deleteMany({ room: req.params.id });
        res.send('ok');
    } catch (error) {
        throw error;
    }
};

controllers/index.js 수정

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');
const { removeRoom : removeRoomService } = require('../services');

// ...

exports.removeRoom = async (req, res, next) => {
    try {
        await removeRoomService(req.params.id);
        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
};

이렇게 서비스를 만들어두면 같은 기능을 여러 파일에서 재사용할 수 있다.

controllers/index.jssocket.js

메시지 기능 추가

chat.html 수정

수정 전수정 후(1줄 추가)

chat.html에 chat기능을 추가한다.

  • socket.on('chat' ... ) : 채팅 기능
    • if(data.chat) { ... } : data에 텍스트만 있는 경우
    • else : data에 gif가 포함된 경우
  • document.querySelector().addEventListener : 채팅 전송

controllers/index.js 수정

  • 채팅 내역 추가 + 시간순정렬

  • sendChat 기능 추가

routes/index.js 수정

sendChat 라우트 추가


웹 소켓만으로 채팅 구현하기

지금까지는 라우터와 웹소켓을 모두 사용해서 채팅 구현
-> 라우터 없이 웹소켓만으로 만들 수 있음

chat.html

  • 웹소켓을 통해 html에서 emit을 진행
  • 작성된 data는 socket.js로 전달

socket.js

도착한 data는 톡방(room)에 뿌려지게 된다(emit).

기타 SOCKET.IO API

  • 특정인에게 메시지 보내기
socket.to(소켓 아이디).emit(이벤트, 데이터);
  • 나를 제외한 전체에게 메시지 보내기
socket.broadcast.emit(이벤트, 데이터);
socket.broadcast.to(방 아이디).emit(이벤트 데이터);

GIF 보내기

chat.html 수정

routes/index.js 수정

const express = require('express');
const { renderMain, renderRoom, createRoom, enterRoom, removeRoom, sendChat } = require('../controllers');
const multer = require('multer');
const path = require('path');
const { fstat } = require('fs');

const router = express.Router();

router.get('/', renderMain);
router.get('/room', renderRoom);
router.post('/room', createRoom);
router.get('/room/:id', enterRoom);
router.delete('/room/:id', removeRoom);
router.post('/room/:id/chat', sendChat);
try {
    fs.readdirSync('uploads');
} catch (error) {
    console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
    fs.mkdirSync('uploads');
}
const upload = multer({
    storage: multer.diskStorage({
        destination(req, file, done) {
            done(null, 'uploads/');
        },
        filename(req, file, done) {
            const ext = path.extname(file.originalname);      // 확장자 추출
            done(null, path.basename(file.originalname, ext) + Date.now() + ext);       // 파일명 + 날짜 + 확장자
        }
    }), limits: { fileSize: 5 * 1024 * 1024 },     // 파일 크기
});
router.post('/room/:id/gif', upload.single('gif'), sendGif);

module.exports = router;

controllers/index.js


...


exports.sendGif = async (req, res, next) => {
    try {
        const chat = await Chat.create({
            room: req.params.id,
            user: req.session.color,
            gif: req.file.filename,
        })
        req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat);
        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
};

app.js

핵심 정리

0개의 댓글