리액트, Node JS - 변경된 셀만 데이터 전송해서 최적화하기 (Data Optimization with Socket.IO)
참고
- 스프레드시트 공동 작업하기 Project Settings
- Handsontable에 Socket.IO 적용하기
- 변경된 셀만 데이터 전송해서 최적화하기
- 행 / 열 이동 및 크기 변경 연동하기
- 다른 클라이언트가 선택한 셀 표시하기
이전 글에서 전체 데이터를 한 번에 서버로 전송하였다.
afterChange: function (changes, source) {
// changes : 변경된 데이터 정보, source : 변경을 발생시킨 원인
if (source === "loadData") return;
console.log("Changed Data :", source, changes);
socketIO.emit("sendData", this.getData());
},
afterCreateRow: function (index, amount) {
console.log("create row :", index, amount);
socketIO.emit("sendData", this.getData());
},
...
그리고 서버는 다른 클라이언트에게 broadcast로 전체 데이터를 전송하였다.
socket.on("sendData", (data) => {
console.log(data);
currentData = data;
socket.broadcast.emit("respondData", currentData);
});
데이터가 적으면 상관없지만, 클라이언트가 많을수록, 데이터가 크면 클수록 서버에 부담이 되게 된다.
필요한 데이터만 전송하여 handsontable을 공동 편집해 보자.
최신 정보 전달
먼저 나중에 들어온 클라이언트도 최신 정보를 알 수 있도록 코드를 보완하자.
서버에 currentData가 undefined인 경우라면, 최초로 서버에 연결된 클라이언트가 된다.
이 클라이언트가 현재 tableData를 통째로 보내도록 하자.
undefined가 아닌 경우는 나중에 들어온 클라이언트이므로, setTableData로 데이터를 갱신한다.
useEffect(() => {
socketIO.once("initData", (data) => {
console.log(data);
if(data.currentData) setTableData(data.currentData);
else {
socketIO.emit("setCurrentData", tableData);
}
});
}, []);
서버에서는 현재 data를 설정하기만 하면 된다.
socket.on("setCurrentData", (data) => {
console.log("setCurrentData");
currentData = data;
});
handsontable의 edit hook
현재 사용하는 hook은 총 5가지다. (데이터 변경 + 행열 추가 / 삭제)
각각 로그를 추가해서 hook이 어떻게 동작하는지 알아보자.
afterChange: function (changes, source) {
socketIO.emit("changeData", changes);
},
afterCreateRow: function (index, amount) {
console.log("create row :", index, amount);
},
afterRemoveRow: function (index, amount) {
console.log("remove row :", index, amount);
},
afterCreateCol: function (index, amount) {
console.log("create col :", index, amount);
},
afterRemoveCol: function (index, amount) {
console.log("remove col :", index, amount);
},
데이터를 변경할 경우, 변경된 데이터는 [row, col, before, after]에 대한 정보로 알 수 있고, 배열로 제공된다.
행/열 추가/삭제의 경우 index, amount로 어떤 index에 얼만큼 수정이 되었는지 알 수 있다.
그리고 data 붙여넣기로 테이블이 변경되는 경우, 행/열이 추가된 후에 데이터 편집이 이루어진다.
즉, 해당되는 정보를 그대로 socket 서버에 보내면 된다.
다른 클라이언트에게는 changes나 index, amount만 정보를 보내면 된다.
그리고 이 정보를 이용해서 서버와 클라이언트 모두 현재 data를 갱신하면, data를 통째로 보낼 필요가 없다.
afterChange: function (changes, source) {
// changes : 변경된 데이터 정보, source : 변경을 발생시킨 원인
if (source === "loadData") return;
console.log("Changed Data :", source, changes);
socketIO.emit("changeData", changes);
},
afterCreateRow: function (index, amount) {
console.log("create row :", index, amount);
socketIO.emit("createRow", index, amount);
},
afterRemoveRow: function (index, amount) {
console.log("remove row :", index, amount);
socketIO.emit("removeRow", index, amount);
},
afterCreateCol: function (index, amount) {
console.log("create col :", index, amount);
socketIO.emit("createCol", index, amount);
},
afterRemoveCol: function (index, amount) {
console.log("remove col :", index, amount);
socketIO.emit("removeCol", index, amount);
},
Socket Server
2차원 배열 빈 행/열 추가, 삭제하기를 참고하여 2차원 배열을 변경하는 메서드를 추가한다.
const insertRows = (table, rowIndex, amount) => { ... };
const deleteRows = (table, rowIndex, amount) => { ... };
const insertColumns = (table, colIndex, amount) => { ... };
const deleteColumns = (table, colIndex, amount) => { ... };
만약 행 추가 이벤트가 발생하면, 서버는 index와 amount로 currentData에 행을 추가하면 된다.
그리고 index와 amount만 클라이언트에게 보내면 된다.
socket.on("createRow", (index, amount) => {
console.log("createRow", index, amount);
currentData = insertRows(currentData, index, amount);
socket.broadcast.emit("resCreateRow", index, amount);
});
데이터 변경은 changes를 for문으로 순회하면서 data를 직접 수정하면 된다.
그리고 changes만 클라이언트에게 보내면 된다.
socket.on("changeData", (changes) => {
console.log("changeData", changes);
for(let change of changes) {
let [row, col, before, after] = change;
currentData[row][col] = after;
}
socket.broadcast.emit("resChangeData", changes);
});
그 외 코드는 전체 코드를 참고하자.
Socket Client
클라이언트(리액트)도 2차원 배열 빈 행/열 추가, 삭제하기 메서드를 추가한다.
const insertRows = (table, rowIndex, amount) => { ... };
const deleteRows = (table, rowIndex, amount) => { ... };
const insertColumns = (table, colIndex, amount) => { ... };
const deleteColumns = (table, colIndex, amount) => { ... };
hook에서 서버에 해당되는 메시지를 전송한다.
afterCreateRow: function (index, amount) {
console.log("create row :", index, amount);
socketIO.emit("createRow", index, amount);
},
afterRemoveRow: function (index, amount) {
console.log("remove row :", index, amount);
socketIO.emit("removeRow", index, amount);
},
afterCreateCol: function (index, amount) {
console.log("create col :", index, amount);
socketIO.emit("createCol", index, amount);
},
afterRemoveCol: function (index, amount) {
console.log("remove col :", index, amount);
socketIO.emit("removeCol", index, amount);
},
서버 코드와 마찬가지로 callback 함수에서 data를 갱신하자.
const resCreateRow = (index, amount) => {
console.log("createRow", index, amount);
setTableData((prev) => {
let newTable = insertRows(prev, index, amount);
return newTable;
});
};
...
const resChangeData = (changes) => {
console.log("changeData",changes);
setTableData((prev) => {
let newTable = [...prev];
for(let change of changes) {
let [row, col, before, after] = change;
newTable[row][col] = after;
}
return newTable;
});
};
그리고 이벤트를 등록하면 된다.
useEffect(() => {
socketIO.connect();
if (!socketIO) return;
// socketIO.on("respondData", respondDataCallback);
socketIO.on("resCreateRow", resCreateRow);
socketIO.on("resRemoveRow", resRemoveRow);
socketIO.on("resCreateCol", resCreateCol);
socketIO.on("resRemoveCol", resRemoveCol);
socketIO.on("resChangeData", resChangeData);
return () => {
// socketIO.off("respondData", respondDataCallback);
socketIO.off("resCreateRow", resCreateRow);
socketIO.off("resRemoveRow", resRemoveRow);
socketIO.off("resCreateCol", resCreateCol);
socketIO.off("resRemoveCol", resRemoveCol);
socketIO.off("resChangeData", resChangeData);
};
}, []);
전체 코드는 다음과 같다.
socketIOServer.js
const { Server } = require("socket.io");
let currentData = undefined;
const io = new Server("3333", {
cors: {
origin: "http://localhost:3000",
},
});
const insertRows = (table, rowIndex, amount) => {
const afterIndex = table.slice(rowIndex);
const emptyArray = Array.from({ length: table[0].length }, () => "");
const newRows = Array.from({ length: amount }, () => emptyArray);
const newArray = table.slice(0, rowIndex).concat(newRows, afterIndex);
return newArray;
};
const deleteRows = (table, rowIndex, amount) => {
const newTable = [...table];
newTable.splice(rowIndex, amount);
return newTable;
};
const insertColumns = (table, colIndex, amount) => {
const newMatrix = [];
for (let i = 0; i < table.length; i++) {
const newRow = [...table[i]];
for (let k = 0; k < amount; k++) {
newRow.splice(colIndex + k, 0, "");
}
newMatrix.push(newRow);
}
return newMatrix;
};
const deleteColumns = (table, colIndex, amount) => {
const newTable = [];
for (let i = 0; i < table.length; i++) {
const newRow = [...table[i]];
newRow.splice(colIndex, amount);
newTable.push(newRow);
}
return newTable;
};
io.sockets.on("connection", (socket) => {
socket.emit("initData", {
currentData,
});
socket.on("setCurrentData", (data) => {
console.log("setCurrentData");
currentData = data;
});
socket.on("createRow", (index, amount) => {
console.log("createRow", index, amount);
currentData = insertRows(currentData, index, amount);
socket.broadcast.emit("resCreateRow", index, amount);
});
socket.on("removeRow", (index, amount) => {
console.log("removeRow", index, amount);
currentData = deleteRows(currentData, index, amount);
socket.broadcast.emit("resRemoveRow", index, amount);
});
socket.on("createCol", (index, amount) => {
console.log("createCol", index, amount);
currentData = insertColumns(currentData, index, amount);
socket.broadcast.emit("resCreateCol", index, amount);
});
socket.on("removeCol", (index, amount) => {
console.log("removeCol", index, amount);
currentData = deleteColumns(currentData, index, amount);
socket.broadcast.emit("resRemoveCol", index, amount);
});
socket.on("changeData", (changes) => {
console.log("changeData", changes);
for(let change of changes) {
let [row, col, before, after] = change;
currentData[row][col] = after;
}
socket.broadcast.emit("resChangeData", changes);
});
// socket.on("sendData", (data) => {
// console.log(data);
// currentData = data;
// socket.broadcast.emit("respondData", currentData);
// });
socket.on("disconnect", () => {});
});
SocketTable.js (Client)
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import { io } from "socket.io-client";
import {
Avatar,
Conversation,
MessageSeparator,
} from "@chatscope/chat-ui-kit-react";
import SimpleHandsOnTable from "./SimpleHandsOnTable";
const AVATAR_MAP = {
Lilly: "https://chatscope.io/storybook/react/assets/lilly-aj6lnGPk.svg",
Joe: "https://chatscope.io/storybook/react/assets/joe-v8Vy3KOS.svg",
Emily: "https://chatscope.io/storybook/react/assets/emily-xzL8sDL2.svg",
Akane: "https://chatscope.io/storybook/react/assets/akane-MXhWvx63.svg",
Eliot: "https://chatscope.io/storybook/react/assets/eliot-JNkqSAth.svg",
Zoe: "https://chatscope.io/storybook/react/assets/zoe-E7ZdmXF0.svg",
};
const defaultData = [
["", "Tesla", "Nissan", "Toyota", "Honda", "Mazda", "Ford"],
["2017", 10, 11, 12, 13, 15, 16],
["2018", 10, 11, 12, 13, 15, 16],
["2019", 10, 11, 12, 13, 15, 16],
["2020", 10, 11, 12, 13, 15, 16],
["2021", 10, 11, 12, 13, 15, 16],
];
let socketIO = io("http://localhost:3333", { autoConnect: false });
const customOptions = {
afterChange: function (changes, source) {
// changes : 변경된 데이터 정보, source : 변경을 발생시킨 원인
if (source === "loadData") return;
console.log("Changed Data :", source, changes);
socketIO.emit("changeData", changes);
},
afterCreateRow: function (index, amount) {
console.log("create row :", index, amount);
socketIO.emit("createRow", index, amount);
},
afterRemoveRow: function (index, amount) {
console.log("remove row :", index, amount);
socketIO.emit("removeRow", index, amount);
},
afterCreateCol: function (index, amount) {
console.log("create col :", index, amount);
socketIO.emit("createCol", index, amount);
},
afterRemoveCol: function (index, amount) {
console.log("remove col :", index, amount);
socketIO.emit("removeCol", index, amount);
},
};
const insertRows = (table, rowIndex, amount) => {
const afterIndex = table.slice(rowIndex);
const emptyArray = Array.from({ length: table[0].length }, () => "");
const newRows = Array.from({ length: amount }, () => emptyArray);
const newArray = table.slice(0, rowIndex).concat(newRows, afterIndex);
return newArray;
};
const deleteRows = (table, rowIndex, amount) => {
const newTable = [...table];
newTable.splice(rowIndex, amount);
return newTable;
};
const insertColumns = (table, colIndex, amount) => {
const newMatrix = [];
for (let i = 0; i < table.length; i++) {
const newRow = [...table[i]];
for (let k = 0; k < amount; k++) {
newRow.splice(colIndex + k, 0, "");
}
newMatrix.push(newRow);
}
return newMatrix;
};
const deleteColumns = (table, colIndex, amount) => {
const newTable = [];
for (let i = 0; i < table.length; i++) {
const newRow = [...table[i]];
newRow.splice(colIndex, amount);
newTable.push(newRow);
}
return newTable;
};
const SocketTable = () => {
const location = useLocation();
const [loginID, setLoginID] = useState("");
const [tableData, setTableData] = useState(defaultData);
const init = () => {
setLoginID(location.state.loginID);
};
useEffect(init, []);
// const respondDataCallback = (newData) => {
// setTableData(newData);
// };
const resCreateRow = (index, amount) => {
console.log("createRow", index, amount);
setTableData((prev) => {
let newTable = insertRows(prev, index, amount);
return newTable;
});
};
const resRemoveRow = (index, amount) => {
console.log("removeRow", index, amount);
setTableData((prev) => {
let newTable = deleteRows(prev, index, amount);
return newTable;
});
};
const resCreateCol = (index, amount) => {
console.log("createCol", index, amount);
setTableData((prev) => {
let newTable = insertColumns(prev, index, amount);
return newTable;
});
};
const resRemoveCol = (index, amount) => {
console.log("removeCol", index, amount);
setTableData((prev) => {
let newTable = deleteColumns(prev, index, amount);
return newTable;
});
};
const resChangeData = (changes) => {
console.log("changeData",changes);
setTableData((prev) => {
let newTable = [...prev];
for(let change of changes) {
let [row, col, before, after] = change;
newTable[row][col] = after;
}
return newTable;
});
};
useEffect(() => {
socketIO.connect();
if (!socketIO) return;
// socketIO.on("respondData", respondDataCallback);
socketIO.on("resCreateRow", resCreateRow);
socketIO.on("resRemoveRow", resRemoveRow);
socketIO.on("resCreateCol", resCreateCol);
socketIO.on("resRemoveCol", resRemoveCol);
socketIO.on("resChangeData", resChangeData);
return () => {
// socketIO.off("respondData", respondDataCallback);
socketIO.off("resCreateRow", resCreateRow);
socketIO.off("resRemoveRow", resRemoveRow);
socketIO.off("resCreateCol", resCreateCol);
socketIO.off("resRemoveCol", resRemoveCol);
socketIO.off("resChangeData", resChangeData);
};
}, []);
useEffect(() => {
socketIO.once("initData", (data) => {
console.log(data);
if(data.currentData) setTableData(data.currentData);
else {
socketIO.emit("setCurrentData", tableData);
}
});
}, []);
return (
<div>
<Conversation
info="I'm fine, thank you, and you?"
lastSenderName={loginID}
name={loginID}
>
<Avatar name={loginID} src={AVATAR_MAP[loginID]} status="available" />
</Conversation>
<MessageSeparator style={{ marginTop: 5, marginBottom: 5 }} />
<SimpleHandsOnTable
data={tableData}
customOptions={customOptions}
/>
</div>
);
};
export default SocketTable;
SimpleHandsOnTable.js는 수정 없음