계층 구조 데이터, 왜 어려운가?
카테고리, 조직도, 댓글 대댓글, 메뉴 트리 — 실무에서 계층 구조(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은 각 노드에 left와 right 값을 부여한다. 한 노드의 모든 후손은 부모의 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로 전환하는 접근도 유효하다.