NodeJs의 socket.io를 이용한 상용 채팅 서비스 구현하기
사용 채팅 서비스 구현하기
일전에 간단한 채팅 동작에 대해서 설명드렸습니다.
오늘은 실서비스에 사용할 채팅에 대해서 설명드리려고 합니다.
사용 툴
- 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부분으로 구성된다.
- chatInit: 상대를 선택하면 기존 채팅이 있는지 확인하고, 있으면 room Id 를 가져오고 없으면 새로 생성하여 join 한다. 그리고 기존 채팅방의 내용을 가져온다.
- sendMessage: 메시지를 node 로 보낸다.
- 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이 있다면 하나만 지웁니다
}