NodeJs의 socket.io를 이용한 상용 채팅 서비스 구현하기

NodeJs의 socket.io를 이용한 상용 채팅 서비스 구현하기 updated_at: 2024-03-20 18:43

사용 채팅 서비스 구현하기

일전에 간단한 채팅 동작에 대해서 설명드렸습니다.
오늘은 실서비스에 사용할 채팅에 대해서 설명드리려고 합니다.

사용 툴

  • nodeJs (backEnd)
  • angular (FrontEnd)
  • redis (NoSql)

redis 구성

아래와 같은 시나리오하에서 redis db를 구성할 예정입니다.

  • A 라는 유저가 로그인 했을 경우 본인의 채팅 리스트가 보여져야 한다.
  • 기존 채팅창을 클릭하면 기존 Member들(1:1 혹은 다중)과의 채팅 내용이 보여져야 한다.
  • 새로운 메시지가 왔을 경우 메시지창에 new 표시를 한다.
  • 채팅방을 나가면 채팅리스트에서는 사라지지만 다시 조인하여 기존 채팅항목을 보여야 한다.

redis 데이터 구조 및 키 설계

  • RoomId: 채팅방의 고유 아이디
  • UserId: User ID

1. 채팅방 정보

채팅방 정보에는 이 채팅방에 참여한 유저 및 채팅방에 대한 간략한 정보들이 들어간다.

  • chatRoomInfo.[RoomId] - STRING TYPE
{type: type: personal | private | public, users: [이방에 참여한 유저 아이디...], created_at: '최초참여일' } 

2. 본인이 참여한 채팅방 리스트

  • chatRooms.[userId] - LIST TYPE
{id: roomId, type: personal | private | public, to: 상대방ID, created_at: '최초참여일' } 
..........

3. 채팅내용

  • chats.[RoomId] - LIST TYPE
{message: '메시지 내용', user: {작성자정보}, created_at: '작성일', reader: [이글을 읽은 유저 아이디...]}
..........

  • personal는 강제적으로 상대방을 join 시키고
  • public 일경우 상대방이 스스로 join하는 방식으로 진행

구현시작

  • html
<!--#scrollMe [scrollTop]="scrollMe.scrollHeight 을 사용하여 스크롤이 아래로 자동으로 이동하게 하자-->
<main #scrollMe [scrollTop]="scrolltop"> 
  <ng-container *ngFor=" let message of messages;  let i=index"> 
    <div *ngIf="message.user.id !== user.id"><!-- 상대방 채팅 메시지 보기 -->
        <div>
          <div>{{message.user.name}}</div>
          <div>{{message.created_at}}</div>
        </div>
        <div>
            {{message.message}}
        </div>
    </div>
    <div *ngIf="message.user.id === user.id"> <!-- 본인의 채팅 메시지 보기 -->
      <div>
        <div>{{message.user.name}}</div>
        <div>{{message.created_at}}</div>
      </div>
      <div>
          {{message.message}}
      </div>
    </div>
  </ng-container>
</main>
  • chat.ts

chat.ts는 크게 3부분으로 구성된다.

  1. chatInit: 상대를 선택하면 기존 채팅이 있는지 확인하고, 있으면 room Id 를 가져오고 없으면 새로 생성하여 join 한다. 그리고 기존 채팅방의 내용을 가져온다.
  2. sendMessage: 메시지를 node 로 보낸다.
  3. messageTrim: 전달 받은 메시지를 디스플레이용으로 변경한다.
private chatInit() {
  //  본인이 속한 채팅룸 리스트 가져오기
  this.socket.emit('chatRooms', (err: string, rooms: any)=>{
    const room = _.find(rooms, (item: string)=>{
      const itemObj = JSON.parse(item);
      return itemObj.type === 'personal' &&  itemObj.to === this.data.user.id; 
    })
    if (room) {
      const roomObj =  JSON.parse(room);
      this.roomId = roomObj.id;
    } else {
      this.roomId = uuid.v4();
    }

    // Join chatting Room
    this.socket.emit('enterRoom', {room: this.roomId, type: 'personal', to: this.data.user.id}, (err: string, messages: any)=>{
      this.messages = messages;
    });

    // 현재 채팅룸에서 채팅내용 가져오기
    this.socket.emit('chats', {room: this.roomId}, (err: string, messages: any)=>{
      this.messageTrim(messages)
    });

    // 실시간 채팅내용 리스닝하기
    this.socket.on('chat', (message: any) => {
      this.messages.push(message);
    });
  });
}

