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가지 길:

  1. EmbeddedAddress값 객체로 정의하고 컬럼 prefix로 펼친다.
  2. STI (Single Table Inheritance) — 한 테이블에 모든 자식의 컬럼을 두고 type 컬럼으로 구분.
  3. 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()    // 모든 type

Article.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 — 세 패턴 비교 매트릭스

결정 매트릭스

측면EmbeddedSTICTI
테이블 수1 (부모만)1 (부모만)N (자식별)
컬럼 sparse없음많음 (NULL)없음
NOT NULL 가능❌ (자식별 컬럼은 NULL 허용 강제)
다형성 조회N/A빠름 (한 SELECT)UNION ALL 필요
자식 추가 비용N/A (다른 개념)컬럼 추가 (sparse 증가)새 테이블
외래키 받기N/A한 테이블 가리킴각각 다른 테이블 가리킴
인덱스부모와 함께부모 + type자식별 독립
JPA 이름@EmbeddableSINGLE_TABLETABLE_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!: Address

address컬럼 묶음이지 외래키 관계가 아니다. 다른 엔티티에서 Address참조하려고 하면 불가능 — Address는 테이블이 아니다. 참조 가능한 것을 원하면 별도 @Entity() class Address 만들고 *@ManyToOne*으로.

함정 2) STI에서 자식 컬럼을 NOT NULL로 적기

@ChildEntity('video')
class Video extends Content {
  @Column()                              // ❌ NOT NULL이 되면 Article 저장 불가
  url!: string
}

STI는 한 테이블에 모든 자식 컬럼이 있다 → urlArticle 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도 관계 데코레이터로 표현 못 한다. 두 가지 우회:

  1. STI로 바꾸기Content 한 테이블이면 FK가 자연스럽다.
  2. 분리된 테이블 유지 + 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

요약: 세 패턴은 값 객체 묶음·다형성·정규화라는 다른 문제를 푼다. EmbeddedDDD의 값 객체컬럼 prefix로 펼친다 — 1:1 한정. STI다형성 조회가 잦은 도메인에 빠르다 — 자식 컬럼은 반드시 nullable. CTI의미가 분리된 자식에 정직하다 — 다형성 조회는 직접 UNION ALL. 다음 문서(06-entity-schema-alternative)는 이 모든 데코레이터를 거부하고 같은 메타를 클래스 밖에서 적는 길을 다룬다.