TypeORM Tree Entity 심화

계층 구조 데이터, 왜 어려운가?

카테고리, 조직도, 댓글 대댓글, 메뉴 트리 — 실무에서 계층 구조(Hierarchical Data)는 어디에나 있다. 하지만 관계형 DB는 본질적으로 플랫한 테이블 구조라 트리를 표현하려면 별도 전략이 필요하다. TypeORM은 4가지 트리 패턴을 데코레이터 수준에서 지원한다. 각 패턴의 구조, 성능 특성, 실전 선택 기준을 깊이 파고든다.

TypeORM이 지원하는 4가지 트리 전략

전략 읽기 성능 쓰기 성능 적합한 상황
Adjacency List ⚠️ 재귀 필요 ✅ 빠름 얕은 트리, 잦은 수정
Closure Table ✅ 매우 빠름 ⚠️ 별도 테이블 깊은 트리, 조상/후손 조회 빈번
Materialized Path ✅ LIKE 쿼리 ✅ 단순 경로 탐색, 브레드크럼
Nested Set ✅ 범위 쿼리 ❌ 느림 읽기 전용에 가까운 구조

1. Adjacency List: 가장 직관적인 부모 참조

Adjacency List는 각 노드가 부모의 ID를 참조하는 가장 기본적인 패턴이다. TypeORM에서 @Tree("adjacency-list") 없이도 자기 참조 관계로 구현할 수 있지만, 트리 데코레이터를 쓰면 TreeRepository의 편의 메서드를 활용할 수 있다.

import {
  Entity, PrimaryGeneratedColumn, Column,
  Tree, TreeChildren, TreeParent
} from 'typeorm';

@Entity()
@Tree("adjacency-list")
export class Category {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ default: 0 })
  depth: number;

  @TreeParent()
  parent: Category | null;

  @TreeChildren()
  children: Category[];
}

TreeRepository 활용

@Injectable()
export class CategoryService {
  constructor(
    @InjectRepository(Category)
    private readonly repo: TreeRepository<Category>,
  ) {}

  // 전체 트리 조회 (루트부터 모든 자식)
  async getFullTree(): Promise<Category[]> {
    return this.repo.findTrees();
  }

  // 특정 노드의 모든 후손
  async getDescendants(parentId: number): Promise<Category[]> {
    const parent = await this.repo.findOneBy({ id: parentId });
    return this.repo.findDescendants(parent);
  }

  // 특정 노드의 모든 조상 (브레드크럼)
  async getAncestors(nodeId: number): Promise<Category[]> {
    const node = await this.repo.findOneBy({ id: nodeId });
    return this.repo.findAncestors(node);
  }

  // 트리 형태로 후손 조회 (중첩 객체)
  async getDescendantsTree(parentId: number): Promise<Category> {
    const parent = await this.repo.findOneBy({ id: parentId });
    return this.repo.findDescendantsTree(parent);
  }
}

한계: 내부적으로 재귀 쿼리(WITH RECURSIVE)를 사용하기 때문에 깊이가 깊어질수록 성능이 저하된다. MySQL 5.7 이하에서는 CTE를 지원하지 않아 다른 전략을 고려해야 한다.

2. Closure Table: 읽기 최적화의 끝판왕

Closure Table은 모든 조상-후손 관계를 별도 매핑 테이블에 저장한다. 노드 A가 노드 D의 조부모라면, (A, D, depth=2)라는 행이 클로저 테이블에 존재한다.

@Entity()
@Tree("closure-table")
export class Department {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ nullable: true })
  managerId: number;

  @TreeParent()
  parent: Department | null;

  @TreeChildren()
  children: Department[];
}

TypeORM이 자동으로 department_closure 테이블을 생성한다. 이 테이블의 구조는 다음과 같다:

-- 자동 생성되는 클로저 테이블
-- ancestor_id | descendant_id
-- 1           | 1  (자기 자신)
-- 1           | 2  (1의 자식)
-- 1           | 3  (1의 손자)
-- 2           | 2  (자기 자신)
-- 2           | 3  (2의 자식)

Closure Table의 강력한 조회 성능

@Injectable()
export class DepartmentService {
  constructor(
    @InjectRepository(Department)
    private readonly repo: TreeRepository<Department>,
  ) {}

  // 특정 부서의 모든 하위 부서 (단일 JOIN, 재귀 없음!)
  async getAllSubDepartments(deptId: number): Promise<Department[]> {
    const dept = await this.repo.findOneBy({ id: deptId });
    return this.repo.findDescendants(dept);
  }

  // 깊이 제한 조회 (직속 + 1단계 하위만)
  async getDirectReports(deptId: number): Promise<Department[]> {
    return this.repo
      .createQueryBuilder('dept')
      .innerJoin(
        'dept.closure', 'closure',
        'closure.ancestor = :id AND closure.depth = 1',
        { id: deptId }
      )
      .getMany();
  }

