트리는 부모와 자식으로 데이터를 연결하는 구조가 아니다

vx_developer·어제

코테보다가

목록 보기
28/29
post-thumbnail

트리를 처음 배우면 하나의 루트 노드에서 시작해 부모와 자식 관계로 데이터가 연결되는 자료구조라고 설명한다.

const categoryTree = {
  name: "전체 상품",
  children: [
    {
      name: "의류",
      children: [
        { name: "상의", children: [] },
        { name: "하의", children: [] }
      ]
    }
  ]
};

전체 상품은 루트이고, 의류는 그 자식이며, 상의와 하의는 의류의 자식이다. 하나의 노드에서 아래로 내려가며 계층을 탐색할 수 있다는 설명은 트리의 기본 구조를 이해하는 데 유용하다.

하지만 실제 쇼핑몰에서 카테고리를 만든다면 객체 안에 children을 넣는 것만으로는 충분하지 않다. 카테고리가 수천 개라면 전체 트리를 매번 불러와야 할까? 운영자가 상위 카테고리를 이동하면 하위 카테고리의 경로는 어떻게 바뀔까? 존재하지 않는 카테고리를 부모로 지정하거나 자신의 하위 카테고리 아래로 이동시키면 어떻게 막아야 할까? 하나의 상품이 여러 카테고리에 속한다면 카테고리와 상품 전체를 하나의 트리로 표현할 수 있을까?

실제 서비스에서 트리는 부모와 자식을 저장하는 모양이 아니라, 데이터가 속한 범위를 계층으로 나누고 필요한 범위만 단계적으로 탐색하기 위한 구조다.

카테고리 전체를 가져온 뒤 화면에서 찾으면 충분할까?

크리스가 운영하는 쇼핑몰에 처음에는 다음과 같은 카테고리만 있다고 해보자.

전체 상품
├─ 의류
│  ├─ 상의
│  └─ 하의
└─ 전자기기
   ├─ 노트북
   └─ 스마트폰

카테고리가 여섯 개뿐이라면 서버에서 모든 카테고리를 가져와 화면에서 트리를 만드는 방식도 충분히 동작한다.

async function getCategoryPage(categoryId: string) {
  const categories = await categoryRepository.findAll();

  const category = categories.find(
    (item) => item.id === categoryId
  );

  const children = categories.filter(
    (item) => item.parentId === categoryId
  );

  return {
    category,
    children
  };
}

현재 카테고리와 자식 카테고리를 찾기 위해 모든 카테고리를 조회한다. 데이터가 작고 요청이 드물다면 이해하기 쉽고 구현 비용도 낮다.

하지만 카테고리가 50,000개로 늘어나도 사용자가 보고 싶은 것은 보통 전체 트리가 아니다. 사용자가 의류 페이지에 들어왔다면 현재 카테고리와 바로 아래 카테고리, 화면에 표시할 상품만 필요할 가능성이 높다.

async function getCategoryPage(categoryId: string) {
  const [category, children] = await Promise.all([
    categoryRepository.findById(categoryId),
    categoryRepository.findChildren(categoryId)
  ]);

  if (!category || !category.isVisible) {
    throw new Error("Category not found");
  }

  return {
    category,
    children
  };
}

이제 필요한 범위만 조회한다. parent_id에 인덱스가 있다면 특정 부모의 자식들을 전체 카테고리에서 반복해서 찾지 않아도 된다.

CREATE INDEX idx_categories_parent_id
ON categories(parent_id);

트리의 장점은 모든 데이터를 한 번에 보여주는 데 있지 않다. 사용자가 선택한 노드를 기준으로 탐색 범위를 그 노드의 하위 영역으로 좁힐 수 있다는 데 있다.

현실의 카테고리 탐색은 다음과 같이 코드의 입력·상태·출력으로 변환된다.

입력
사용자가 선택한 카테고리 ID

상태
카테고리 ID, 부모 ID, 표시 여부, 정렬 순서

출력
현재 카테고리와 탐색 가능한 하위 카테고리

사용자는 카테고리 이름을 클릭하지만 서버는 이름만으로 탐색하지 않는다. 검증된 카테고리 ID를 기준으로 현재 노드를 찾고, 부모와 자식 관계를 통해 다음 탐색 범위를 결정한다.

계층을 화면 모양 그대로 저장해야 할까?

메모리에서는 children 배열을 가진 중첩 객체가 자연스럽다.

