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 컬럼을 복합 인덱스의 선두에 포함하여 타입별 조회 성능을 확보하세요.
참고 자료