  // 노드 이동 (부모 변경)
  async moveDepartment(deptId: number, newParentId: number): Promise<void> {
    const dept = await this.repo.findOneBy({ id: deptId });
    const newParent = await this.repo.findOneBy({ id: newParentId });
    dept.parent = newParent;
    await this.repo.save(dept);
    // TypeORM이 클로저 테이블을 자동으로 재계산
  }
}

장점: 조상/후손 조회가 단일 JOIN으로 해결된다. 깊이가 100이든 1000이든 성능이 일정하다. 단점: 노드 삽입/이동 시 클로저 테이블의 행을 갱신해야 하므로 쓰기 비용이 높다.

3. Materialized Path: 경로 기반 탐색

Materialized Path는 각 노드에 루트부터의 전체 경로를 문자열로 저장한다. /1/3/7/ 같은 형식이다.

@Entity()
@Tree("materialized-path")
export class Comment {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'text' })
  content: string;

  @Column()
  authorId: number;

  @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
  createdAt: Date;

  @TreeParent()
  parent: Comment | null;

  @TreeChildren()
  children: Comment[];

  // TypeORM이 자동으로 mpath 컬럼을 추가한다
  // mpath: "/1/3/7/" 형태
}

Materialized Path 활용 패턴

@Injectable()
export class CommentService {
  constructor(
    @InjectRepository(Comment)
    private readonly repo: TreeRepository<Comment>,
  ) {}

  // 대댓글 작성
  async addReply(
    parentId: number,
    content: string,
    authorId: number,
  ): Promise<Comment> {
    const parent = await this.repo.findOneBy({ id: parentId });
    const reply = this.repo.create({ content, authorId, parent });
    return this.repo.save(reply);
    // mpath가 자동으로 "/부모경로/새ID/" 로 설정됨
  }

  // 특정 댓글의 모든 대댓글 (LIKE 쿼리)
  async getReplies(commentId: number): Promise<Comment[]> {
    const comment = await this.repo.findOneBy({ id: commentId });
    return this.repo.findDescendants(comment);
    // 내부적으로: WHERE mpath LIKE '/1/3/%'
  }

  // 브레드크럼 생성 (경로 파싱)
  async getBreadcrumb(commentId: number): Promise<Comment[]> {
    const comment = await this.repo.findOneBy({ id: commentId });
    return this.repo.findAncestors(comment);
  }

  // 트리 정렬 (경로 기반 자연 정렬)
  async getThreadedComments(postId: number): Promise<Comment[]> {
    return this.repo
      .createQueryBuilder('comment')
      .where('comment.postId = :postId', { postId })
      .orderBy('comment.mpath', 'ASC')
      .getMany();
  }
}

장점: 별도 테이블 없이 LIKE 쿼리로 후손을 조회할 수 있고, mpath 정렬로 트리 순서를 자연스럽게 표현할 수 있다. 댓글 스레드 정렬에 특히 유용하다. 단점: 경로 문자열 길이에 제한이 있고, 인덱스 효율이 B-Tree보다 떨어진다.

4. Nested Set: 범위 쿼리의 마법

Nested Set은 각 노드에 leftright 값을 부여한다. 한 노드의 모든 후손은 부모의 left-right 범위 안에 존재한다.

@Entity()
@Tree("nested-set")
export class Menu {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  label: string;

  @Column({ nullable: true })
  url: string;

  @Column({ default: 0 })
  order: number;

  @TreeParent()
  parent: Menu | null;

  @TreeChildren()
  children: Menu[];

  // TypeORM이 자동으로 nsleft, nsright 컬럼을 추가
}

Nested Set의 읽기 성능

@Injectable()
export class MenuService {
  constructor(
    @InjectRepository(Menu)
    private readonly repo: TreeRepository<Menu>,
  ) {}

  // 전체 메뉴 트리 (단일 쿼리, JOIN 없음!)
  async getMenuTree(): Promise<Menu[]> {
    return this.repo.findTrees();
    // 내부: SELECT * FROM menu ORDER BY nsleft
    // 단일 정렬 쿼리로 전체 트리 복원 가능
  }

  // 특정 메뉴의 모든 하위 메뉴
  async getSubMenus(menuId: number): Promise<Menu[]> {
    const menu = await this.repo.findOneBy({ id: menuId });
    return this.repo.findDescendants(menu);
    // 내부: WHERE nsleft > parent.nsleft AND nsright < parent.nsright
  }

  // 자식 개수 계산 (쿼리 없이!)
  countChildren(menu: Menu): number {
    // (right - left - 1) / 2 = 자식 수
    return (menu['nsright'] - menu['nsleft'] - 1) / 2;
  }
}

장점: 읽기가 극도로 빠르다. 후손 조회가 단순 범위 비교로 해결된다. 치명적 단점: 노드 삽입/삭제/이동 시 전체 트리의 left/right 값을 재계산해야 한다. 수천 개 노드가 있다면 삽입 한 번에 수천 개 행을 UPDATE해야 한다.

전략 선택 가이드: 실전 의사결정 트리

쓰기가 잦은가?
├─ YES → 트리 깊이가 5 이하인가?
│        ├─ YES → Adjacency List ✅
│        └─ NO  → Materialized Path ✅
└─ NO  → 조상/후손 양방향 조회가 필요한가?
         ├─ YES → Closure Table ✅
         └─ NO  → Nested Set ✅ (읽기 전용에 최적)

