TypeORM Entity Inheritance

ORM에서 상속이 필요한 이유: 공통 필드와 다형성

실무에서 엔티티 간에 공통 필드가 반복되는 상황은 매우 흔합니다. createdAt, updatedAt, deletedAt 같은 감사 필드나, Notification의 여러 하위 타입(EmailNotification, PushNotification, SMSNotification) 등이 대표적입니다.

TypeORM은 세 가지 상속 전략을 제공합니다:

전략 테이블 구조 TypeORM 지원
Concrete Table Inheritance 하위 클래스마다 별도 테이블 부분 지원 (일반 상속으로 구현)
Single Table Inheritance (STI) 모든 하위 클래스가 하나의 테이블 @TableInheritance + @ChildEntity
Embedded Entities 공통 필드를 임베딩 (상속 아닌 조합) ❌ (TypeORM 미지원, MikroORM에서 지원)

이 아티클에서는 TypeORM이 공식 지원하는 패턴들을 깊이 다룹니다.

패턴 1: 추상 클래스 상속 — 공통 필드 재사용의 기본

가장 기본적이고 가장 많이 사용되는 패턴입니다. 추상 클래스에 공통 필드를 정의하고, 실제 엔티티가 이를 상속합니다. 각 엔티티는 독립적인 테이블을 가집니다.

// base.entity.ts — @Entity() 데코레이터 없음
import {
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  DeleteDateColumn,
  BaseEntity,
} from 'typeorm';

export abstract class BaseEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date | null;
}
// user.entity.ts
@Entity()
export class User extends BaseEntity {
  @Column()
  name: string;

  @Column({ unique: true })
  email: string;
}

// product.entity.ts
@Entity()
export class Product extends BaseEntity {
  @Column()
  title: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;
}

결과 테이블 구조:

-- user 테이블
CREATE TABLE "user" (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name VARCHAR NOT NULL,
  email VARCHAR UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP
);

-- product 테이블 (동일한 공통 컬럼 포함)
CREATE TABLE "product" (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title VARCHAR NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  deleted_at TIMESTAMP
);

추상 클래스 설계 팁

  • 추상 클래스에는 @Entity() 데코레이터를 붙이지 않습니다. 붙이면 별도 테이블이 생성됩니다.
  • 공통 메서드(soft delete 체크, JSON 직렬화 등)도 추상 클래스에 정의할 수 있습니다.
  • 여러 단계로 상속 가능: TimestampEntity → SoftDeleteEntity → User
// 다단계 상속
export abstract class TimestampEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

export abstract class SoftDeleteEntity extends TimestampEntity {
  @DeleteDateColumn()
  deletedAt: Date | null;

  get isDeleted(): boolean {
    return this.deletedAt !== null;
  }
}

@Entity()
export class Order extends SoftDeleteEntity {
  @Column()
  orderNo: string;

  @Column('decimal')
  totalPrice: number;
}

패턴 2: Single Table Inheritance (STI) — 하나의 테이블에 여러 타입

STI는 부모와 모든 자식 엔티티를 하나의 테이블에 저장하고, discriminator 컬럼으로 타입을 구분합니다.

// notification.entity.ts — 부모 엔티티
@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
export class Notification {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  recipientId: number;

  @Column()
  title: string;

  @Column({ type: 'text', nullable: true })
  body: string | null;

  @Column({ default: false })
  isRead: boolean;

  @CreateDateColumn()
  createdAt: Date;
}
// email-notification.entity.ts
@ChildEntity('email')
export class EmailNotification extends Notification {
  @Column()
  emailAddress: string;

  @Column({ nullable: true })
  cc: string | null;
}

// push-notification.entity.ts
@ChildEntity('push')
export class PushNotification extends Notification {
  @Column()
  deviceToken: string;

  @Column({ default: 'default' })
  sound: string;
}

// sms-notification.entity.ts
@ChildEntity('sms')
export class SMSNotification extends Notification {
  @Column()
  phoneNumber: string;
}

생성되는 테이블:

-- 단일 테이블에 모든 타입의 컬럼이 존재
CREATE TABLE "notification" (
  id SERIAL PRIMARY KEY,
  type VARCHAR NOT NULL,                -- discriminator
  recipient_id INT NOT NULL,
  title VARCHAR NOT NULL,
  body TEXT,
  is_read BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT now(),
  -- EmailNotification 전용
  email_address VARCHAR,                -- NULL 허용 (다른 타입에서는 비어있음)
  cc VARCHAR,
  -- PushNotification 전용
  device_token VARCHAR,
  sound VARCHAR DEFAULT 'default',
  -- SMSNotification 전용
  phone_number VARCHAR
);

