🗄️ TypeORM2. Relations (1:1 · 1:N · N:M)06 — 자기참조 & polymorphic

06 — 자기참조 & polymorphic (Self-referencing & Polymorphic)

질문: Categoryparent: Category를 가지는 트리 구조는 어떻게 정의하는가? CommentPostPhoto처럼 여러 타입을 가리킬 수 있는 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문자열 splitLIKEO(1)경로가 가시적
'nested-set'nsleft, nsright범위 비교범위 비교범위 비교O(n)read 매우 빠름, write 매우 느림
'closure-table'별도 테이블 <entity>_closurejoin 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 데코레이터는 자기참조 부분만 관리. 단, findTreesproducts까지 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에서 끝장을 본다.