Published on

자기 참조 모델의 계층 데이터 처리 상향식/하향식 접근법 - View와 Serializer에서 구현하기

Introduction

자기 참조 모델(Self-referential Model), 또는 계층적 데이터 모델(Hierarchical Data Model)은 데이터베이스 설계에서 매우 유용한 구조입니다.

데이터의 양이 매우 방대하다면 django-mptt도 좋겠지만, 이렇게 직접 구현하는 방법도 충분히 실용적이고 유연합니다.

대표적으로 조직도, 메뉴 구조, 댓글 스레드, 그리고 이 글에서 다룰 디렉토리 구조처럼 부모-자식 관계가 무한히 확장될 수 있는 데이터를 표현하는 데 사용됩니다.

Django ORM에서는 이런 모델을 ForeignKey('self', ...)와 같이 자신을 참조하는 외래 키로 간단히 정의할 수 있습니다.

하지만 이런 계층 구조의 데이터를 실제로 화면에 보여주기 위해 처리하는 과정은 생각보다 까다로울 수 있습니다.

이 글에서는 실용적인 예제를 위해 복잡한 비즈니스 로직을 단순화하고, 상향식(Bottom-Up)과 하향식(Top-Down) 탐색이라는 두 가지 핵심 아이디어를 중심으로 Django View와 Serializer에서 계층적 데이터를 효율적으로 다루는 방법을 예제 코드와 함께 기록합니다.

계층적 모델 예시 와 API(JSON) 구조

데이터 모델의 구조와 최종적으로 만들고 싶은 JSON 결과물의 형태를 먼저 살펴보겠습니다.

1. Django Models

하나의 Directory가 여러 File을 가질 수 있고, 동시에 다른 Directory를 자식으로 가질 수 있는 계층 구조입니다.

여기에 각 디렉토리의 깊이를 나타내는 depth 필드(level도 유사)를 추가하면 좋습니다.

save() 메소드를 오버라이드하여 부모-자식 관계가 형성될 때 depth가 자동으로 계산되도록 구현합니다.


class Directory(models.Model):
    """ 디렉토리 모델 """
    name = models.CharField(max_length=100)
    # 'self'를 참조하여 자기 참조 관계를 정의합니다.
    parent = models.ForeignKey(
        'self',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        related_name='children'
    )
    # 디렉토리의 깊이(level)를 저장하는 필드
    depth = models.PositiveIntegerField(default=0)

    def __str__(self):
        return self.name

    def save(self, *args, kwargs):
        # 부모가 있으면, 부모의 depth + 1을 자신의 depth로 설정합니다.
        if self.parent:
            self.depth = self.parent.depth + 1
        else:
            # 최상위 디렉토리의 depth는 0으로 설정합니다.
            self.depth = 0
        super().save(*args, kwargs)

class File(models.Model):
    """ 파일 모델 """
    name = models.CharField(max_length=100)
    size = models.PositiveIntegerField()
    # 파일은 반드시 하나의 디렉토리에 속합니다.
    directory = models.ForeignKey(
        Directory,
        on_delete=models.CASCADE,
        related_name='files'
    )

    def __str__(self):
        return self.name

참고로 위 save 메소드는 생성 및 부모 변경 시 depth를 잘 계산하지만, 기존 디렉토리의 부모를 다른 하위 디렉토리로 옮기는 복잡한 경우(순환 구조 방지 등)는 추가적인 처리가 필요할 수 있습니다. 이 글에서는 기본적인 depth 계산법에 집중합니다.

2. API JSON 구조

위 모델을 기반으로, 디렉토리의 계층 구조와 각 디렉토리에 속한 파일 목록을 한눈에 볼 수 있는 아래와 같은 중첩된 JSON을 만드는 것이 최종 목표입니다.

[
  {
    "id": 1,
    "name": "문서",
    "depth": 0,
    "files": [
      {
        "id": 103,
        "name": "요구사항.txt",
        "size": 1024
      }
    ],
    "children": [
      {
        "id": 2,
        "name": "업무",
        "depth": 1,
        "files": [
          {
            "id": 101,
            "name": "보고서.docx",
            "size": 12345
          }
        ],
        "children": []
      },
      {
        "id": 3,
        "name": "개인",
        "depth": 1,
        "files": [
          {
            "id": 102,
            "name": "일기.txt",
            "size": 5678
          }
        ],
        "children": []
      }
    ]
  },
  {
    "id": 4,
    "name": "사진",
    "depth": 0,
    "files": [],
    "children": []
  }
]

1. 상향식(Bottom-Up) 탐색: 자식에서 부모 찾기

특정 조건을 만족하는 자식 노드에서 시작해, 그 노드가 속한 모든 상위 경로를 찾아내는 방식입니다.

