- Published on
django-treebeard MP_Node로 트리 구조 데이터 다루기
- Authors

- Name
- Hyo814
django-treebeard MP_Node로 트리 구조 데이터 다루기
Django에서 카테고리, OID, 분류체계 같은 계층형(트리) 데이터를 다루다 보면 재귀 쿼리나 N+1 문제에 자주 부딪힙니다. django-treebeard는 이런 문제를 세 가지 알고리즘으로 해결해줍니다. 이 글에서는 그 중 Materialized Path(MP_Node) 방식을 중심으로 실무 적용 패턴을 정리합니다.
1. Materialized Path란?
Materialized Path는 트리의 각 노드에 루트부터 자신까지의 경로를 문자열로 저장하는 방식입니다.
예를 들어 아래 구조라면:
루트 (depth=1)
└── 부모 (depth=2)
└── 나 (depth=3)
나 노드의 path 값은 "000100020003" 처럼 저장됩니다. steplen=4이면 4글자씩 잘라 각 계층을 나타냅니다.
django-treebeard의 세 가지 방식 비교
| 방식 | 클래스 | 특징 |
|---|---|---|
| Materialized Path | MP_Node | 경로 문자열 기반, 읽기 빠름 |
| Nested Set | NS_Node | 좌우 값 기반, 쓰기 시 재정렬 필요 |
| Adjacency List | AL_Node | parent FK 기반, 깊은 탐색 시 재귀 필요 |
읽기 빈도가 높고 구조 변경이 적은 분류 체계라면 MP_Node가 적합합니다.
MP_Node가 자동으로 추가하는 필드
MP_Node를 상속하면 다음 필드가 모델에 자동으로 생성됩니다:
| 필드 | 타입 | 설명 |
|---|---|---|
path | CharField | 전체 경로 (예: "000100010002") |
depth | PositiveIntegerField | 노드 깊이 (루트 = 1) |
numchild | PositiveIntegerField | 직접 자식 수 |
steplen | 클래스 속성 | 경로 한 단계의 문자 수 (기본값: 4) |
2. 기본 모델 정의
from treebeard.mp_tree import MP_Node
from django.db import models
class Category(MP_Node):
name = models.CharField(max_length=255)
node_order_by = ['name'] # 자식 노드 정렬 기준
class Meta:
db_table = 'category'
node_order_by를 지정하면 add_child() 호출 시 자동으로 정렬된 순서로 삽입됩니다.
3. 노드 생성 및 조회 메서드
생성
# 루트 노드 생성
root = Category.add_root(name='전체')
# 자식 노드 생성
child = root.add_child(name='자연과학')
grandchild = child.add_child(name='수학')
조회
# 모든 루트 노드
Category.get_root_nodes()
# 부모 노드
node.get_parent()
# 직접 자식 노드
node.get_children()
# 모든 조상 (루트 → 부모 순)
node.get_ancestors()
# 모든 후손
node.get_descendants()
# 트리 전체를 딕셔너리 리스트로 내보내기
Category.dump_bulk()
4. 실무 쿼리 최적화 패턴
MP_Node의 핵심 장점은 path 필드를 활용해 추가 쿼리 없이 트리를 탐색할 수 있다는 점입니다.
패턴 ①: path__startswith로 후손 노드 단일 쿼리 조회
# ❌ 비효율 — 노드마다 get_descendants() 호출 (N+1)
for concept in concepts:
descendants = concept.get_descendants() # 쿼리 N번
# ✅ 효율 — path prefix + depth 조건으로 단일 쿼리
from django.db.models import Q
path_conditions = Q()
for concept in concepts:
path_conditions |= Q(path__startswith=concept.path, depth__gt=concept.depth)
descendants = Category.objects.filter(path_conditions)
패턴 ②: path[:-steplen]으로 부모 경로 추출 (DB 쿼리 없음)
# ❌ 비효율 — DB SELECT 발생
parent = node.get_parent()
# ✅ 효율 — 문자열 조작으로 부모 경로 계산
parent_path = node.path[:-node.steplen] # "000100010002" → "00010001"
parent = node_dict[parent_path] # 메모리 딕셔너리 조회
패턴 ③: 경로 캐시로 조상 계산
@staticmethod
def get_ancestors_from_cache(node, path_to_node):
"""DB 쿼리 없이 path 문자열에서 모든 조상을 추출"""
ancestors = []
steplen = node.steplen # 보통 4
for i in range(steplen, len(node.path), steplen):
ancestor_path = node.path[:i]
if ancestor_path in path_to_node:
ancestors.append(path_to_node[ancestor_path])
return ancestors
동작 원리:
노드 path = "000100020003" (depth=3, steplen=4)
i=4 → path[:4] = "0001" → 루트 노드 (depth=1)
i=8 → path[:8] = "00010002" → 부모 노드 (depth=2)
패턴 ④: depth 필터로 레벨별 조회
# 루트 노드만 조회
Category.objects.filter(depth=1)
# 3단계까지만 조회
Category.objects.filter(depth__lte=3).order_by('path')
패턴 ⑤: 트리 기반 이름 중복 검사
# 같은 부모 아래에서만 중복 검사
if parent is not None:
queryset = queryset.filter(
path__startswith=parent.path,
depth=parent.depth + 1
)
else:
queryset = queryset.filter(depth=1) # 루트 레벨
5. DRF Serializer와 함께 사용하기
MP_Node 모델에서 add_root() / add_child()는 일반적인 Model.objects.create()로는 호출할 수 없기 때문에, Serializer의 create()를 커스터마이징해야 합니다.
from rest_framework import serializers
from django.db import transaction
class TreeModelSerializer(serializers.ModelSerializer):
@transaction.atomic
def create(self, validated_data):
parent = validated_data.pop('parent', None)
model = self.Meta.model
if parent is None:
return model.add_root(**validated_data)
else:
return parent.add_child(**validated_data)
@transaction.atomic
def update(self, instance, validated_data):
if 'parent' in validated_data:
new_parent = validated_data.get('parent')
current_parent = instance.get_parent() if instance.depth > 1 else None
if current_parent != new_parent:
raise serializers.ValidationError({
'parent': '노드 이동은 별도 엔드포인트를 사용하세요.'
})
validated_data.pop('parent', None)
return super().update(instance, validated_data)
설계 포인트: 노드 이동 시
path를 재계산해야 하므로 일반 update에서는 막고, 전용 엔드포인트로 분리하는 것이 안전합니다.
6. 자주 쓰는 메서드 요약
생성
| 메서드 | 설명 |
|---|---|
Model.add_root(**kwargs) | 루트 노드 생성 |
node.add_child(**kwargs) | 자식 노드 생성 |
조회
| 메서드 | 반환 | 설명 |
|---|---|---|
Model.get_root_nodes() | QuerySet | 모든 루트 노드 |
node.get_parent() | Node/None | 부모 노드 |
node.get_children() | QuerySet | 직접 자식 |
node.get_ancestors() | QuerySet | 모든 조상 |
node.get_descendants() | QuerySet | 모든 후손 |
내보내기
| 메서드 | 설명 |
|---|---|
Model.dump_bulk(parent=None) | 트리를 딕셔너리 리스트로 직렬화 |
자동 관리 필드
| 필드 | 활용 예시 |
|---|---|
path | path__startswith로 후손 조회, path[:-steplen]으로 부모 추출 |
depth | depth=1(루트), depth__lte=3(3단계까지) |
numchild | 자식 유무 확인 |
steplen | 경로 조작의 단위 길이 |
마무리
MP_Node는 계층형 데이터를 다루는 Django 프로젝트에서 강력한 선택지입니다. path 필드 하나로 조상/후손 탐색을 단일 쿼리로 처리할 수 있고, 문자열 조작으로 DB 왕복 없이 트리를 순회할 수 있습니다.
카테고리, 분류 체계, 조직도처럼 읽기 빈도가 높은 트리 구조라면 MP_Node 도입을 적극 추천합니다.