Published on

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

Authors
  • avatar
    Name
    Hyo814
    Twitter

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 PathMP_Node경로 문자열 기반, 읽기 빠름
Nested SetNS_Node좌우 값 기반, 쓰기 시 재정렬 필요
Adjacency ListAL_Nodeparent FK 기반, 깊은 탐색 시 재귀 필요

읽기 빈도가 높고 구조 변경이 적은 분류 체계라면 MP_Node가 적합합니다.

MP_Node가 자동으로 추가하는 필드

MP_Node를 상속하면 다음 필드가 모델에 자동으로 생성됩니다:

필드타입설명
pathCharField전체 경로 (예: "000100010002")
depthPositiveIntegerField노드 깊이 (루트 = 1)
numchildPositiveIntegerField직접 자식 수
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)트리를 딕셔너리 리스트로 직렬화

자동 관리 필드

필드활용 예시
pathpath__startswith로 후손 조회, path[:-steplen]으로 부모 추출
depthdepth=1(루트), depth__lte=3(3단계까지)
numchild자식 유무 확인
steplen경로 조작의 단위 길이

마무리

MP_Node는 계층형 데이터를 다루는 Django 프로젝트에서 강력한 선택지입니다. path 필드 하나로 조상/후손 탐색을 단일 쿼리로 처리할 수 있고, 문자열 조작으로 DB 왕복 없이 트리를 순회할 수 있습니다.

카테고리, 분류 체계, 조직도처럼 읽기 빈도가 높은 트리 구조라면 MP_Node 도입을 적극 추천합니다.