06 — 자기참조 & polymorphic (Self-referencing & Polymorphic)
질문:
Category가parent: Category를 가지는 트리 구조는 어떻게 정의하는가?Comment가Post나Photo처럼 여러 타입을 가리킬 수 있는 polymorphic 관계는? 한 줄 답: *자기참조는 **@TreeChildren/@TreeParent*로 네 가지 트리 패턴(Adjacency List · Materialized Path · Nested Set · Closure Table) 중 하나를 선택한다. polymorphic은 TypeORM의 1급 시민이 아니다 — STI(단일 테이블 상속), 별도 외래키, 또는 다형 ID 컬럼 같은 우회 패턴이 필요하다.
한 장 그림
Why — 왜 두 주제를 같이 묶는가
둘 다 관계의 표현이 까다로운 경계 사례다.
- 자기참조: 한 엔티티가 자기 자신을 가리킨다 → 재귀 쿼리(WITH RECURSIVE)가 필요하거나, 조인 테이블에 경로를 미리 박아두는 공간-시간 트레이드오프가 필요하다.
- polymorphic: 한 외래키가 여러 타입 테이블 중 하나를 가리킨다 → RDB의 FK는 한 테이블만 가리킬 수 있으므로 FK 무결성을 포기하거나 다중 컬럼을 써야 한다.
TypeORM은 자기참조는 1급 지원, polymorphic은 비지원. 이게 두 주제의 대칭과 비대칭이다.
How (Part 1) — 자기참조 트리
1) 단순 자기참조 — Adjacency List (기본)
@Entity()
export class Category {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
@ManyToOne(() => Category, (cat) => cat.children, { nullable: true })
parent: Category | null;
@OneToMany(() => Category, (cat) => cat.parent)
children: Category[];
}동작:
- 컬럼:
id,name,parentId(자기 테이블의 id 가리킴) - 쿼리: 자식 가져오기는 쉬움, 전체 트리는 N+1 또는 재귀 CTE 직접 작성
-- 깊은 트리는 직접 CTE
WITH RECURSIVE tree AS (
SELECT * FROM category WHERE id = 1
UNION ALL
SELECT c.* FROM category c JOIN tree t ON c.parentId = t.id
)
SELECT * FROM tree;2) TypeORM Tree 엔티티 — 네 가지 패턴
import { Tree, TreeChildren, TreeParent, Entity } from 'typeorm';
@Entity()
@Tree('closure-table') // ★ 핵심
export class Category {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
@TreeChildren()
children: Category[];
@TreeParent()
parent: Category;
}네 가지 패턴 비교:
| 패턴 | 컬럼/테이블 | 자식 조회 | 조상 조회 | 후손 조회 | 삽입 비용 | 강점 |
|---|---|---|---|---|---|---|
'adjacency-list' | parentId (자기 테이블) | O(1) | O(depth) — 재귀 | O(n) — 재귀 | O(1) | 가장 단순, write 빠름 |
'materialized-path' | mpath 컬럼 (/1/3/7/) | LIKE | 문자열 split | LIKE | O(1) | 경로가 가시적 |
'nested-set' | nsleft, nsright | 범위 비교 | 범위 비교 | 범위 비교 | O(n) | read 매우 빠름, write 매우 느림 |
'closure-table' ★ | 별도 테이블 <entity>_closure | join 1회 | join 1회 | join 1회 | O(depth) | read·write 균형, TypeORM 권장 |
// closure-table 사용 예
const repo = dataSource.getTreeRepository(Category);
await repo.findTrees(); // 전체 트리
await repo.findDescendants(category); // 후손 전부
await repo.findAncestors(category); // 조상 전부
await repo.findDescendantsTree(category); // 트리 형태권장: 트리가 깊고 자주 조회되면
'closure-table'. 단순 1-2 depth면'adjacency-list'로 충분.
3) @TreeChildren/@TreeParent 없이 순수 자기참조
@Entity()
export class Comment {
@PrimaryGeneratedColumn() id: number;
@Column() body: string;
@ManyToOne(() => Comment, (c) => c.replies, { nullable: true })
parent: Comment | null;
@OneToMany(() => Comment, (c) => c.parent)
replies: Comment[];
}이건 Adjacency List와 같은 형태지만 TreeRepository API를 못 씀. 단순 1-2 depth (댓글 → 답글)면 충분 — 깊은 재귀가 없는 경우의 정답.
How (Part 2) — polymorphic 우회 패턴
1) STI (Single Table Inheritance) — 같은 기반 타입일 때
@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export class Content { // ★ 기반 엔티티
@PrimaryGeneratedColumn() id: number;
@Column() title: string;
}
@ChildEntity()
export class Post extends Content {
@Column() body: string;
}
@ChildEntity()
export class Photo extends Content {
@Column() url: string;
}
// Comment는 Content를 가리킨다
@Entity()
export class Comment {
@ManyToOne(() => Content)
content: Content; // ★ Post든 Photo든 OK
}조건: Post와 Photo가 진짜 한 가족일 때 (공통 컬럼 多, 함께 다뤄야 함). 한 테이블에 모든 컬럼이 박혀 대부분 NULL이 되는 희소 테이블 단점.
2) 별도 외래키 — 후보 타입이 적고 명확할 때
@Entity()
export class Comment {
@PrimaryGeneratedColumn() id: number;
@Column() body: string;
@ManyToOne(() => Post, { nullable: true }) // ★ 후보 1
post: Post | null;
@ManyToOne(() => Photo, { nullable: true }) // ★ 후보 2
photo: Photo | null;
// CHECK 제약: 정확히 하나만 NOT NULL
}장점: FK 무결성 보장, 각 관계 독립적 explain. 단점: 새 타입 추가마다 컬럼 추가 + 마이그레이션.
3) 다형 컬럼 (commentableType + commentableId) — Rails 스타일
@Entity()
export class Comment {
@PrimaryGeneratedColumn() id: number;
@Column() body: string;
@Column() commentableType: 'post' | 'photo' | 'video'; // ★ 타입 문자열
@Column() commentableId: number; // ★ 해당 테이블의 id
}
// 코드 레벨에서 분기 조회
async function getCommentable(comment: Comment) {
switch (comment.commentableType) {
case 'post': return postRepo.findOne({ where: { id: comment.commentableId } });
case 'photo': return photoRepo.findOne({ where: { id: comment.commentableId } });
case 'video': return videoRepo.findOne({ where: { id: comment.commentableId } });
}
}장점: 새 타입 추가 = type 문자열 + 코드 분기. 마이그레이션 최소. 단점: FK 무결성 X (DB가 commentableId가 유효한지 모름). 고아 row 발생 위험.
Rails ActiveRecord의
polymorphic: true가 이 패턴. 강력하지만 FK 무결성을 포기한다는 결정적 트레이드오프.
What — 자기참조 vs polymorphic 비교
| 항목 | 자기참조 (Self-ref) | Polymorphic |
|---|---|---|
| TypeORM 1급 지원 | ★ (@Tree, @TreeChildren, @TreeParent) | 없음 |
| FK 무결성 | 자동 (자기 PK 참조) | 우회 패턴에 따라 (1·2번 보존 / 3번 포기) |
| 패턴 선택 폭 | 4가지 트리 패턴 | 3가지 우회 패턴 |
| 권장 시작점 | 'closure-table' 또는 단순 Adjacency | 후보 타입이 명확하면 별도 FK, 동적이면 다형 컬럼 |
| 대안 ORM 처리 | JPA·Hibernate도 유사 4패턴 지원 | Rails는 1급 지원, JPA는 @Inheritance(SINGLE_TABLE) |
What-if — 자주 만나는 함정
1) 자기참조 — 단순 @ManyToOne(() => Self)로 깊은 트리
@ManyToOne(() => Category, { nullable: true })
parent: Category | null;깊이 10의 카테고리 트리에서 루트부터 모든 후손 조회를 시도하면 → 재귀 쿼리를 직접 작성해야 함. TypeORM의 find는 고정 깊이만 지원 (relations: { parent: { parent: { parent: ... } } }). 깊은 트리는 처음부터 @Tree('closure-table').
2) Tree 엔티티의 findTrees() — 전체 로드
await repo.findTrees(); // ★ 모든 트리를 메모리에수만 개의 노드가 있는 경우 메모리 폭발. 특정 서브트리만 가져오는 findDescendantsTree(rootCategory, { depth: 3 }) 사용.
3) Polymorphic 다형 컬럼 — 조인을 못 함
-- 이런 쿼리가 *불가능*
SELECT c.*, p.* FROM comments c
LEFT JOIN posts p ON p.id = c.commentableId -- type 조건 어디?
AND c.commentableType = 'post' -- ★ 가능하긴 함조인은 되지만 FK 무결성이 없으므로 EXPLAIN이 인덱스를 못 탐 + type별로 다른 join을 작성해야 함. 쿼리 복잡도 폭발.
4) STI — 희소 테이블 폭발
@ChildEntity()
export class Post extends Content {
@Column() body: string; // Post에만 있음
@Column() commentsEnabled: boolean; // Post에만
}
@ChildEntity()
export class Photo extends Content {
@Column() url: string; // Photo에만
@Column() exif: string; // Photo에만
}→ 한 테이블에 body, commentsEnabled, url, exif 모두 들어가고 대부분 NULL. 컬럼 수가 Post 컬럼 + Photo 컬럼 + …. NULL 가능성 때문에 NOT NULL 제약도 못 검 — 타입 안전성도 일부 깨짐. 후보 타입이 3-4개 이상이면 STI 회피.
5) @Tree와 @OneToMany의 동시 사용
@Entity()
@Tree('closure-table')
export class Category {
@TreeChildren()
children: Category[];
@OneToMany(() => Product, (p) => p.category) // ★ 다른 관계
products: Product[];
}이건 허용된다 — Tree 데코레이터는 자기참조 부분만 관리. 단, findTrees가 products까지 eager로 가져오지 않으므로 relations 명시 필요.
6) Closure Table의 직접 INSERT
// 안티패턴
await dataSource.query('INSERT INTO category VALUES (...)');
// → category_closure 테이블이 자동으로 채워지지 *않음*Closure Table은 TypeORM API를 통해서만 일관성이 유지된다. raw SQL로 노드 추가하면 closure 테이블이 망가짐. 마이그레이션도 trigger로 보호하지 않는 한 위험.
Insight — RDB의 한계가 ORM의 비대칭을 만든다
”자기참조는 해결된 문제, polymorphic은 해결되지 않은 문제”
자기참조의 4가지 패턴은 Joe Celko의 “Trees and Hierarchies in SQL”(2004)에서 정형화되었다 — Adjacency / Path / Nested Set / Closure. 학문적으로 끝난 문제라서 ORM이 그대로 구현한 것.
polymorphic은 RDB FK가 한 테이블만 가리킬 수 있다는 근본 제약과 충돌한다. 학문적 해결책이 없다 — 그래서 ORM마다 다른 우회를 한다:
- Rails: 다형 컬럼 (FK 무결성 포기)
- JPA: 상속 (
@Inheritance) - TypeORM: STI만 1급, 나머지는 수동 우회
”Closure Table은 왜 TypeORM의 권장인가”
| 패턴 | 약점 |
|---|---|
| Adjacency List | 재귀 쿼리 직접 작성 |
| Materialized Path | 노드 이동 시 모든 후손 경로 갱신 |
| Nested Set | 삽입 시 대부분의 row 갱신 |
| Closure Table | 별도 테이블이지만 모든 작업이 단순 쿼리 |
Closure는 공간을 더 쓰지만(O(depth × n)) 시간이 균형. TypeORM이 기본으로 closure를 권장하는 이유.
”Prisma는 polymorphic을 아예 막는다”
Prisma 스키마는 FK가 한 테이블만 가리키는 것을 강제한다 — polymorphic이 문법적으로 표현 불가. 우회하려면 별도 FK 여러 개 또는 수동 type+id 컬럼. 제약이 안전이라는 입장.
TypeORM은 “지원 안 함”이고 Prisma는 “표현 불가”. 결과는 같지만 마음가짐의 차이.
”polymorphic을 진짜 필요로 하는가?”
실전에서 polymorphic이 필수라고 생각되는 경우 중 80%는 모델링 실수다:
- “Comment가 Post와 Photo 모두에 달릴 수 있어야 해” → Content 같은 상위 추상으로 묶을 수 있음 (STI 후보)
- “Notification이 모든 이벤트에 달릴 수 있어야 해” → EventLog 테이블 + Notification.eventLogId로 분리
- “Tag가 모든 엔티티에 붙어야 해” → Tag + Taggable 중간 테이블에 type 컬럼 (다형 컬럼)
polymorphic을 쓰기 전에 “정말 같은 가족인가?”를 묻고, 같은 가족이면 STI, 아니면 각자 FK. 다형 컬럼은 마지막 보루.
”Closure Table의 깊이 4 이상은 권장 안 함”
깊이가 십 단위가 되면 closure 테이블 크기가 수십만 row로 폭발. 이때는 MongoDB 같은 문서 DB로 트리 자체를 한 문서로 다루는 게 더 효율적. RDB가 풀려는 문제가 RDB에 맞는지부터 의심.
요약
자기참조는 1급 시민 —
@Tree(...)+@TreeChildren/@TreeParent로 네 패턴 중 선택. 권장 트리 패턴은'closure-table'— read·write 균형, 단순 쿼리. polymorphic은 비지원 — STI / 별도 FK / 다형 컬럼 중 데이터 특성에 맞게 우회. polymorphic을 쓰기 전에 정말 한 가족인지 의심하라 — 80%는 모델링 실수. closure를 raw SQL로 만지지 마라 — closure 테이블이 망가진다.
챕터 종료. 다음 챕터 03 — Repository & DataMapper에서 이 모든 관계를 SQL로 번역하는 게이트웨이를 본다. eager가 만든 N+1은 07 — 성능 & N+1에서 끝장을 본다.