패턴별 실전 사용 사례

사용 사례 추천 전략 이유
댓글/대댓글 Materialized Path 잦은 삽입 + 스레드 정렬 필요
상품 카테고리 Closure Table 읽기 빈번 + 깊이 다양 + 가끔 재배치
조직도 Closure Table "이 사람 위의 모든 상사" 조회 빈번
네비게이션 메뉴 Nested Set 변경 드묾 + 전체 트리 렌더링
파일 시스템 경로 Materialized Path 경로 기반 탐색이 자연스러움

성능 최적화: 트리 쿼리 튜닝

Closure Table 인덱스 전략

// 클로저 테이블에 복합 인덱스 추가 (마이그레이션)
await queryRunner.query(`
  CREATE INDEX idx_closure_ancestor 
  ON department_closure (ancestor_id, descendant_id);
  
  CREATE INDEX idx_closure_descendant 
  ON department_closure (descendant_id, ancestor_id);
`);

// 깊이 컬럼이 있다면 깊이 기반 필터링도 인덱스 활용
await queryRunner.query(`
  CREATE INDEX idx_closure_depth 
  ON department_closure (ancestor_id, depth);
`);

Materialized Path LIKE 최적화

// mpath에 인덱스 추가 (prefix LIKE만 인덱스 활용 가능)
await queryRunner.query(`
  CREATE INDEX idx_comment_mpath ON comment (mpath);
`);

// ✅ 인덱스 사용: WHERE mpath LIKE '/1/3/%'  (prefix)
// ❌ 인덱스 미사용: WHERE mpath LIKE '%/3/%' (중간 매칭)

깊이 제한 조회로 N+1 방지

// findTrees()는 전체를 로드 → 대규모 트리에서 위험
// depth 옵션으로 제한
const shallowTree = await this.repo.findTrees({ depth: 2 });

// findDescendantsTree도 깊이 제한 가능
const subtree = await this.repo.findDescendantsTree(node, { depth: 3 });

커스텀 트리 쿼리: QueryBuilder 활용

@Injectable()
export class AdvancedCategoryService {
  constructor(
    @InjectRepository(Category)
    private readonly repo: TreeRepository<Category>,
    private readonly dataSource: DataSource,
  ) {}

  // 특정 카테고리와 모든 하위의 상품 수 집계
  async getCategoryWithProductCount(categoryId: number) {
    const category = await this.repo.findOneBy({ id: categoryId });
    const descendants = await this.repo.findDescendants(category);
    const ids = descendants.map(d => d.id);

    return this.dataSource
      .createQueryBuilder()
      .select('c.id', 'categoryId')
      .addSelect('c.name', 'categoryName')
      .addSelect('COUNT(p.id)', 'productCount')
      .from(Category, 'c')
      .leftJoin('product', 'p', 'p.categoryId = c.id')
      .where('c.id IN (:...ids)', { ids })
      .groupBy('c.id')
      .getRawMany();
  }

  // Adjacency List + CTE로 직접 재귀 쿼리 (MySQL 8+)
  async getTreeWithCTE(rootId: number) {
    return this.dataSource.query(`
      WITH RECURSIVE tree AS (
        SELECT id, name, parent_id, 0 AS depth
        FROM category WHERE id = ?
        UNION ALL
        SELECT c.id, c.name, c.parent_id, t.depth + 1
        FROM category c
        INNER JOIN tree t ON c.parent_id = t.id
        WHERE t.depth < 10
      )
      SELECT * FROM tree ORDER BY depth, name
    `, [rootId]);
  }
}

트리 노드 이동 시 주의사항

// ⚠️ 순환 참조 방지: 자기 자신의 후손을 부모로 지정하면 무한 루프
async moveNode(nodeId: number, newParentId: number): Promise<void> {
  // 1. 순환 참조 검사
  const node = await this.repo.findOneBy({ id: nodeId });
  const descendants = await this.repo.findDescendants(node);
  const descendantIds = descendants.map(d => d.id);

  if (descendantIds.includes(newParentId)) {
    throw new BadRequestException(
      '자기 자신의 후손을 부모로 지정할 수 없습니다'
    );
  }

  // 2. 안전하게 부모 변경
  const newParent = await this.repo.findOneBy({ id: newParentId });
  node.parent = newParent;
  await this.repo.save(node);
}

트리 데이터 구조는 요구사항에 따라 최적 전략이 완전히 달라진다. "읽기 vs 쓰기 비율"과 "트리 깊이" 두 축으로 판단하면 실수를 줄일 수 있다. TypeORM의 트리 데코레이터를 활용하면 전략 변경 시 엔티티 데코레이터만 바꾸면 되므로, 프로토타입 단계에서 Adjacency List로 시작하고 성능 이슈 발생 시 Closure Table로 전환하는 접근도 유효하다.

관련 글: TypeORM Cascade 관계 심화 | TypeORM QueryBuilder 심화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux