발행일

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

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)자식 노드 생성
node.move(target, pos)노드 이동 — 자신+후손 전체의 path 재계산 (7절 참고)

조회

메서드반환설명
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경로 조작의 단위 길이

7. 쓰면서 알게 된 조건들

위 패턴들은 잘 동작하는데, 각각 전제가 붙어 있습니다. 그 전제를 안 적으면 다른 상황에서 그대로 쓰다 물립니다.

① 패턴 ①은 노드 수에 비례해 SQL이 커진다

path_conditions = Q()
for concept in concepts:
    path_conditions |= Q(path__startswith=concept.path, depth__gt=concept.depth)

N+1을 없앤 건 맞는데, concepts가 200개면 LIKE 조건 200개가 OR로 이어진 쿼리 하나가 됩니다. 쿼리 수는 1이지만 그 한 방이 무거워요. 쿼리 수만 세면 최적화된 것처럼 보이는 게 함정입니다.

노드 집합이 크면 차라리 공통 조상 하나로 범위를 좁혀 한 번에 긁고 메모리에서 나누는 쪽이 낫습니다.

# 대상들이 한 서브트리 안에 있다면
subtree = Category.objects.filter(path__startswith=root.path).order_by('path')
# 메모리에서 path prefix로 그룹핑

"N+1을 없앴다"와 "빨라졌다"는 다른 얘기라는 걸 이때 배웠어요.

② 패턴 ②·③의 "DB 쿼리 없음"에는 조건이 있다

node_dict[parent_path], path_to_node[ancestor_path]이 딕셔너리를 만들려면 노드를 미리 전부 읽어와야 합니다. 즉 정확히는 "쿼리 없음" 이 아니라 "쿼리 1번 + 전체를 메모리에" 예요.

상황유리한 쪽
트리 전체를 어차피 그리는 화면딕셔너리 캐시 (패턴 ②·③)
노드 하나의 부모만 필요get_parent() 한 번이 더 쌈
노드 수가 메모리에 안 들어감캐시 방식 불가

트리 화면처럼 어차피 전량을 읽는 경우에만 이득이라는 조건이 빠져 있었습니다.

③ 가장 위험한 연산이 이 글에 없다

5절에서 노드 이동을 이렇게 막았습니다.

노드 이동은 별도 엔드포인트를 사용하세요.

맞는 결정인데, 그 별도 엔드포인트가 어떻게 생겼는지는 안 적혀 있습니다. 6절 요약표에도 이동 메서드가 빠져 있었고요 — 이건 다시 보다가 발견해서 표에 추가해뒀습니다.

MP_Node에서 노드를 옮기면 자신뿐 아니라 모든 후손의 path가 다시 계산됩니다. 조상들의 numchild도 바뀌고요. treebeard는 node.move(target, pos)로 이걸 처리해주지만, 이건 한 번에 수백 행을 갱신하는 연산이라 트랜잭션과 동시성 고려가 필요합니다.

동시성이 실제로 깨지면 — 예를 들어 두 요청이 같은 부모에 동시에 add_child()를 부르면 path·numchild가 어긋날 수 있습니다 — 손으로 복구하는 건 사실상 불가능합니다. treebeard가 Model.find_problems()Model.fix_tree()라는 점검·복구 도구를 주는 이유가 그거예요. 트리가 이상해졌을 때의 유일한 복구 수단이니, 패턴보다 이 둘의 존재를 먼저 알아두는 게 좋습니다.

연산영향 범위
add_child()자신 + 부모의 numchild
move()자신 + 전체 후손의 path + 양쪽 부모의 numchild
delete()자신 + 후손 전체

읽기가 빠른 대가로 쓰기가 비싼 게 Materialized Path의 본질인데, 이 글은 읽기 패턴만 다뤘습니다. 1절 비교표에서 "Nested Set은 쓰기 시 재정렬 필요" 를 단점으로 적어놨지만, MP도 이동에서는 재계산이 필요하다는 건 안 적었어요.

steplen은 자식 수 상한이다

steplen=4는 한 부모 아래 자식 수의 상한을 정합니다. 기본 alphabet(36자) 기준 36⁴ ≈ 168만이라 실무에선 넉넉하지만, path 필드 길이(기본 255자)가 최대 깊이를 결정한다는 것도 같이 알아둘 값이에요. 255 ÷ 4 ≈ 63단계입니다.

분류체계처럼 깊이가 얕은 데이터에서는 신경 쓸 일이 없지만, "자동으로 관리되는 필드"라고 해서 무한하지는 않습니다.

path__startswith가 인덱스를 타는지는 확인하지 않았다

이 글의 모든 조회 패턴이 path__startswith(SQL로는 LIKE 'prefix%')에 걸려 있는데, 정작 그 조건이 인덱스를 타는지 한 번도 EXPLAIN으로 확인하지 않았습니다. treebeard가 path에 unique 인덱스를 걸어주긴 하지만, PostgreSQL에서 C 로케일이 아닌 DB라면 일반 btree 인덱스는 LIKE 전방일치에 쓰이지 않아요 — varchar_pattern_ops 연산자 클래스를 지정한 인덱스가 따로 필요합니다. 안 타고 있다면 ①에서 걱정한 "OR 200개짜리 무거운 쿼리"는 사실 순차 스캔 200번이고, 이 글의 성능 전제가 통째로 흔들립니다. 이 패턴을 가져다 쓰실 거면 EXPLAIN부터 돌려보세요. 저도 해야 합니다.


실제 적용

이 내용을 실제로 쓴 화면은 분류체계·메타클래스 트리입니다. 두 트리의 공통 로직을 정리한 기록은 헬퍼 4종으로 추출한 회고에, 목록 쿼리를 줄인 기록은 메타클래스 쿼리 최적화에 따로 적었습니다.


마무리

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

다만 그 장점은 읽기에 한정됩니다. 노드를 자주 옮기거나 구조가 계속 바뀌는 데이터라면 이동 비용을 먼저 재봐야 해요. 카테고리, 분류 체계, 조직도처럼 한 번 만들고 주로 읽는 트리 구조라면 MP_Node 도입을 고려해볼 만합니다.