type CategoryNode = {
  id: string;
  name: string;
  children: CategoryNode[];
};

하지만 관계형 데이터베이스에 이 객체를 그대로 저장하기는 어렵다. 일반적으로는 각 카테고리가 자신의 부모를 가리키도록 저장한다.

CREATE TABLE categories (
  id UUID PRIMARY KEY,
  store_id UUID NOT NULL,
  parent_id UUID NULL REFERENCES categories(id),
  name VARCHAR(100) NOT NULL,
  is_visible BOOLEAN NOT NULL DEFAULT TRUE,
  sort_order INTEGER NOT NULL DEFAULT 0
);

각 행은 하나의 카테고리를 나타내고, parent_id는 바로 위의 부모 카테고리를 가리킨다. 루트 카테고리는 부모가 없으므로 NULL을 가진다.

id           name        parent_id
category-1   전체 상품    null
category-2   의류         category-1
category-3   상의         category-2
category-4   하의         category-2

이 저장 방식은 인접 리스트라고 부른다. 이름을 몰라도 구조는 이해할 수 있다. 각 노드가 직접 연결된 부모 하나만 저장하는 방식이다.

카테고리 하나를 추가하거나 다른 부모 아래로 이동하는 작업은 비교적 단순하다.

await categoryRepository.create({
  storeId: "store-10",
  parentId: "category-2",
  name: "아우터",
  sortOrder: 30
});

이 코드는 의류 아래에 아우터를 추가한다. 데이터베이스에는 전체 트리를 다시 저장하지 않고 새 카테고리와 부모의 관계만 기록한다.

반면 상의의 모든 하위 카테고리를 가져오려면 한 번의 단순 조회만으로는 부족할 수 있다. 자식의 자식까지 계속 따라가야 하기 때문이다.

WITH RECURSIVE category_tree AS (
  SELECT id, parent_id, name, 0 AS depth
  FROM categories
  WHERE id = $1

  UNION ALL

  SELECT child.id,
         child.parent_id,
         child.name,
         tree.depth + 1
  FROM categories child
  JOIN category_tree tree
    ON child.parent_id = tree.id
)
SELECT *
FROM category_tree;

이 쿼리는 선택한 카테고리에서 시작해 그 아래에 연결된 카테고리를 단계적으로 찾는다. depth는 시작 카테고리에서 몇 단계 아래에 있는지도 나타낸다.

메모리의 트리와 데이터베이스의 저장 구조는 같을 필요가 없다. 데이터베이스에서는 변경하기 쉬운 관계를 저장하고, API 응답에서는 화면이 사용하기 쉬운 중첩 구조로 변환할 수 있다.

type CategoryRow = {
  id: string;
  parentId: string | null;
  name: string;
};

function buildCategoryTree(
  rows: CategoryRow[],
  rootId: string
): CategoryNode | null {
  const nodes = new Map<string, CategoryNode>();

  for (const row of rows) {
    nodes.set(row.id, {
      id: row.id,
      name: row.name,
      children: []
    });
  }

  for (const row of rows) {
    if (!row.parentId) {
      continue;
    }

    nodes
      .get(row.parentId)
      ?.children.push(nodes.get(row.id)!);
  }

  return nodes.get(rootId) ?? null;
}

데이터베이스의 행이 원본 데이터라면 중첩된 카테고리 객체는 특정 응답을 위해 만들어진 계산 데이터다. 계산 데이터가 오래되었을 때 다시 만들 수 있도록 둘의 책임을 구분해야 한다.

트리는 어떻게 탐색 범위를 줄이는가?

사용자가 쇼핑몰에서 의류 → 남성 의류 → 아우터로 이동했다고 해보자. 사용자가 의류를 선택한 뒤에도 전자기기, 주방용품, 반려동물용품 아래의 모든 카테고리를 계속 검사할 필요는 없다.

async function openCategory(categoryId: string) {
  const children =
    await categoryRepository.findVisibleChildren(categoryId);

  return children.sort(
    (a, b) => a.sortOrder - b.sortOrder
  );
}

현재 노드의 자식만 가져오면 다음에 선택할 수 있는 범위가 결정된다. 의류 아래를 탐색하는 동안 전혀 관련 없는 전자기기의 하위 구조는 조회하지 않아도 된다.