public sendMessage() {
  this.socket.emit('chat', {room: this.roomId, message: this.message, user:{id: this.user.id, name: this.user.name}}, (err: string, message: any)=>{
    if (err) {

    } else {
      this.messages.push(message);
      this.message = null;
    }
  });
}

private messageTrim(messages: any) {
  _.chain(messages)
  .sortBy((message) => { return message.created_at; }) // created_at 순으로 정렬한 후
  .reverse()
  .each((message)=>{
    this.messages.push(JSON.parse(message));
  })
}
  • nodejs > socket.js
const async = require('async');
const redis = require('./redis');
const _ = require('underscore');

module.exports = function(io) {

  io.on('connection', onConnection);

  function onConnection(socket) {
    // 채팅 dashboard 접근시
    socket.on('join', (user) => {
      user.socket_id = socket.id;
      socket.user = user;
      socket.join('join-' + user.id); // 개별 notice를 받기위해 
    });

    socket.on('disconnect', () => {
    });

    /**
     * user의 채팅방 목록을 가져온다.
     */
    socket.on('chatRooms', (callback) => {
      if(socket.user) {
        redis.chatRooms(socket.user.id, (err, list)=>{
          return callback(err, list);
        });
      } else {
        console.error('not login to server');
      }
    });

    /**
     * 채팅방에 속하는 채팅목록을 가져온다.
     */
    socket.on('chats', (req, callback) => {
      const room = req.room;
      redis.chats(room, (err, list)=>{
        return callback(err, list);
      });
    });

    /**
     * 채팅내용을 저장한다.
     */
    socket.on('chat', (req, callback) => {
      req.created_at = new Date();
      const room = req.room;
      redis.chat(req, (err)=>{
        if (!err) {
          socket.broadcast.to(room).emit('chat', req);

          // 현재 로그인이 되어 있지 않아도 채팅방에 속한 사용자에게도 메시지가 발생했음을 알린다.
          const chatRoomInfoKey = 'chatRoomInfo.' + room;
          redis.getData(chatRoomInfoKey, (error, resp) =>{
            if(error) return callback(error);
            const obj = JSON.parse(resp);
            _.each(obj.users, (user) =>{
              if (user !== socket.user.id) {
                // socket.broadcast.to(room).emit('chat', req);
                socket.to('join-' + user).emit('receiveMessageCount', {room: room});
              }
            })
          })

        }
        return callback(err, req);
      });
    });

    /**
     * 채팅방 입장
     * room id를 확인하고 rooId가 속한 채팅방이 없을 경우 채팅방 목록을 만든다.
     * @param Object req {room: this.roomId, type: 'personal', to: this.data.user.id}
     */
    socket.on('enterRoom', (req, callback) => { // callback is callback function

      socket.join(req.room);

      // 채팅 room이 개설되었는지 확인하고 없으면 새로 만든다.
      const chatRoomInfoKey = 'chatRoomInfo.' + req.room;
      redis.findKey(chatRoomInfoKey, (error, result) => {
        if(error) return callback(error);
        if (result === 0) { // 방정보를 만들고 방에 초대된 멤버들을 저장한다.
          
          const params = {};
          params.type = req.type;
          params.users = [];
          params.users.push(socket.user.id);
          params.users.push(req.to);
          params.created_at = new Date();
          
          // create Room Info
          redis.setData(chatRoomInfoKey, params, (error, result) =>{
            if(error) return callback(error);
          })

        } else { // 방에 존재 하지만 현재 user가 방에 없으면 방에 추가한다.

        }
      });

      redis.chatRooms(socket.user.id, (error, rooms)=>{
        const room = _.find(rooms, (item)=>{
          const itemObj = JSON.parse(item);
          return itemObj.id === req.room; 
        })

        // 기존 room을 발견하지 못한 경우 추가한다.
        if (!room) {
          const roomInfo1 = {id: req.room, type: req.type, to: req.to, created_at: new Date()};
          // 본인 채팅룸 생성
          redis.updateChatRooms(socket.user.id, roomInfo1, (err) => {
          })
          // 상대방 채팅룸 생성
          const roomInfo2 = {id: req.room, type: req.type, to: socket.user.id, created_at: new Date()};
          redis.updateChatRooms(req.to, roomInfo2, (err) => {
          })
        } else {
          // 기존 room 이 있을 경우 최근 글을 읽음 상태로 변경
          const roomObj = JSON.parse(room);
          redis.insertReader(roomObj, socket.user.id);
        }
      });
    }); // socket.on('join', function(info, callback) {

    /**
      * 읽음으로 메시지 변경
      */
    socket.on('markToRead', (req, callback) => {
      if (!socket.user) {
        return callback('NO_LOGIN');
      }

      redis.insertReader({id: req.room}, socket.user.id);
      return callback(null);
    });
  } // function onConnection(socket) {
};
  • nodejs > redis.js
