애자일 협력툴로 마인드맵의 데이터 베이스를 설계 중이다.
설계를 위한 요구사항을 분석해보면 다음과 같다.
자식이 없을 수도 10개가 될 수도 있다.
실시간 협업툴이기 때문에 추가,수정,삭제가 빈번하다.
수정이 일어나도 이전 데이터를 기억해야한다.
root,epic,story,task로 최대 4 레벨이다.
후보로 생각나는 방법을 적어보고 장단점을 생각해보았다. 예시로는 기존 트리에 1번 노드에 3번 노드를 넣는 것으로 들겠다.
요구사항을 보며 Git을 떠올렸다. Git은 트리구조로 데이터를 저장하고, 커밋마다 스냅샷을 찍는다.
장점:
로직이 간단하다.
수정이 일어나면 새 Json을 만들어서 보내면 된다.
join이 필요없다.
노드간의 관계가 필요없고, 한 번의 쿼리만 필요하다.
단점:
일부분 수정에도 큰 payload가 발생한다.
트리의 한 노드에서의 변화에도 전체 트리를 전송해야한다.
중복 데이터가 발생한다.
변경이 일어나지 않은 부분에 대해서도 저장이 되기때문에 DB에 중복데이터가 발생한다.
Git은 사실 전체 데이터를 통째로 저장하지는 않는다. 변경 부분에 대하여 새 blob을 생성하여 효율적으로 데이터를 보관한다.(+ sha1 hash 알고리즘) 그러다면, 변경이 일어난 부분에 대해서만 새 노드를 생성하는 방법은 어떨까. 일단, 루트 노드는 매번 새로 생성되어야 할 것이다.
장점:
단점:
join이 많이 필요하다.
계층적 노드에 대한 join이 많이 일어난다. 서버에서 30개의 히스토리를 보내줘야한다면, 30번의 재귀적인 join이 일어날 것이다.
변경 알고리즘이 복잡하다.
변경된 노드에 대해 새 노드를 생성하는 것과 더불어, 상위 노드도 자식노드에 대한 변경이 있음으로 새 노드를 생성하다 보면, 재귀적으로 root까지 새 노드를 생성하는 알고리즘이 필요하다.
마치 데이터베이스를 롤백하듯이, 트리의 변경을 논리적 단위로 저장한다면, 변경사항만으로도 과거의 히스토리를 만들어낼 수 있다.
장점:
payload가 작다.
정말 순수히 바뀐 부분에 대해서만 전송하고 저장할 수 있다.
join이 필요없다.
노드간의 관계가 필요없고, 서버에서는 최신 노드만 저장할 수 있다.
단점:
RDB와 NoSQL 중 어떤 것이 유리할지 고려해보았다.
수정은 빈번하나 과거 데이터를 기억해야함으로 DB에서의 수정은 거의 없을 것이고. 계층적인 데이터를 저장하기 위해 RDB에서는 Adjacency List, Nested Set, Closure Table 같은 기법을 써야하고, NoSQL이라면 MongoDB(Document DB), Neo4j(Graph DB)가 고려 대상이 될 수 있겠다.
장점:
단점:
장점:
단점:
https://www.slideshare.net/billkarwin/models-for-hierarchical-data
https://db-engines.com/en/system/CouchDB%3BElasticsearch%3BMongoDB%3BNeo4j
https://www.mongodb.com/scale/nosql-vs-relational-databases
https://www.mongodb.com/nosql-explained/nosql-vs-sql
https://www.lesstif.com/dbms/rdb-hierarchical-data-28606798.html
데이터 row의 크기는 데이터베이스의 성능에 영향을 미친다. 큰 row를 가지게되면 메모리에 올라가는 데이터가 적어지고, 이는 캐쉬 성능을 떨어트리고 더 많은 disc I/O를 일으킨다.
Bigger Row Size = Less Rows Per Page = More data pages per table = More I/O operations = Performance degradation
https://stackoverflow.com/questions/842791/mysql-duplicating-data-vs-join
https://aboutsqlserver.com/2010/08/25/why-row-size-matters/
실시간 데이터 반영을 위해 서버와 소켓간에 socket.io를 활용할 계획이다. 혹시 전해줘야할 데이터가 커진다면 영향이 있지 않을까? (물론 json 데이터에 대한 크기이긴 하지만 말이다.)
조사해보니, Node.js와 socket.io 지정된 limit은 없고, 서버와 클라이언트가 수용할수 있는 bandwidth에 달려 있다고 한다. 데이터와 사이즈, 전송 주기, 서버의 네트워크 bandwidth를 통해 계산을 할 수 있는데, 그렇다면 패킷이 작은게 유리하다고는 할 수 있겠다.
https://stackoverflow.com/questions/12977719/how-much-data-can-i-send-through-a-socket-emit
https://stackoverflow.com/questions/47275123/how-f requently-can-i-send-data-using-socket-io
역정규화라는 좋은 단어가 있었다. 잦은 join으로 인해 퍼포먼스가 떨어지는 것을 방지하기 위해 관계를 제거하고 하나의 구조로 만드는 것이다.
트리의 depth 자체는 낮지만 자식 노드의 개수가 정해져있지 않기 때문에 mySQL에서의 역정규화는 json을 stringfy 시키는 방법이 유일해 보인다.
https://en.wikipedia.org/wiki/Denormalization
https://dba.stackexchange.com/questions/461/duplicate-column-for-faster-queries
https://stackoverflow.com/questions/842791/mysql-duplicating-data-vs-join
결론적으로 선택한 방법은, 3번 최신 노드와 변경사항을 저장하는 방법이다. 그 이유는 중복된 데이터가 최소한이면서도, join의 횟수도 적기 때문이다. join이 필요한 경우도 최신 노드를 저장할 때 뿐인데 이 또한 비정규화를 통해 방지할 수 있다. 저장되고 전송되는 data도 변경사항 뿐임으로 효율적이다. 구체적인 규격은 다음과 같다.
최신노드를 서버에서 보관을 해야한다. 이때 Redis를 통해 메모리에서 최신노드를 관리하고, 비동기로 최신노드를 DB에 저장시켜 성능을 향상시킨다.
mySQL을 사용하는 이유는 noSql에 비해 비정형 데이터를 저장하기에는 불편하나, Adjacency List로 저장한 뒤 redis에 최신 모델을 올려놓는 방식으로 극복한다. 또한, 변경사항을 저장할 때 timeStamp를 index로 사용해 빠르게 정렬하고 응답하게 한다.
상향식으로 parent를 기억한 뒤 맵을 만든다면, 노드간의 순서를 변경시킬시 3번의 쿼리가 필요하다. (부모, 나, 형제 모드)
하향식으로 child를 관리한다면, 1번의 쿼리로 가능하다.
모든 맵의 변경은 서버에 요청하고, 서버에서 처리 후 broadcast 하는 방식으로 동시성을 보호한다. 먼저 도착한 요청 순으로 낮은 timeStamp를 부여 받는다.
모든 변경 요청은 순수한 작업이고 롤백이 가능해야 한다.
구체적인 변경 타입과 필요한 데이터는 다음과 같다.
type | nodeFrom | nodeTo | dataFrom | dataTo | 비고 |
---|---|---|---|---|---|
node 추가 | 상위 nodeId | null | null | node데이터 | (id는 서버에서 추가) |
node 삭제 | 상위 nodeId | null | node데이터 | null | |
node 이동 | nodeId | nodeId | node데이터 | nodeId | 새 백로그 넘버 부여 |
node 합체 | nodeId | nodeId | node데이터 | nodeId[] | 새 백로그 넘버 부여 |
node 다중 삭제 | nodeId | null | node데이터 | null | |
node 순서 변경 | nodeId | null | nodeId[] | nodeId[] | |
마감시간 변경 | nodeId | null | Date | Date | |
이름 변경 | nodeId | null | string | string | |
스프린트 추가 | nodeId | null | null | sprintId | |
스프린트 삭제 | nodeId | null | sprintId | null | |
스프린트 변경 | nodeId | null | sprintId | sprintId | |
라벨 추가 | nodeId | null | null | labelId | |
라벨 삭제 | nodeId | null | labelId | null | |
중요도 변경 | nodeId | null | number | number | |
댓글 추가 | nodeId | null | null | 댓글데이터 | (id는 서버에서 추가) |
댓글 삭제 | nodeId | null | 댓글데이터 | null |