개념적으로, 각 디렉토리는 부모를 단 하나만 가질 수 있으므로(1:1 관계), 특정 자식에서 부모를 따라 올라가는 경로는 항상 유일한 경로로 결정됩니다. 이 원리를 이용해 여러 시작점들로부터 루트까지 이어지는 경로들을 모두 찾아내는 것이 상향식 탐색의 핵심입니다.

View 예시: 활성화된 모든 디렉토리 경로 필터링

이 방식은 파일(File)이 하나라도 포함된 디렉토리와 그 상위 디렉토리들만 골라서 화면에 보여주고 싶을 때 효과적입니다.

전략은 먼저 파일이 속한 디렉토리 ID들을 모두 구한 뒤, 각 ID에서부터 부모를 따라 루트까지 거슬러 올라가며 경로상의 모든 디렉토리 ID를 수집하는 것입니다. 이 과정의 효율을 높이기 위해, 모든 디렉토리 정보를 미리 메모리에 올려 ID를 키로 하는 딕셔너리(해시맵)로 만들어두면, 매번 데이터베이스를 조회하지 않고도 빠르게 부모 노드를 찾을 수 있습니다. 이는 N+1 문제를 예방하는 좋은 방법입니다.


class DirectoryViewSet(ModelViewSet):
    """
    활성화된 디렉토리 목록을 계층 구조로 보여주는 ViewSet
    """
    serializer_class = DirectorySerializer

    def get_queryset(self):
        # 최상위 디렉토리(root)를 기본으로 조회합니다.
        return Directory.objects.filter(depth=0)

    def list(self, request, *args, kwargs):
        all_directories = list(Directory.objects.all())
        active_file_directory_ids = set(
            File.objects.filter(is_active=True).values_list('directory_id', flat=True)
        )

        visible_directory_ids = self._get_all_visible_directory_ids(
            all_directories, active_file_directory_ids
        )

        queryset = self.get_queryset().filter(id__in=visible_directory_ids)
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

    def _get_all_visible_directory_ids(self, all_directories, initial_ids):
        """ 상향식 탐색으로 활성화할 모든 디렉토리 ID를 수집합니다. """
        directory_map = {directory.id: directory for directory in all_directories}
        visible_ids = set()

        def collect_ancestors(directory_id):
            if directory_id in visible_ids:
                return
            
            visible_ids.add(directory_id)
            
            parent_id = directory_map.get(directory_id).parent_id
            if parent_id:
                collect_ancestors(parent_id)

        for directory_id in initial_ids:
            collect_ancestors(directory_id)
            
        return visible_ids

실행 결과 예시

위 코드는 파일이 들어있는 디렉토리와 그 상위 경로만 필터링하여 보여줍니다. 예를 들어, 업무2025 디렉토리에만 파일이 있다면, 관련 없는 개인이나 2024 디렉토리는 결과에서 제외됩니다.

[
  {
    "id": 1,
    "name": "문서",
    "depth": 0,
    "children": [
      {
        "id": 2,
        "name": "업무",
        "depth": 1,
        "children": []
      }
    ]
  },
  {
    "id": 4,
    "name": "사진",
    "depth": 0,
    "children": [
      {
        "id": 5,
        "name": "2025",
        "depth": 1,
        "children": []
      }
    ]
  }
]

2. 하향식(Top-Down) 탐색: 부모에서 모든 자식 찾기

특정 디렉토리에서 시작해, 그 아래에 속한 모든 하위 디렉토리를 찾아내는 방식입니다.

상향식과 반대로, 하나의 디렉토리는 여러 자식 디렉토리를 가질 수 있습니다(1:N 관계). 따라서 특정 부모에서 시작해 모든 자식, 그리고 그 자식의 자식들로 여러 갈래로 뻗어 나가며 탐색하는 것이 하향식 탐색의 핵심입니다.

View 예시: 특정 디렉토리와 모든 하위 파일 조회

이 방식은 프론트에서 사용자가 '문서' 디렉토리를 클릭했을 때, 그 하위의 '업무', '개인' 등 모든 관련 파일을 한 번에 보여주고 싶을 때 유용할 것 같습니다.

전략은 먼저 '문서' 디렉토리의 모든 하위 디렉토리 ID들을 재귀적으로 탐색하여 수집하는 것입니다. 여기서도 모든 디렉토리 정보를 미리 조회한 후, 부모 ID를 키로 자식 목록을 값으로 갖는 딕셔너리를 만들어두면 탐색 성능을 크게 향상시킬 수 있습니다.

이렇게 얻은 전체 디렉토리 ID 목록을 사용하여 관련된 파일들을 한 번의 쿼리로 조회합니다.