이것은 트리가 항상 검색을 빠르게 만든다는 뜻은 아니다. 카테고리 이름을 아무 조건 없이 전체 데이터에서 검색한다면 여전히 많은 노드를 확인할 수 있다.

const result = categories.filter(
  (category) => category.name.includes(keyword)
);

트리의 탐색 이점을 얻으려면 검색을 시작할 범위가 정해져 있어야 한다.

async function searchWithinCategory(
  rootCategoryId: string,
  keyword: string
) {
  const descendantIds =
    await categoryRepository.findDescendantIds(
      rootCategoryId
    );

  return categoryRepository.searchByName({
    categoryIds: descendantIds,
    keyword
  });
}

여성 의류 안에서만 재킷을 검색한다면 다른 카테고리 영역을 제외할 수 있다. 트리는 데이터가 속한 범위를 표현하고, 알고리즘은 그 범위 밖의 후보를 탐색 대상에서 제거한다.

다만 실제 상품 검색에서는 카테고리 이름 검색, 상품명 검색, 가격 필터와 재고 상태가 함께 사용된다. 이때 카테고리 트리는 검색 범위를 결정하는 조건 중 하나이고, 데이터베이스 인덱스나 검색 엔진은 그 범위에서 실제 상품을 빠르게 찾는 책임을 가진다.

자료구조 하나가 검색의 모든 문제를 해결하는 것은 아니다. 트리는 무엇을 같은 영역으로 묶고 어디서 탐색을 시작할지를 제공한다.

부모 ID를 받으면 그대로 저장해도 될까?

운영자 화면에서 크리스가 새 카테고리를 만들며 부모 카테고리를 선택한다고 해보자.

async function createCategory(
  input: CreateCategoryInput
) {
  return categoryRepository.create({
    storeId: input.storeId,
    parentId: input.parentId,
    name: input.name
  });
}

이 코드는 요청에 들어온 storeId와 parentId를 그대로 신뢰한다. 공격자가 다른 쇼핑몰의 카테고리 ID를 보내면 자신이 관리하지 않는 트리 아래에 카테고리를 만들 수 있다. 존재하지 않거나 삭제된 카테고리를 부모로 지정할 수도 있다.

부모와 자식 관계도 외부 입력이므로 저장 전에 검증해야 한다.

async function createCategory(
  actorId: string,
  input: CreateCategoryInput
) {
  const store = await storeRepository.findManagedStore(
    actorId,
    input.storeId
  );

  if (!store) {
    throw new Error("Store not found");
  }

  const parent = input.parentId
    ? await categoryRepository.findById(input.parentId)
    : null;

  if (
    parent &&
    (
      parent.storeId !== store.id ||
      parent.isDeleted
    )
  ) {
    throw new Error("Invalid parent category");
  }

  const name = input.name.trim();

  if (name.length === 0 || name.length > 100) {
    throw new Error("Invalid category name");
  }

  return categoryRepository.create({
    storeId: store.id,
    parentId: parent?.id ?? null,
    name,
    isVisible: false
  });
}

서버는 로그인한 운영자가 해당 쇼핑몰을 관리할 권한이 있는지 확인한다. 부모 카테고리가 같은 쇼핑몰에 속하는지, 삭제되지 않았는지도 검증한다. 새 카테고리는 검토 전까지 숨김 상태로 생성할 수 있다.

트리 구조는 단순한 UI 배치가 아니다. 부모를 잘못 연결하면 데이터의 소속 범위와 접근 권한까지 달라질 수 있다. 따라서 관계를 만드는 API가 트리의 규칙을 지킬 책임을 가져야 한다.

카테고리를 자신의 하위로 이동시키면 어떻게 될까?

크리스가 의류 카테고리를 아우터 아래로 이동한다고 해보자. 아우터가 이미 의류의 하위 카테고리라면 다음과 같은 관계가 만들어진다.

의류
└─ 아우터
   └─ 의류
      └─ 아우터
         └─ ...

트리에서는 한 노드에서 아래로 내려가 다시 같은 노드로 돌아올 수 없어야 한다. 이런 순환 관계가 생기면 하위 카테고리를 찾는 탐색이 끝나지 않거나 같은 데이터를 반복해서 처리할 수 있다.

단순히 부모 ID만 변경하면 이 문제를 막을 수 없다.

// Bad: 새로운 부모가 자신의 하위인지 확인하지 않는다.
async function moveCategory(
  categoryId: string,
  newParentId: string
) {
  await categoryRepository.updateParent(
    categoryId,
    newParentId
  );
}

