- 발행일
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 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) | 자식 노드 생성 |
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) | 트리를 딕셔너리 리스트로 직렬화 |
자동 관리 필드
| 필드 | 활용 예시 |
|---|---|
path | path__startswith로 후손 조회, path[:-steplen]으로 부모 추출 |
depth | depth=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종으로 추출한 회고2026-05-16분류체계와 메타클래스 트리 공통 로직을 헬퍼 4종으로 추출한 회고두 페이지가 따로 들고 있던 jstree 옵션과 이벤트 핸들러를 비교해 차이가 거의 없음을 확인하고, 공통 헬퍼 4종으로 추출한 리팩토링 회고입니다.에, 목록 쿼리를 줄인 기록은 메타클래스 쿼리 최적화2026-05-26메타클래스 리스트가 점점 느려진 이유 — N+1 잡고 prefetch 정리한 회고항목 수가 늘면서 점점 느려지던 메타클래스 리스트 페이지의 쿼리를 분석하고, select_related/prefetch_related로 정리하면서 의식한 점들을 기록합니다.에 따로 적었습니다.
마무리
MP_Node는 계층형 데이터를 다루는 Django 프로젝트에서 쓸 만한 선택지입니다. path 필드 하나로 조상/후손 탐색을 단일 쿼리로 처리하고 문자열 조작만으로 DB 왕복 없이 트리를 순회합니다.
다만 그 장점은 읽기에 한정됩니다. 노드를 자주 옮기거나 구조가 계속 바뀌는 데이터라면 이동 비용을 먼저 재봐야 해요. 카테고리, 분류 체계, 조직도처럼 한 번 만들고 주로 읽는 트리 구조라면 MP_Node 도입을 고려해볼 만합니다.