require('dotenv').config();
const redis = require('redis');

const rClient = redis.createClient(process.env.REDIS_PORT, process.env.REDIS_HOST, {
  return_buffers: false,
  password: process.env.REDIS_AUTH,
  db: process.env.REDIS_DATABASE,
  retry_strategy: (options) => {
    if (options.error === null || options.error.code === 'ECONNREFUSED') {
      return 1000; // retry after 1 sec
    }
  }
});

exports.setData = (key, value, callback) => {
  rClient.set(key, JSON.stringify(value), (err, resp) => {
    return callback(err, resp);
  });
};

// chatRoomInfo.[Room ID] 에 대한 정리
exports.getData = (key, callback) => {
  rClient.get(key, (err, resp) => {
    return callback(err, resp);
  });
};

exports.getChatRoomInfo = (chatRoomInfo, params, callback) => {
  rClient.set(chatRoomInfo, JSON.stringify(params), (err, resp) => {
    return callback(null, resp);
  });
};

/**
 * user의 모든 채팅룸 가져오기
 */
exports.chatRooms = (user, callback) => {
  rClient.lrange('chatRooms.' + user, 0, -1, (err, data) => {
    if (err) { return callback(err); }
    try {
      return callback(null, data);
    } catch (e) {
      callback(e);
    }
  });
};

exports.updateChatRooms = (user, roomInfo, callback) => {
  rClient.lpush(['chatRooms.' + user, JSON.stringify(roomInfo)], (err) => {
    return callback(err);
  });

};

/**
  * room 에 있는 채팅 내용 가져오기
  */
exports.chats = (room, callback) => { 
  rClient.lrange('chats.' + room, 0, -1, (err, data) => {
    if (err) { return callback(err); }
    try {
      return callback(null, data);
    } catch (e) {
      callback(e);
    }
  });
};

/**
 * 채팅내용을 입력한다.
 */
exports.chat = (req, callback) => {
  // {room: this.roomId, message: this.message, user:{id: this.user.id, name: this.user.name}}
  const room = req.room;
  delete req.room; // 필요없는 정보는 삭제
  rClient.lpush(['chats.' + room, JSON.stringify(req)], (err) => {
    return callback(err);
  });
};

/**
 * 읽은 사람의 정보를 업데이트 한다.
 */
exports.insertReader = (room, user) => {
  // type : chat | system   chat: 일반적인 채팅, system: system에서 전달하는 메시지
  // rClient.lpush(['trade.chat.' + chatId, JSON.stringify(chat)], () => {});
  
  const roomName = 'chats.' + room.id;

  rClient.lrange(roomName, 0, 0, (err, data) => {
    if (err) {
      console.error(err);
    }
    try {
      console.log('data >>', data);
      console.log('data.length >>', data.length);
      if (data.length > 0) {
        const message = JSON.parse(data[0]);
        
        const is = message.reader.indexOf(user);
        if (is === -1) {
          message.reader.push(user);
          rClient.lset(roomName, 0, JSON.stringify(message), () => {
          });
        }
      }
    } catch (e) {
      // return;
    }
  });
};

exports.findKey = (key, callback) => {
   rClient.exists(key, (err, result) =>{ // result : 0 | 1
    callback(err, result);
  });
}

exports.deleteItem = (room, item) => {
  rClient.lrem(room, 1, item);     // 키(room) 에 item이 있다면 하나만 지웁니다
}
평점을 남겨주세요
평점 : 5.0
총 투표수 : 1

질문 및 답글