이동하기 전에는 다음 조건을 확인해야 한다.

  • 자기 자신을 부모로 선택하지 않았는가?
  • 새 부모가 같은 쇼핑몰에 속하는가?
  • 새 부모가 이동할 카테고리의 하위 노드가 아닌가?
  • 새 위치에서 허용된 최대 깊이를 넘지 않는가?
  • 이동할 권한이 있는 운영자인가?
async function moveCategory(
  actorId: string,
  categoryId: string,
  newParentId: string
) {
  const category =
    await categoryRepository.findManageableBy(
      actorId,
      categoryId
    );

  const newParent =
    await categoryRepository.findById(newParentId);

  if (!category || !newParent) {
    throw new Error("Category not found");
  }

  if (category.id === newParent.id) {
    throw new Error(
      "A category cannot be its own parent"
    );
  }

  if (category.storeId !== newParent.storeId) {
    throw new Error(
      "Categories must belong to the same store"
    );
  }

  const descendantIds =
    await categoryRepository.findDescendantIds(
      category.id
    );

  if (descendantIds.includes(newParent.id)) {
    throw new Error(
      "A category cannot move under its descendant"
    );
  }

  await categoryRepository.updateParent(
    category.id,
    newParent.id
  );
}

이 코드는 새 부모가 현재 카테고리의 하위에 포함되는지 확인한다. 포함된다면 이동을 거부하여 순환 관계를 막는다.

다만 검증과 저장 사이에 다른 요청이 동시에 트리를 변경할 수 있다. 두 운영자가 같은 영역을 동시에 이동한다면 각각의 검증 시점에는 문제가 없었지만 두 변경이 합쳐진 뒤 순환이 만들어질 가능성을 고려해야 한다.

중요한 카테고리 이동은 트랜잭션 안에서 처리하고, 관련 노드를 잠그거나 버전 값을 확인하는 방식으로 동시 변경을 통제할 수 있다.

await database.transaction(async (tx) => {
  const category = await tx.categories.findForUpdate(
    categoryId
  );

  const newParent = await tx.categories.findForUpdate(
    newParentId
  );

  await assertValidCategoryMove(
    tx,
    category,
    newParent
  );

  await tx.categories.updateParent(
    category.id,
    newParent.id
  );
});

트리는 연결 관계만 표현하지만 서비스는 그 연결이 항상 유효하도록 유지해야 한다. 순환 방지와 동시성 제어는 트리 객체가 자동으로 해결해 주는 기능이 아니라 변경 로직이 책임져야 하는 비즈니스 규칙이다.

카테고리 이동은 부모 하나만 바꾸는 일일까?

인접 리스트 방식에서는 parent_id 하나를 변경하면 카테고리 이동 자체는 완료된다.

await categoryRepository.updateParent(
  "outer-category",
  "sale-category"
);

하지만 쇼핑몰이 탐색 속도를 높이기 위해 각 카테고리의 전체 경로를 함께 저장하고 있다면 이야기가 달라진다.

/의류/남성 의류/아우터

아우터를 시즌 할인 아래로 이동하면 자신의 경로뿐 아니라 모든 하위 카테고리의 경로도 바뀐다.

변경 전
/의류/남성 의류/아우터
/의류/남성 의류/아우터/재킷

변경 후
/시즌 할인/아우터
/시즌 할인/아우터/재킷

경로를 저장하면 특정 영역의 하위 데이터를 빠르게 찾을 수 있지만, 이동할 때 수정해야 할 데이터가 늘어난다.

type Category = {
  id: string;
  parentId: string | null;
  path: string;
};

여기서 parentId와 path를 아무 규칙 없이 함께 수정하면 서로 다른 구조를 나타낼 수 있다. 어느 값을 원본으로 볼 것인지 정해야 한다.

예를 들어 parentId 관계를 Source of Truth로 정하고 path는 조회 성능을 위한 계산 데이터로 관리할 수 있다. 이동할 때 트랜잭션 안에서 하위 경로를 함께 갱신하거나, 변경 이벤트를 발행한 뒤 별도 작업으로 경로를 다시 계산할 수 있다.

별도 작업으로 갱신한다면 잠시 동안 오래된 경로가 노출될 수 있다는 점도 받아들여야 한다. 즉시 일관된 경로가 필요한 주문·권한 로직과 화면 탐색을 빠르게 만드는 캐시에는 서로 다른 일관성 기준이 필요할 수 있다.

