05 · Embedded & 상속
이 문서가 답하는 질문: 같은 컬럼 묶음을 여러 엔티티가 공유할 때, TypeORM은 Embedded·Single Table Inheritance(STI)·Concrete Table Inheritance(CTI) 세 패턴을 제공한다 — 언제 무엇을 쓰나? 한 줄 답 (Pyramid Top): “
@Embedded는 값 객체를 컬럼 prefix로 펼치고, STI는 한 테이블에 type 컬럼, CTI는 부모/자식 테이블 분리 — 셋은 조회 효율·정규화·진화 비용에서 다른 트레이드오프를 산다.”
Why — 같은 컬럼이 반복될 때
세 엔티티가 모두 주소를 가진다:
class User { /* street, city, zip, country */ }
class Order { /* shippingStreet, shippingCity, shippingZip, shippingCountry */ }
class Vendor { /* street, city, zip, country */ }3가지 길:
- Embedded —
Address를 값 객체로 정의하고 컬럼 prefix로 펼친다. - STI (Single Table Inheritance) — 한 테이블에 모든 자식의 컬럼을 두고
type컬럼으로 구분. - CTI (Concrete Table Inheritance) — 각 자식이 자기 테이블, 부모는 추상 클래스.
각자 해결하는 문제가 다르다:
| 패턴 | 해결 | 비용 |
|---|---|---|
| Embedded | 같은 묶음의 반복 (Address × N) | 1:1 관계 한정 |
| STI | 다형성 + 빠른 조회 (한 SELECT로 다 가져옴) | 컬럼 sparse, NULL 많음 |
| CTI | 깔끔한 정규화 | 조회마다 UNION ALL |
How — 세 패턴의 코드
1) @Embedded — 값 객체 펼치기
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
// 값 객체 (재사용할 컬럼 묶음)
class Address {
@Column() street!: string
@Column() city!: string
@Column({ length: 10 }) zip!: string
@Column({ length: 2 }) country!: string
}
@Entity()
class User {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() name!: string
@Column(() => Address)
address!: Address // 컬럼으로 펼쳐짐
}DDL:
CREATE TABLE user (
id uuid PRIMARY KEY,
name varchar NOT NULL,
address_street varchar NOT NULL, -- prefix가 붙음
address_city varchar NOT NULL,
address_zip varchar(10) NOT NULL,
address_country varchar(2) NOT NULL
);중요: Address는 별도 테이블이 아니다. User 테이블 안에 prefix로 펼쳐진 4 컬럼이다.
여러 Address가 필요하면 — prefix 명시:
@Entity()
class Order {
@PrimaryGeneratedColumn('uuid') id!: string
@Column(() => Address, { prefix: 'shipping_' })
shipping!: Address // shipping_street, shipping_city, ...
@Column(() => Address, { prefix: 'billing_' })
billing!: Address // billing_street, billing_city, ...
}prefix 끄기:
@Column(() => Address, { prefix: '' })
address!: Address // street, city, zip, country (prefix 없음)2) STI — Single Table Inheritance
import { Entity, PrimaryGeneratedColumn, Column, TableInheritance, ChildEntity } from 'typeorm'
@Entity('content')
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
class Content {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() title!: string
}
@ChildEntity('article')
class Article extends Content {
@Column() body!: string
}
@ChildEntity('video')
class Video extends Content {
@Column() url!: string
@Column() duration!: number
}DDL — 하나의 테이블:
CREATE TABLE content (
id uuid PRIMARY KEY,
type varchar NOT NULL, -- 'article' | 'video' (discriminator)
title varchar NOT NULL,
body text NULL, -- Article에만 의미
url varchar NULL, -- Video에만 의미
duration int NULL -- Video에만 의미
);조회:
const articleRepo = dataSource.getRepository(Article)
const all = await articleRepo.find() // WHERE type = 'article' 자동 추가
const contentRepo = dataSource.getRepository(Content)
const everything = await contentRepo.find() // 모든 typeArticle.find()는 type 조건이 자동으로 박혀 Article만 가져온다. Content.find()는 전부.
3) CTI — Concrete Table Inheritance
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
// 추상 부모 (테이블 없음)
abstract class Content {
@PrimaryGeneratedColumn('uuid') id!: string
@Column() title!: string
}
@Entity('article')
class Article extends Content {
@Column() body!: string
}
@Entity('video')
class Video extends Content {
@Column() url!: string
@Column() duration!: number
}DDL — 두 개의 테이블:
CREATE TABLE article (
id uuid PRIMARY KEY,
title varchar NOT NULL,
body text NOT NULL
);
CREATE TABLE video (
id uuid PRIMARY KEY,
title varchar NOT NULL,
url varchar NOT NULL,
duration int NOT NULL
);핵심 차이: 부모 Content는 @Entity가 없다 → 테이블이 안 생긴다. id·title은 각 자식 테이블에 복제된다.
조회: articleRepo.find()는 article 테이블만, videoRepo.find()는 video 테이블만. 둘 다 가져오는 표준 방법은 없다 — 직접 UNION ALL QueryBuilder를 적어야 한다.
MappedSuperclass — 더 간결한 CTI
TypeORM은 JPA의 @MappedSuperclass를 별도 데코레이터로 두지 않는다. 그냥 @Entity 없는 추상 클래스가 그 역할이다 — 위 CTI 예제의 abstract class Content가 바로 MappedSuperclass.
abstract class Base {
@PrimaryGeneratedColumn('uuid') id!: string
@CreateDateColumn() createdAt!: Date
@UpdateDateColumn() updatedAt!: Date
}
@Entity() class User extends Base { @Column() name!: string }
@Entity() class Post extends Base { @Column() title!: string }user 테이블과 post 테이블이 각각 id·createdAt·updatedAt을 가진다. 시스템 컬럼을 공통화하는 가장 흔한 패턴.
What — 세 패턴 비교 매트릭스
결정 매트릭스
| 측면 | Embedded | STI | CTI |
|---|---|---|---|
| 테이블 수 | 1 (부모만) | 1 (부모만) | N (자식별) |
| 컬럼 sparse | 없음 | 많음 (NULL) | 없음 |
| NOT NULL 가능 | ✅ | ❌ (자식별 컬럼은 NULL 허용 강제) | ✅ |
| 다형성 조회 | N/A | 빠름 (한 SELECT) | UNION ALL 필요 |
| 자식 추가 비용 | N/A (다른 개념) | 컬럼 추가 (sparse 증가) | 새 테이블 |
| 외래키 받기 | N/A | 한 테이블 가리킴 | 각각 다른 테이블 가리킴 |
| 인덱스 | 부모와 함께 | 부모 + type | 자식별 독립 |
| JPA 이름 | @Embeddable | SINGLE_TABLE | TABLE_PER_CLASS |
언제 무엇을 쓰나
전체 예제 — 셋 다 한 도메인에서
// 값 객체
class Money {
@Column({ type: 'decimal', precision: 12, scale: 2 }) amount!: string
@Column({ length: 3 }) currency!: string
}
// 부모 (STI)
@Entity('payment')
@TableInheritance({ column: { type: 'varchar', name: 'kind' } })
class Payment {
@PrimaryGeneratedColumn('uuid') id!: string
@Column(() => Money, { prefix: 'total_' })
total!: Money
@CreateDateColumn() createdAt!: Date
}
// STI 자식들 (한 테이블에 type 컬럼으로 구분)
@ChildEntity('card')
class CardPayment extends Payment {
@Column() cardLast4!: string
}
@ChildEntity('bank')
class BankPayment extends Payment {
@Column() accountNumber!: string
}
// CTI (별도 테이블)
abstract class Audit {
@PrimaryGeneratedColumn('uuid') id!: string
@CreateDateColumn() at!: Date
@Column() actorId!: string
}
@Entity('audit_login')
class LoginAudit extends Audit {
@Column() ip!: string
}
@Entity('audit_export')
class ExportAudit extends Audit {
@Column() resource!: string
}이 예제에서:
Money는 값 객체 — Embedded로 펼침.Payment는 다형성 조회가 잦은 도메인 → STI.Audit는 종류별 의미가 완전히 분리 → CTI.
What-if — 자주 틀리는 패턴
함정 1) Embedded를 외부 키로 착각
@Column(() => Address) address!: Addressaddress는 컬럼 묶음이지 외래키 관계가 아니다. 다른 엔티티에서 Address를 참조하려고 하면 불가능 — Address는 테이블이 아니다. 참조 가능한 것을 원하면 별도 @Entity() class Address 만들고 *@ManyToOne*으로.
함정 2) STI에서 자식 컬럼을 NOT NULL로 적기
@ChildEntity('video')
class Video extends Content {
@Column() // ❌ NOT NULL이 되면 Article 저장 불가
url!: string
}STI는 한 테이블에 모든 자식 컬럼이 있다 → url은 Article row에서는 반드시 NULL이어야 한다. TypeORM이 자동으로 nullable로 만들지 않으므로 명시:
@Column({ nullable: true })
url!: string | null또는 애플리케이션 단에서 type별 검증 (zod·class-validator).
함정 3) CTI에서 다형성 외래키
@Entity()
class Comment {
@Column() targetId!: string
@Column() targetType!: string // 'article' | 'video'
}targetId가 어떤 테이블의 PK인지 DB가 모른다 — FK 제약을 못 건다. TypeORM도 관계 데코레이터로 표현 못 한다. 두 가지 우회:
- STI로 바꾸기 —
Content한 테이블이면 FK가 자연스럽다. - 분리된 테이블 유지 + Application 측 검증 —
targetType + targetId를 매번 코드가 라우팅.
함정 4) Embedded의 prefix 충돌
@Entity()
class Order {
@Column(() => Address) address!: Address // address_*
@Column(() => Address) deliveryAddress!: Address // ❌ 같은 prefix 'address_' 가능
}해결: 명시적 prefix.
@Column(() => Address, { prefix: 'addr_' }) address!: Address
@Column(() => Address, { prefix: 'delivery_addr_' }) deliveryAddress!: Address함정 5) STI의 discriminator 컬럼명을 잊거나 잘못 적기
@TableInheritance({ column: { type: 'varchar', name: 'kind' } })
class Payment { ... }
@ChildEntity() // ⚠️ name 없음 → 클래스명 'CardPayment' 그대로 들어감
class CardPayment extends Payment { ... }
// vs
@ChildEntity('card') // ✅ 'card'로 명시
class CardPayment extends Payment { ... }discriminator 값을 명시하지 않으면 클래스명이 들어간다 → 리팩토링 시 클래스명 변경 = 데이터 의미 변경. 항상 명시.
함정 6) MappedSuperclass 부모에 @Entity() 붙이기
@Entity() // ❌ 부모도 테이블이 됨
abstract class Base { @PrimaryGeneratedColumn() id!: number }
@Entity()
class User extends Base { /* ... */ }이러면 Base 테이블까지 만들려고 시도한다 → 의도 위반. 추상 부모는 @Entity 빼고 abstract 키워드만.
Insight — 흥미로운 이야기
**“세 상속 패턴은 Martin Fowler의 PoEAA(2002)에서 그대로 왔다”
Patterns of Enterprise Application Architecture(2002)는 ORM 관계 매핑의 세 가지 길을 카탈로그화했다:
- Single Table Inheritance (한 테이블, type 컬럼)
- Class Table Inheritance (부모 테이블 + 자식 테이블, JOIN)
- Concrete Table Inheritance (자식별 테이블만)
Hibernate(2003)가 이 셋을 그대로 구현했고 — JPA spec(2006)이 *
InheritanceType.SINGLE_TABLE·JOINED·TABLE_PER_CLASS*로 언어화했다. TypeORM은 JOINED(Class Table)을 빼고 STI와 CTI 두 가지만 지원한다. 그 이유는 드라이버 일관성 — JOIN 방식은 각 DB의 외래키 동작에 강하게 묶인다. “세 길 중 둘만 선택한 결정”은 TypeORM의 운영 단순성과 완전성 사이의 절충이다.
“Embedded는 값 객체(Value Object) 사상의 ORM 표현”
Eric Evans의 Domain-Driven Design(2003)은 *엔티티(Entity)*와 *값 객체(Value Object)*를 구분했다 — 엔티티는 정체성이 있고, 값 객체는 값 자체가 의미다.
Address(서울, 강남구, ...)는 주소가 같으면 같은 것이고, 그 자체의 고유 ID가 의미 없다. Embedded는 그 사상의 ORM 표현이다. 별도 테이블·별도 PK가 없어야 정직하다 — 그래서 prefix로 펼쳐 부모 테이블에 박는다.@Embedded를 작은 1:1 관계로만 보면 값 객체의 정체성이 보이지 않는다. 이게 STI/CTI와 근본적으로 다른 출발점이다.
요약 + Mermaid
요약: 세 패턴은 값 객체 묶음·다형성·정규화라는 다른 문제를 푼다. Embedded는 DDD의 값 객체를 컬럼 prefix로 펼친다 — 1:1 한정. STI는 다형성 조회가 잦은 도메인에 빠르다 — 자식 컬럼은 반드시 nullable. CTI는 의미가 분리된 자식에 정직하다 — 다형성 조회는 직접 UNION ALL. 다음 문서(
06-entity-schema-alternative)는 이 모든 데코레이터를 거부하고 같은 메타를 클래스 밖에서 적는 길을 다룬다.