STI 조회: 자동 타입 필터링

// 부모 엔티티로 조회 → 모든 타입 반환 (다형성)
const allNotifications = await notificationRepository.find({
  where: { recipientId: userId },
});
// EmailNotification, PushNotification, SMSNotification 인스턴스가 섞여서 반환

// 자식 엔티티로 조회 → 해당 타입만 반환
const emailNotifications = await emailNotificationRepository.find({
  where: { recipientId: userId },
});
// WHERE type = 'email' 조건이 자동 추가됨

// instanceof로 타입 분기
for (const notification of allNotifications) {
  if (notification instanceof EmailNotification) {
    console.log(notification.emailAddress);  // 타입 안전
  } else if (notification instanceof PushNotification) {
    console.log(notification.deviceToken);
  }
}

STI의 장점과 단점

장점 단점
단일 테이블이므로 JOIN 불필요 → 빠른 조회 타입별 고유 컬럼이 모두 NULL 허용이어야 함
모든 타입을 한 번에 조회 가능 (다형성) 타입이 많으면 컬럼 수가 급증 (테이블 비대화)
마이그레이션이 단순 (테이블 하나) DB 수준 NOT NULL 제약을 타입별로 걸 수 없음
discriminator 인덱스로 타입별 조회 최적화 디스크 공간 낭비 (NULL 컬럼들)

패턴 3: Concrete Table Inheritance — 각 타입이 독립 테이블

TypeORM은 Concrete Table Inheritance를 공식 데코레이터로 지원하지 않지만, 일반 TypeScript 클래스 상속으로 동일한 효과를 얻을 수 있습니다.

// 공통 필드 정의 (추상 클래스 — @Entity 없음)
export abstract class Payment {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('decimal', { precision: 10, scale: 2 })
  amount: number;

  @Column()
  orderId: number;

  @Column({ default: 'pending' })
  status: string;

  @CreateDateColumn()
  createdAt: Date;
}

// 각 타입이 독립 테이블
@Entity()
export class CardPayment extends Payment {
  @Column()
  cardLastFour: string;

  @Column()
  approvalNo: string;
}

@Entity()
export class BankTransfer extends Payment {
  @Column()
  bankCode: string;

  @Column()
  accountNo: string;
}

@Entity()
export class VirtualAccount extends Payment {
  @Column()
  virtualAccountNo: string;

  @Column()
  expiresAt: Date;
}

결과: card_payment, bank_transfer, virtual_account 세 개의 독립 테이블이 생성됩니다.

Concrete Table의 장점과 단점

장점 단점
각 테이블이 완전한 스키마 → NOT NULL 제약 가능 “모든 결제 조회” 시 UNION ALL 필요
테이블별 독립적 인덱스 최적화 공통 필드 변경 시 모든 테이블 마이그레이션 필요
NULL 컬럼 없음 → 디스크 효율적 TypeORM의 다형성 조회(부모 Repository)가 불가

세 가지 패턴 비교: 상황별 선택 가이드

기준 추상 클래스 상속 STI (@TableInheritance) Concrete Table
테이블 수 자식 클래스 수만큼 1개 자식 클래스 수만큼
다형성 조회 ❌ 불가 ✅ 부모 Repository로 전체 조회 ❌ 수동 UNION 필요
NOT NULL 제약 ✅ 가능 ❌ 타입별 고유 컬럼 불가 ✅ 가능
사용 사례 공통 필드 재사용만 필요 타입별 다형성 조회가 핵심 타입별 독립성이 중요
TypeORM 지원 일반 TS 상속 공식 지원 일반 TS 상속

STI 실전 함정: NULL 컬럼과 Validation

STI에서 가장 큰 문제는 타입별 고유 컬럼이 DB 수준에서 NOT NULL이 될 수 없다는 점입니다. EmailNotification의 emailAddress는 비즈니스적으로 필수이지만, PushNotification 행에서는 NULL이어야 하므로 DB에 NOT NULL을 걸 수 없습니다.

// 해결: class-validator로 애플리케이션 수준 검증
import { IsNotEmpty, IsEmail, ValidateIf } from 'class-validator';

