히스토리 기능 기획부터 구현까지 - boostcampwm-2021/WEB08-AgileStorming GitHub Wiki

작성자: 안주영

기술 공유 발표 자료

시작

마인드마이스터 사이트의 히스토리 기능 https://www.mindmeister.com/

히스토리에 따라 마인드맵이 그려진다.

고민

구현을 어떻게 할 것인가?

  1. 전체를 스냅샷을 찍어 남기는 방법
  2. 변경사항마다 새로운 노드를 만들어 갈아끼는 방법
  3. 변경 내역을 남기고 이를 바탕으로 계산하는 방법

변경 내역을 남기자

  • 가장 깔끔한 방식으로 생각되었음.
  • 고민,, 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);
  }
});