히스토리 기능 기획부터 구현까지 - boostcampwm-2021/WEB08-AgileStorming GitHub Wiki
작성자: 안주영
시작
마인드마이스터 사이트의 히스토리 기능 https://www.mindmeister.com/
히스토리에 따라 마인드맵이 그려진다.
고민
구현을 어떻게 할 것인가?
- 전체를 스냅샷을 찍어 남기는 방법
- 변경사항마다 새로운 노드를 만들어 갈아끼는 방법
- 변경 내역을 남기고 이를 바탕으로 계산하는 방법
변경 내역을 남기자
- 가장 깔끔한 방식으로 생각되었음.
- 고민,, history도 남기고 실제 마인드맵 데이터베이스도 업데이트 해야 한다.
- 둘의 동기화를 어떻게 하지?
- 동시에 여러 사람이 작업을 하면 순서를 어떻게 보장하지?
검색
이벤트 소싱
- 기존 CRUD 방식과는 다르게 이벤트 로그를 남기는 방식.
- 오직 Create만 있다.
- 기록된 이벤트를 이벤트 핸들러를 통해 처리를 해서 상태를 재현.
- 따라서 상태와 이벤트가 불일치할 일이 없다.
- 이벤트 버전을 씀으로써 동시성 문제도 해결할 수 있다.
스냅샷
- 이벤트 로그가 아주 많이 쌓이게 되면 상태를 재현하는 데 비용이 많이 들게 된다.
- 특정 이벤트, 혹은 몇 개의 이벤트 로그가 쌓일 때 마다 상태를 스냅샷으로 가지고 있게 한다.
- 조회할 때 스냅샷으로부터 몇 개의 이벤트만 계산하면 된다.
CQRS (Command Query Responsibility Segregation)
- 아무리 스냅샷이 있다고 해도 조회를 하려고 할 때마다 계산을 하는 것은 좋지 않다.
- 조회와 명령 책임 분리
- 이벤트 소싱의 경우 읽기와 쓰기를 하는 데이터베이스 모델을 분리
- 이벤트 소싱의 구현에 있어서 cqrs는 필수라고 한다.
- 쿼리를 따로 날리는 게 아니라 이벤트 로그가 들어오면 메세지 큐 등을 통해 유기적으로 조회용 db가 업데이트 되도록 만들어야 함.
적용할 수 있을까?
이벤트 소싱의 단점
- 이벤트 스키마가 바뀐다면 이벤트 로그와 이벤트 핸들러의 버전 관리가 필요하다.
- 이벤트 일관성을 잘 지키도록 만들어야 한다.
- 데이터 처리 순서
- 단 한 번만 이벤트가 실행
- 우리가 이벤트 로그가 필요한 이유는 클라이언트에서 마인드맵의 재현을 위한 것일 뿐이어서, 위의 단점들을 크게 고려하지 않아도 된다.
redis
- redis의 명령 처리는 싱글 스레드이다. 따라서 데이터 처리 순서에 대한 걱정을 덜어도 된다.
- 이벤트 소싱이나 cqrs의 구현 자체는 어떠한 db도 상관이 없지만 redis가 가장 가볍고 간편했다.
redis stream
- 로그 메세지 데이터 구조를 위한 데이터 유형
- redis 5.0부터 추가 됨
- producers, consumers 개념
- 기본적으로 xadd로 데이터를 추가,
- 로그 데이터 처리의 성공,실패 여부가 중요하지 않다면 xread로 간단히 읽어와서 처리할 수 있고,
- 혹은 xgroup, xgroupread, xack, xpending, xclaim으로 데이터 처리가 되었는지 까지 확인하고, 재시도하는 과정을 만들 수도 있다.
XREAD BLOCK 0 STREAMS mystream $
간단 예시
우리의 구현
- /server/src/utils/redis.ts
import redis from 'redis';
import * as dotenv from 'dotenv';
dotenv.config();
export const xread = (stream: string, id: string, callback: (str) => void) => {
const xreadClient = redis.createClient({
port: 6379,
host: process.env.REDIS_HOST,
});
xreadClient.xread('BLOCK', 0, 'STREAMS', stream, id, (err, result) => {
if (err) throw err;
callback(result);
xreadClient.quit();
});
};
const redisClient = redis.createClient({
port: 6379,
host: process.env.REDIS_HOST,
});
export const xadd = ({ stream, args }: { stream: string; args: string[] }) => {
redisClient.xadd(stream, '*', ...args, (err, result) => {
if (err) throw err;
});
};
export const xrevrange = ({ projectId, from, to, count }: Record<string, string>) => {
return new Promise<Array<string | string[]>>((resolve) => {
redisClient.xrevrange(projectId, from, to, 'COUNT', count, (err, result) => {
if (err) throw err;
resolve(result);
});
});
};
- /server/src/utils/socket.ts
io.on('connection', async (socket: ISocket) => {
...
if (!userInRooms.hasOwnProperty(projectId)) {
xread(projectId, '$', handleNewEvent);
userInRooms[projectId] = [id];
}
...
socket.on('history-event', (type, data) => {
xread(projectId, '$', handleNewEvent);
xadd({
stream: projectId,
args: ['type', type, 'projectId', projectId, 'user', id, 'data', data],
});
});
}
const handleNewEvent = async (data: Record<number, object>) => {
const eventData = data[0][1][0][1];
const dbData = await convertHistoryEvent(eventData);
io.in(projectId).emit('history-event', eventData, dbData);
};
- /server/src/utils/event-converter.ts
import { createNode, updateNode, deleteNode } from '../services/mindmap';
type eventType = 'ADD_NODE' | 'DELETE_NODE' | 'UPDATE_NODE_CONTENT';
enum eventArgs {
'type' = 1,
'project' = 3,
'user' = 5,
'data' = 7,
}
type eventData = {
nodeFrom: number,
nodeTo: number,
dataFrom: object,
dataTo: object
}
const historyEventFunction = (): Record<eventType.THistoryEventType, THistoryEventFunction> => {
return {
ADD_NODE: ({ nodeFrom, dataTo }, project: string) => {
return createNode(project, nodeFrom, dataTo as eventType.TAddNodeData);
},
DELETE_NODE: ({ nodeFrom, dataFrom }) => {
deleteNode(nodeFrom, (dataFrom as eventType.TDeleteNodeData).nodeId);
return;
},
MOVE_NODE: ({ nodeTo, dataTo }) => {
updateNode(nodeTo, dataTo as eventType.TMoveNodeData);
return;
},
UPDATE_NODE_PARENT: ({ nodeFrom, nodeTo, dataTo }) => {
const { nodeId, nodeType, nodeParentType } = dataTo as eventType.TUpdateNodeParent;
updateNodeParent(nodeFrom, nodeTo, nodeId);
if (nodeType === 'TASK' && nodeParentType !== 'STORY') deleteTask(nodeId);
if (nodeType === 'STORY' && nodeParentType !== 'EPIC') deleteChildTasks(nodeId);
return;
},
UPDATE_NODE_SIBLING: ({ dataTo }) => {
const { parentId, children } = dataTo as eventType.TUpdateNodeSibling;
updateNode(parentId, { children: JSON.stringify(children) });
return;
},
UPDATE_NODE_CONTENT: ({ nodeFrom, dataTo }) => {
updateNode(nodeFrom, dataTo as eventType.TUpdateNodeContent);
return;
},
UPDATE_TASK_INFORMATION: ({ nodeFrom, dataTo }) => {
updateTask(nodeFrom, dataTo as eventType.TUpdateTaskInformation);
return;
},
};
};
export const convertHistoryEvent = (args: string[]) => {
const [type, project, user, data] = ['type', 'project', 'user', 'data'].map((str) =>
args[EventArgs[str]]);
return historyEventFunction()[type](JSON.parse(data), project, user);
};
router.get('/:projectId', async (req: Request, res: Response) => {
try {
const { projectId } = req.params;
const { rangeFrom, count } = req.query;
const history: Array<string | string[]> = await xrevrange({ projectId, from: rangeFrom ?? '+', to: '-', count: count ?? 30 });
res.status(200).send(history);
} catch (e) {
res.status(500).send(e.message);
}
});