트리 저장 방법을 선택할 때는 조회 속도만 봐서는 안 된다.

저장 방식쉬운 작업비용이 커지는 작업
부모 ID 저장노드 추가, 부모 변경전체 조상·하위 범위 조회
전체 경로 저장조상 경로 확인, 하위 범위 검색카테고리 이동과 경로 갱신
전체 트리 문서 저장한 번에 전체 구조 읽기일부 변경, 동시 수정, 대규모 트리
별도 검색 인덱스·캐시반복 조회와 탐색원본과 동기화, 장애 후 복구

읽기가 대부분이고 이동이 드문 쇼핑몰이라면 경로나 캐시를 추가하는 것이 유리할 수 있다. 구조 변경이 빈번하고 트리가 작다면 부모 ID만 저장하는 편이 단순할 수 있다.

자료구조는 코드 안의 모양만으로 선택하지 않는다. 어떤 조회가 자주 일어나고 어떤 변경이 자주 발생하는지에 따라 저장 위치와 계산 비용을 결정해야 한다.

전체 트리를 캐시하면 데이터베이스를 조회하지 않아도 될까?

카테고리 구조는 상품 재고나 가격보다 변경 빈도가 낮다. 따라서 전체 카테고리 트리를 캐시에 저장해 빠르게 응답할 수 있다.

async function getStoreCategoryTree(storeId: string) {
  const cacheKey = `category-tree:${storeId}`;
  const cached = await cache.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  const rows =
    await categoryRepository.findAllByStore(storeId);

  const tree = buildStoreCategoryTree(rows);

  await cache.set(cacheKey, JSON.stringify(tree), {
    ttlSeconds: 300
  });

  return tree;
}

캐시가 있으면 반복되는 카테고리 조회에서 데이터베이스 부하를 줄일 수 있다. 하지만 캐시가 카테고리의 원본이 되어서는 안 된다.

운영자가 카테고리를 이동했는데 이전 캐시가 남아 있으면 사용자는 오래된 구조를 볼 수 있다. 카테고리 변경이 성공한 뒤 관련 캐시를 제거하거나 새 버전으로 교체해야 한다.

async function renameCategory(
  categoryId: string,
  name: string
) {
  const category =
    await categoryRepository.updateName(
      categoryId,
      name
    );

  await cache.delete(
    `category-tree:${category.storeId}`
  );

  return category;
}

여기에서도 데이터베이스 변경은 성공했지만 캐시 삭제가 실패할 수 있다. 짧은 시간 동안 오래된 값이 허용된다면 만료 시간을 두고 다음 요청에서 복구할 수 있다. 즉시 반영되어야 한다면 변경 이벤트, 버전 키 또는 안정적인 캐시 무효화 전략이 필요하다.

const cacheKey =
  `category-tree:${storeId}:v${categoryVersion}`;

카테고리 구조가 바뀔 때 categoryVersion을 증가시키면 새 요청은 새로운 키를 사용한다. 이전 캐시는 남아 있어도 더 이상 현재 버전으로 선택되지 않는다.

원본 데이터와 계산 데이터를 구분하면 장애가 발생했을 때 무엇을 기준으로 복구해야 하는지도 명확해진다.

Source of Truth
데이터베이스의 카테고리 행과 부모 관계

계산 데이터
중첩된 트리 응답, 전체 경로, 하위 카테고리 ID 목록

임시 데이터
메모리 트리, Redis 캐시, 검색 인덱스

트리를 빠르게 읽기 위해 여러 표현을 만들 수 있지만, 모든 표현을 동일한 원본으로 취급하면 서로 다른 구조가 생겼을 때 무엇이 맞는지 결정할 수 없다.

상품도 카테고리 트리 안에 자식으로 넣어야 할까?

화면에서는 카테고리 아래에 상품이 보이므로 상품을 카테고리의 자식 노드로 넣고 싶을 수 있다.

const tree = {
  name: "아우터",
  children: [
    {
      name: "크리스 재킷",
      children: []
    }
  ]
};

하지만 하나의 상품이 아우터, 신상품, 시즌 할인에 동시에 노출될 수 있다. 트리에서는 일반적으로 하나의 노드가 부모 하나를 가지지만, 이 상품은 여러 카테고리와 연결된다.

같은 상품을 세 번 복제하면 가격이나 재고가 변경될 때 세 위치의 데이터를 모두 수정해야 한다. 각 복사본이 서로 다른 상태를 가지면 어떤 상품이 원본인지도 불분명해진다.

카테고리 계층과 상품 소속 관계를 분리하는 편이 안전하다.

CREATE TABLE product_categories (
  product_id UUID NOT NULL REFERENCES products(id),
  category_id UUID NOT NULL REFERENCES categories(id),
  PRIMARY KEY (product_id, category_id)
);

카테고리는 트리 구조를 유지하고, 상품과 카테고리는 별도의 다대다 관계로 연결한다.

const product =
  await productRepository.findById(productId);

const categoryIds =
  await productCategoryRepository.findCategoryIds(
    productId
  );

상품의 가격, 재고와 판매 상태는 상품 데이터가 원본이다. 카테고리는 상품을 탐색하기 위한 분류 기준이지 상품 자체의 소유자가 아니다.

모든 관계가 계층처럼 보인다고 해서 트리로 만들어서는 안 된다. 하나의 대상이 여러 부모를 가져야 하거나 연결을 따라 다시 이전 대상으로 돌아갈 수 있다면 그래프가 더 적합할 수 있다.

트리를 선택한다는 것은 데이터가 다음 규칙을 가진다고 선언하는 것과 같다.

  • 계층의 시작점이 존재한다.
  • 각 노드는 일반적으로 하나의 부모를 가진다.
  • 부모에서 자식으로 내려가 다시 같은 노드로 돌아오지 않는다.
  • 한 노드의 하위 범위를 다른 영역과 구분할 수 있다.

서비스의 실제 관계가 이 규칙과 맞지 않는다면 화면이 나무 모양이라는 이유만으로 트리를 선택해서는 안 된다.

삭제된 카테고리의 자식은 어떻게 처리해야 할까?

의류 카테고리를 삭제했을 때 그 아래의 상의, 하의, 아우터를 어떻게 처리할지도 정해야 한다.

단순히 부모만 삭제하면 자식이 존재하지 않는 부모를 가리키는 고아 노드가 될 수 있다. 외래 키가 삭제를 막더라도 서비스가 어떤 동작을 원하는지는 별도로 결정해야 한다.

가능한 정책은 여러 가지다.

  • 하위 카테고리가 있으면 삭제를 거부한다.
  • 하위 카테고리까지 모두 삭제한다.
  • 하위 카테고리를 삭제된 카테고리의 부모로 이동한다.
  • 실제로 삭제하지 않고 트리 전체를 숨김 상태로 바꾼다.

쇼핑몰에서는 실수로 대규모 카테고리를 삭제하는 위험이 있으므로 하위 카테고리가 존재하면 먼저 이동하거나 명시적으로 전체 삭제를 확인하게 할 수 있다.

async function deleteCategory(
  actorId: string,
  categoryId: string
) {
  const category =
    await categoryRepository.findManageableBy(
      actorId,
      categoryId
    );

  if (!category) {
    throw new Error("Category not found");
  }

  const childCount =
    await categoryRepository.countChildren(
      category.id
    );

  if (childCount > 0) {
    throw new Error(
      "Move or delete child categories first"
    );
  }

  const assignedProductCount =
    await productCategoryRepository.countProducts(
      category.id
    );

  if (assignedProductCount > 0) {
    throw new Error(
      "Remove assigned products first"
    );
  }

  await categoryRepository.softDelete(category.id);
}

이 구현은 삭제의 영향을 확인하지 않은 채 트리 관계를 끊지 않는다. 상품 연결과 하위 카테고리를 먼저 정리하도록 요구한다.

다른 정책을 선택할 수도 있지만 중요한 것은 정책이 코드와 데이터베이스 제약 조건에 일관되게 반영되어야 한다는 점이다. 트리에서 부모 하나를 삭제하는 작업은 그 노드만의 상태 변경이 아니라 전체 하위 범위에 영향을 주는 작업일 수 있다.

