04-04 17:40
Notice
Recent Posts
관리 메뉴

독산구너

트리구조 구현을 위한 MongoDB 사용전략 본문

카테고리 없음

트리구조 구현을 위한 MongoDB 사용전략

독산구너 2024. 12. 16. 19:02

글의 목적

인턴 과정 중 투입된 프로젝트에서 '업무'와 '업무가 포함되는 그룹' 정보를 다루게 되었습니다. task와 group으로 칭하겠습니다.

group은 하위 group은 가질 수 있고, 하나의 task는 하나의 group에만 포함되는 구조입니다. 이러한 트리구조를 가장 잘 다룰 수 있는 데이터 저장방식을 고민하였고, 그 과정을 기록하고자 합니다

 

요구사항

  • 그룹-태스크 트리구조 저장
    • 그룹 내 다수의 그룹이 생성될 수 있으며, 없을수도 있다.
    • 그룹 내 다수의 태스크가 생성될 수 있으며, 없을수도 있다.
    • 태스크 하위에는 그룹 또는 태스크 생성이 불가하다
  • 드래그 앤 드롭으로 그룹간 그룹이동, 그룹간 태스크 이동
  • 그룹 삭제, 태스크 삭제
Group A
│
├── Task 1
├── Task 2
└── Group B
    ├── Task 3
    ├── Group C
    │   ├── Task 4
    │   └── Group D
    │       ├── Task 5
    │       └── Group E
    │           └── Task 6
    └── Group F
        ├── Task 7
        └── Group G
            ├── Task 8
            └── Task 9
Group H
│
├── Task 10
└── Group I
    ├── Task 11
    ├── Group J
    │   └── Task 12
    └── Group K
        └── Task 13

 

 

왜 MongoDB를 쓰나요?

현재 Group-Task 는 트리구조이고, RDBMS를 사용할 경우 이러한 트리구조를 저장하기에 적합하지 않다는 결론을 내렸습니다. 이유는 다음과 같습니다.

  1. parentGroupId를 통해 재귀적으로 트리구조를 만들면 하나의 구조를 불러오기 위해 다수의 쿼리를 순서대로 수행해야 한다.
  2. parentGroupId를 통해 재귀적으로 트리구조를 만들 시 두 그룹의 parent가 서로를 가르킬 경우 무한루프에 빠지게 될 가능성이 있다.
  3. 하위노드를 찾기위해 Full Range Scan이 필요하다 (인덱스를 생성하게 되면 parent가 자주 수정될 경우 성능 저하로 이어질 수 있다.)
  4. 순서를 보장하기 위해 추가적인 작업(Order by)이 필요하다.

 

MongoDB Document를 사용해서 얻는 이점은 다음과 같습니다.

  1. 트리구조를 그대로 저장해 항상 루트노드에서부터 탐색하므로 시간복잡도가 낮다(O(1))
  2. 같은 Document에서 순서를 보장한다.

MongoDB를 사용할 때의 단점은 다음과 같습니다.

  1. 루트가 아닌 경우 특정 group을 Random Access할 수 없습니다. (트리를 타고 내려가며 찾아야 합니다.)
  2. RDBMS 가 존재하는데 트리구조 구현만을 위해 NoSQL을 도입하게 됩니다. group depth가 낮을수록 오버스펙이고, 리소스 낭비입니다.

사용 전략

1. Embedded (중첩)

: 트리구조를 그대로 중첩된 문서로 저장하는 방식

// 스키마

const GroupSchema = new mongoose.Schema({
  uuid: String,
  project_id: String,
  name: String,
  upper_group_id: String,
  sequence: Number,
  tasks: [
    {
      uuid: String,
      name: String,
      project_id: String,
      content: String,
      upper_group_id: String,
      completed: Boolean,
      schedule_use: Boolean,
      start_date: Number,
      end_date: Number,
      time_setup: Boolean,
      sequence: Number
    }
  ],
  groups: Array
});

 

장점

  • 직관적이고 중첩된 데이터를 한꺼번에 가져올 수 있어서 읽기 성능에 좋음
  • 그룹 삭제 단일쿼리로 처리가능

단점

  • 트리 깊이가 깊어질수록, 태스크 탐색의 시간복잡도 증가 (모든 노드를 탐색)
  • 수정 시 문서 전체를 업데이트 해야함
    • 내용이 많은 Task를 매번 덮어쓰는 것에 대한 리소스 낭비

 


2. Parent Reference (부모참조)

: 부모 그룹 참조방식으로 구현. 기존 RDBMS를 사용하는 것으로 가정했을 때 생각했던 방식

// 스키마
const GroupSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  project_id: {
    type: String,
    required: true
  },
  name: {
    type: String,
    required: true
  },
  upper_group_id: {
    type: String,
    default: null
  },
  sequence: {
    type: Number,
    required: true
  }
});


const TaskSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String,
    required: true
  },
  project_id: {
    type: String,
    required: true
  },
  content: {
    type: String,
    required: true
  },
  upper_group_id: {
    type: String,
    required: true
  },
  completed: {
    type: Boolean,
    required: true,
    default: false
  },
  schedule_use: {
    type: Boolean,
    required: true,
    default: false
  },
  start_date: {
    type: Number,
    default: null
  },
  end_date: {
    type: Number,
    default: null
  },
  time_setup: {
    type: Boolean,
    default: false
  },
  sequence: {
    type: Number,
    required: true
  }
});

장점

  • 유연성: 업데이트 시 트리구조 전체를 업데이트 할 필요가 없고, parent_group만 수정하여 구조변경 가능
  • 트리구조를 그대로 저장하지 않기 때문에, 문서 크기에 대한 제한이 없다.

단점

  • 조회시 while문을 사용해 여러번 쿼리를 날려야 한다.
  • 부모-자식 관계의 일관성을 지키기 위해 추가적인 로직이 필요하다.
  • NoSQL의 장점을 살릴 수 없다.

3. Materialized Path (경로 직접저장)

: 각 그룹 또는 태스크가 자신의 상위 경로 전체를 문자열로 저장하는 방식

// 스키마
const GroupSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  project_id: {
    type: String,
    required: true
  },
  name: {
    type: String,
    required: true
  },
  path: {
    type: String,
    required: true
  },
  sequence: {
    type: Number,
    required: true
  }
});

const TaskSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String,
    required: true
  },
  project_id: {
    type: String,
    required: true
  },
  content: {
    type: String,
    required: true
  },
  path: {
    type: String,
    required: true
  },
  completed: {
    type: Boolean,
    required: true,
    default: false
  },
  schedule_use: {
    type: Boolean,
    required: true,
    default: false
  },
  start_date: {
    type: Number,
    default: null
  },
  end_date: {
    type: Number,
    default: null
  },
  time_setup: {
    type: Boolean,
    default: false
  },
  sequence: {
    type: Number,
    required: true
  }
});

장점

  • 경로 기반으로 빠르게 탐색 가능
  • 쿼리가 간단

단점

  • 그룹 이동시 하위 그룹의 경로 모두 수정해야

4. Hybrid (중첩 + 참조)

: 내용이 많아 자주 수정시 리소스가 많이 소모되는 Task는 그룹참조 방식으로따로 저장하며, 내용이 적지만 트리구조인 Group은 한번의 쿼리로 불러올 수 있도록 중첩 방식으로 저장

const GroupSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  project_id: {
    type: String,
    required: true
  },
  name: {
    type: String,
    required: true
  },
  sequence: {
    type: Number,
    required: true
  },
  groups: [this],  // 그룹 자체를 포함하여 재귀적으로 스키마 정의
});


const TaskSchema = new mongoose.Schema({
  uuid: {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String,
    required: true
  },
  project_id: {
    type: String,
    required: true
  },
  content: {
    type: String,
    required: true
  },
  completed: {
    type: Boolean,
    required: true,
    default: false
  },
  schedule_use: {
    type: Boolean,
    required: true,
    default: false
  },
  start_date: {
    type: Number,
    default: null
  },
  end_date: {
    type: Number,
    default: null
  },
  time_setup: {
    type: Boolean,
    default: false
  },
  sequence: {
    type: Number,
    required: true
  }
});

장점

  • 쿼리 한번으로 트리구조의 Group 탐색 가능
  • Task를 따로 저장해서 그룹이동시 그룹 구조만 덮어써서 수정가능, 내용이 많은 Task는 수정x
  • Task 탐색에 시간복잡도 낮춤 (인덱스 사용)
    • 수정, 삭제가 빠르다 (그룹 collection에 영향x)

단점

  • Collection이 2개로 나뉘어져서 관리가 어려워질 수 있다.

전략 탐색을 통해

프로젝트 특성을 고려해 데이터 저장 방식을 고려해봤습니다. NoSQL을 써보기로 한 이상 mongoDB의 이점을 살리기 위해 4번째 방법인 Hybrid로 설계해보려 합니다.

앞서 언급한 MongoDB 도입 단점에서 보았듯이, group의 트리구조를 저장하기위해 새로운 noSQL을 도입한건 오버스펙일 수 있습니다.

하지만 서비스 성능과 프로젝트 특성에 맞는 DB를 고려해보고 전략을 구성해봤다는 점에서 많이 배울 수 있었습니다.

 

중첩 + 참조 방식을 통해, group, task의 내용(이름, 내용, 일정정보 등)은 RDBMS에 저장하고(id로 임의접근 가능), 트리구조만을 저장하는 document를 생성하게 됩니다.

이 구조는 group이 다른 group으로 이동하게 되면 document 전체를 덮어써야 하는 단점이 있지만, 조회성능이 빠르기 때문에 적용했습니다.

업무관리 플랫폼의 특성상 group간 이동보다는 조회 및 내용 수정이 잦을 것이라고 생각했기 때문입니다. 그렇기에 트리구조와 내용은 빠른 조회(O(1))로 가능하도록 구성해봤습니다.