# views.py
class FileViewSet(ModelViewSet):
    """
    다양한 조건으로 파일을 조회하는 ViewSet
    """
    serializer_class = FileSerializer

    def get_queryset(self):
        try:
            documents_directory = Directory.objects.get(name="문서", parent__isnull=True)
        except Directory.DoesNotExist:
            # 디렉토리가 존재하지 않으면 빈 쿼리셋을 반환합니다.
            return File.objects.none()

        all_directories = list(Directory.objects.all())
        directory_ids = self._get_all_descendant_ids(all_directories, documents_directory.id)

        return File.objects.filter(
            is_active=True,
            directory_id__in=directory_ids
        )

    def _get_all_descendant_ids(self, all_directories, root_id):
        """ 하향식 탐색으로 모든 하위 디렉토리 ID를 수집합니다. """
        children_map = {}
        for directory in all_directories:
            if directory.parent_id:
                children_map.setdefault(directory.parent_id, []).append(directory)

        descendant_ids = set()

        def collect_children(directory_id):
            descendant_ids.add(directory_id)
            
            for child in children_map.get(directory_id, []):
                collect_children(child.id)

        collect_children(root_id)
        return descendant_ids

실행 결과 예시

'문서' 디렉토리 및 그 모든 하위 디렉토리에 포함된 파일 목록을 조회한 결과입니다. 각 파일의 전체 경로(directory_path)가 함께 제공되는 것을 볼 수 있습니다.

[
  {
    "id": 101,
    "name": "보고서.docx",
    "size": 12345,
    "directory_path": [
      {"id": 1, "name": "문서", "depth": 0},
      {"id": 2, "name": "업무", "depth": 1}
    ]
  },
  {
    "id": 102,
    "name": "일기.txt",
    "size": 5678,
    "directory_path": [
      {"id": 1, "name": "문서", "depth": 0},
      {"id": 3, "name": "개인", "depth": 1}
    ]
  }
]

3. Serializer 활용: 전체 경로(Breadcrumb) 표현

상향식 탐색은 Serializer에서도 유용하게 쓰입니다. 특정 파일이 어떤 경로에 속해있는지 전체 경로, 즉 브레드크럼(Breadcrumb)을 표현하는 것입니다.

Breadcrumb(브레드크럼)은 '빵 부스러기'에서 유래한 용어로, 흔적을 남기는 것을 묘사하는 용어 입니다. 서비스 내에서 자신의 위치를 파악하기 좋게 상위 경로등이 표시되는 UI 요소를 의미합니다.

SerializerMethodField를 사용하여 directory_path 필드를 추가하고, 이 메소드 안에서 while 루프를 통해 현재 디렉토리에서 시작하여 parent를 따라 루트에 도달할 때까지 모든 상위 디렉토리를 수집합니다.

즉, 마지막에 리스트를 뒤집어주면 /최상위/중간/하위 순서의 직관적인 경로가 완성됩니다.


class SimpleDirectorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Directory
        fields = ['id', 'name', 'depth']

class FileSerializer(serializers.ModelSerializer):
    directory_path = serializers.SerializerMethodField()

    class Meta:
        model = File
        fields = ['id', 'name', 'size', 'directory_path']

    def get_directory_path(self, obj):
        """ 파일의 디렉토리부터 최상위 부모까지의 경로(breadcrumb)를 생성합니다. """
        if not obj.directory:
            return []

        path = []
        current_directory = obj.directory
        
        while current_directory:
            serializer = SimpleDirectorySerializer(current_directory)
            path.append(serializer.data)
            current_directory = current_directory.parent
            
        return list(reversed(path))

실행 결과 예시

FileSerializer를 통해 단일 파일 객체를 직렬화한 결과입니다. directory_path 필드에 해당 파일이 속한 전체 디렉토리 경로가 배열 형태로 포함된 것을 확인할 수 있습니다.

{
  "id": 201,
  "name": "업무파일.xlsx",
  "size": 54321,
  "directory_path": [
    { "id": 1, "name": "문서", "depth": 0 },
    { "id": 2, "name": "업무", "depth": 1 },
    { "id": 7, "name": "2025년", "depth": 2 }
  ]
}

계층 구조를 다루는 핵심적인 탐색 방법, 상향식과 하향식에 대해 알아보았습니다.

  • 이 글에서의 상향식 탐색은 특정 노드에서 출발해 유일한 부모를 따라 올라가는 1:1 추적 방식입니다.
  • 이 글에서의 하향식 탐색은 특정 노드에서 출발해 여러 자식을 따라 내려가는 1:N 확장 방식입니다.

이러한 기법들을 계층적 데이터를 다루는 데 동일하게 적용할 수 있습니다.

특히, 재귀적으로 상위나 하위 항목을 조회할 때 발생하기 쉬운 N+1 문제를 예방하기 위해, 이 글의 예제처럼 전체 계층 정보를 메모리에 미리 올려놓고 파이썬으로 처리하는 전략은 매우 유용합니다.

예전에 대댓글을 유사한 방식으로 처리한 경험이 있는데, 최근에 유사한 작업을 다양한 방식으로 진행하며 기록합니다.

아마도 관련 주제로는 이번이 글이 마지막일수도 있을 것 같습니다