카테고리 트리를 설계하기 전에 확인할 질문

  1. 서비스가 표현하려는 관계에는 하나의 명확한 시작점이 있는가?
  2. 각 노드는 부모를 하나만 가져야 하는가, 여러 부모와 연결될 수 있는가?
  3. 부모와 자식 관계를 따라갔을 때 다시 같은 노드로 돌아오는 순환을 허용하지 않는가?
  4. 사용자가 전체 트리를 필요로 하는가, 현재 노드 주변의 일부 범위만 필요로 하는가?
  5. 가장 자주 실행되는 작업은 자식 조회, 조상 조회, 전체 하위 범위 조회와 노드 이동 중 무엇인가?
  6. 카테고리 수와 최대 깊이는 어느 정도까지 증가할 수 있는가?
  7. parent_id처럼 반복 조회에 사용되는 필드에 적절한 인덱스가 있는가?
  8. 부모 ID가 외부에서 들어올 때 존재 여부, 쇼핑몰 소속과 관리 권한을 검증하는가?
  9. 카테고리를 자신의 하위로 이동시켜 순환을 만드는 요청을 차단하는가?
  10. 동시에 여러 운영자가 구조를 변경할 때 검증과 저장을 하나의 안전한 작업으로 처리하는가?
  11. 카테고리 이동 시 경로, 하위 목록, 검색 인덱스와 캐시 중 무엇을 함께 갱신해야 하는가?
  12. 데이터베이스 관계, 저장된 경로와 캐시 중 무엇이 Source of Truth인가?
  13. 계산된 트리나 캐시가 오래되었을 때 원본에서 다시 만들 수 있는가?
  14. 카테고리를 삭제할 때 하위 카테고리와 연결된 상품을 어떻게 처리하는가?
  15. 하나의 상품이 여러 카테고리에 속할 수 있다면 상품을 트리 노드로 복제하지 않고 별도 관계로 관리하는가?
  16. 숨김 카테고리 아래의 상품과 하위 카테고리도 함께 숨겨야 하는가?
  17. 카테고리 깊이를 제한해야 한다면 어느 계층에서 규칙을 검증하는가?
  18. 트리 전체를 응답할 때 데이터 크기와 직렬화 비용이 서비스가 감당할 수 있는 범위인가?
  19. 조회 속도를 위해 추가한 경로나 캐시가 구조 변경 비용을 지나치게 높이지 않는가?
  20. 실제 데이터가 트리의 규칙과 맞지 않는다면 그래프나 별도의 관계 테이블이 더 적합하지 않은가?

트리는 데이터의 소속과 탐색 경계를 설계하는 구조다

트리의 기본 모양은 부모와 자식의 연결이다. 그러나 실제 쇼핑몰에서 중요한 것은 노드를 선으로 연결하는 방법이 아니다. 카테고리라는 계층을 통해 상품 탐색의 범위를 나누고, 사용자가 선택한 위치에서 필요한 영역만 조회할 수 있게 만드는 것이다.

작은 카테고리 목록은 전체 데이터를 가져와도 충분할 수 있다. 데이터가 커지면 현재 노드의 자식이나 특정 노드의 하위 범위만 조회해야 한다. 이를 위해 부모 ID에 인덱스를 만들고, 필요한 경우 전체 경로나 캐시 같은 계산 데이터를 추가할 수 있다.

대신 구조 변경의 책임도 함께 생긴다. 외부에서 받은 부모 ID를 검증하고, 자신의 하위로 이동해 순환이 생기지 않도록 막아야 한다. 동시 변경을 통제하고, 삭제가 하위 카테고리와 연결된 상품에 미치는 영향도 정의해야 한다. 데이터베이스의 부모 관계를 원본으로 사용할지, 저장된 경로나 중첩 문서를 원본으로 사용할지도 명확해야 한다.

모든 연결 관계가 트리는 아니다. 상품처럼 여러 카테고리에 속할 수 있는 데이터는 별도의 관계로 분리해야 한다. 서비스의 데이터가 하나의 부모와 순환 없는 계층이라는 규칙을 가질 때 트리는 강력한 선택이 된다.

결국 트리를 선택한다는 것은 데이터를 나무 모양으로 그리겠다는 뜻이 아니다. 현실의 복잡한 데이터에 소속 범위와 탐색 경계를 부여하고, 필요한 영역만 단계적으로 확인할 수 있도록 만들겠다는 설계 판단이다.

다음 글에서는 그래프가 단순히 점과 선을 저장하는 구조를 넘어, 사용자·장소·작업처럼 여러 방향으로 연결된 관계를 어떻게 모델링하는지 살펴본다.

profile
Vision eXperience Developer

0개의 댓글