@ChildEntity('email')
export class EmailNotification extends Notification {
  @Column({ nullable: true })  // DB는 nullable
  @IsNotEmpty()                 // 앱에서는 필수
  @IsEmail()
  emailAddress: string;
}
// DTO에서 타입별 Validation
export class CreateEmailNotificationDto {
  @IsNotEmpty()
  @IsEmail()
  emailAddress: string;         // 필수

  @IsOptional()
  cc?: string;
}

export class CreatePushNotificationDto {
  @IsNotEmpty()
  deviceToken: string;          // 필수

  @IsOptional()
  sound?: string;
}

STI + QueryBuilder: discriminator 활용

// 타입별 통계 집계
const stats = await notificationRepository
  .createQueryBuilder('n')
  .select('n.type', 'type')
  .addSelect('COUNT(*)', 'count')
  .addSelect('SUM(CASE WHEN n.isRead = true THEN 1 ELSE 0 END)', 'readCount')
  .where('n.recipientId = :userId', { userId })
  .groupBy('n.type')
  .getRawMany();

// 결과: [{ type: 'email', count: 42, readCount: 30 }, ...]
// discriminator 컬럼 직접 조건에 사용
const unreadPush = await notificationRepository
  .createQueryBuilder('n')
  .where('n.type = :type', { type: 'push' })
  .andWhere('n.isRead = false')
  .andWhere('n.recipientId = :userId', { userId })
  .orderBy('n.createdAt', 'DESC')
  .limit(20)
  .getMany();
// 반환 타입은 Notification이지만 실제 인스턴스는 PushNotification

STI 인덱스 전략

@Entity()
@TableInheritance({ column: { type: 'varchar', name: 'type' } })
@Index(['type', 'recipientId'])          // 타입별 조회 최적화
@Index(['recipientId', 'isRead'])        // 사용자별 미읽음 조회
@Index(['type', 'createdAt'])            // 타입별 시간순 조회
export class Notification {
  // ...
}

핵심: discriminator 컬럼(type)을 복합 인덱스의 선두 컬럼에 포함하면, 자식 Repository로 조회할 때 자동 추가되는 WHERE type = 'email' 조건이 인덱스를 탈 수 있습니다.

Migration에서의 STI: 새 타입 추가

// 새 자식 엔티티 추가: InAppNotification
@ChildEntity('inapp')
export class InAppNotification extends Notification {
  @Column({ nullable: true })
  deepLink: string | null;

  @Column({ type: 'jsonb', nullable: true })
  actionButtons: Record<string, any> | null;
}
-- 자동 생성되는 마이그레이션 (컬럼 추가만)
ALTER TABLE "notification" ADD COLUMN "deep_link" VARCHAR;
ALTER TABLE "notification" ADD COLUMN "action_buttons" JSONB;
-- 테이블 생성 불필요 — 기존 테이블에 컬럼만 추가

STI의 마이그레이션 장점: 새 타입을 추가할 때 테이블 생성 없이 컬럼만 추가하면 됩니다. 반면 Concrete Table에서는 새 테이블을 생성하고, 관련된 모든 쿼리와 서비스를 수정해야 합니다.

실무 판단 플로우차트

공통 필드만 재사용하면 되는가?
  ├── YES → 추상 클래스 상속 (가장 단순)
  └── NO → 모든 타입을 한 번에 조회해야 하는가?
              ├── YES → 타입별 고유 컬럼이 3개 이하인가?
              │           ├── YES → STI (@TableInheritance)
              │           └── NO → STI는 컬럼 비대화 → Concrete Table + 수동 UNION 고려
              └── NO → Concrete Table (각 타입 독립 테이블)

핵심 정리

  • 추상 클래스 상속은 가장 단순하고 가장 많이 사용됩니다. 공통 필드(timestamp, soft delete 등) 재사용이 목적이라면 이것으로 충분합니다.
  • STI(@TableInheritance)는 “모든 알림을 시간순으로 조회”처럼 다형성 조회가 핵심일 때 선택합니다. 단, 타입별 고유 컬럼이 많으면 테이블이 비대해집니다.
  • STI의 타입별 고유 컬럼은 DB에 NOT NULL을 걸 수 없으므로 class-validator 등 애플리케이션 수준 검증이 필수입니다.
  • Concrete Table은 타입별 독립성이 중요하고, 다형성 조회가 드물 때 적합합니다.
  • STI 사용 시 discriminator 컬럼을 복합 인덱스의 선두에 포함하여 타입별 조회 성능을 확보하세요.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux