06 · EntitySchema — 데코레이터를 쓰지 않는 대안

이 문서가 답하는 질문: TypeORM은 데코레이터 없이도 같은 엔티티 매핑을 적을 수 있다 — EntitySchema다. 이런 길이 필요하고, 어떻게 데코레이터와 같은 결과를 얻는가? 한 줄 답 (Pyramid Top): EntitySchema데코레이터 없이 같은 메타를 클래스 밖 객체 리터럴로 적는 길이다 — POJO 도메인 모델 유지·DI 충돌 회피·런타임 동적 스키마에 쓴다.”


Why — 데코레이터를 거부하고 싶은 세 가지 이유

데코레이터는 편리하지만 세 가지 비용을 청구한다:

1) 도메인 모델이 TypeORM에 강결합

@Entity()                           // ← TypeORM에 묶임
class User {
  @PrimaryGeneratedColumn('uuid')   // ← TypeORM에 묶임
  id!: string
 
  @Column()                         // ← TypeORM에 묶임
  name!: string
}

클린 아키텍처헥사고날 아키텍처에서는 도메인 모델이 인프라를 모르는 것이 원칙이다. 위 코드는 도메인이 영속성 프레임워크에 의존한다 — 원칙 위반.

2) DI 컨테이너와의 메타데이터 충돌

tsyringe·InversifyJS 같은 DI 컨테이너도 Reflect.metadata자기 메타를 박는다. 같은 클래스에 두 라이브러리의 데코레이터가 동시에 붙으면 순서·키 충돌이 가끔 일어난다.

3) 런타임 동적 스키마

마이그레이션 도구·multi-tenant 시스템에서는 런타임에 엔티티를 생성해야 할 때가 있다. 데코레이터는 클래스 정의 시점에만 동작 — 동적 생성은 불편하다.

해결책: EntitySchema. 같은 메타를 클래스 밖 객체로 적는다.


How — EntitySchema의 형태

기본 형태 — 데코레이터와 1:1 비교

데코레이터:

@Entity({ name: 'users' })
class User {
  @PrimaryGeneratedColumn('uuid') id!: string
  @Column({ length: 100 }) name!: string
  @Column({ unique: true }) email!: string
  @CreateDateColumn() createdAt!: Date
}

EntitySchema:

import { EntitySchema } from 'typeorm'
 
// 도메인 모델 — POJO, TypeORM 의존성 없음
interface User {
  id: string
  name: string
  email: string
  createdAt: Date
}
 
// 인프라 — 스키마 정의
export const UserSchema = new EntitySchema<User>({
  name: 'User',                     // 엔티티 이름
  tableName: 'users',
  columns: {
    id: {
      type: 'uuid',
      primary: true,
      generated: 'uuid',
    },
    name: {
      type: 'varchar',
      length: 100,
    },
    email: {
      type: 'varchar',
      unique: true,
    },
    createdAt: {
      type: 'timestamp with time zone',
      createDate: true,             // = @CreateDateColumn
    },
  },
})

사용:

const dataSource = new DataSource({
  /* ... */
  entities: [UserSchema],           // EntitySchema 인스턴스를 그대로
})
 
const userRepo = dataSource.getRepository<User>(UserSchema)
const users = await userRepo.find()  // User[] 타입

클래스로 두고 싶다면

class User {
  id!: string
  name!: string
  email!: string
  createdAt!: Date
}
 
export const UserSchema = new EntitySchema<User>({
  name: 'User',
  target: User,                     // ← 클래스 연결
  tableName: 'users',
  columns: { /* 위와 동일 */ },
})

target을 적으면 결과 객체가 User 인스턴스가 된다 — 메서드를 정의할 수 있다.

class User {
  id!: string
  name!: string
  email!: string
  createdAt!: Date
 
  getDisplayName() {                // 도메인 메서드 (TypeORM 무관)
    return `${this.name} <${this.email}>`
  }
}
 
const user = await userRepo.findOneBy({ id: 'X' })
console.log(user.getDisplayName())  // ✅ 동작

What — EntitySchema의 모든 필드

시그니처

class EntitySchema<T = any> {
  constructor(options: EntitySchemaOptions<T>)
}
 
interface EntitySchemaOptions<T> {
  name: string                            // 엔티티 이름 (필수)
  target?: Function                       // 클래스 연결 (선택)
  tableName?: string                      // DB 테이블명
  schema?: string
  database?: string
  synchronize?: boolean
  type?: 'regular' | 'view' | 'closure'   // 'view'면 ViewEntity
  expression?: string                     // view용 SQL
  columns: { [K in keyof T]?: EntitySchemaColumnOptions }
  relations?: { [K in keyof T]?: EntitySchemaRelationOptions }
  indices?: EntitySchemaIndexOptions[]
  uniques?: EntitySchemaUniqueOptions[]
  checks?: EntitySchemaCheckOptions[]
  exclusions?: EntitySchemaExclusionOptions[]
}

컬럼 옵션 — 데코레이터와 대응

데코레이터EntitySchemaColumnOptions 필드
@PrimaryColumn()primary: true
@PrimaryGeneratedColumn()primary: true, generated: 'increment'
@PrimaryGeneratedColumn('uuid')primary: true, generated: 'uuid'
@Column({ type, length, ... })type, length, ...
@Column({ nullable: true })nullable: true
@Column({ default: X })default: X
@Column({ unique: true })unique: true
@Column({ select: false })select: false
@Column({ comment })comment
@CreateDateColumn()createDate: true
@UpdateDateColumn()updateDate: true
@DeleteDateColumn()deleteDate: true
@VersionColumn()version: true

관계 — relations 필드

interface Post {
  id: string
  title: string
  author: User
}
 
export const PostSchema = new EntitySchema<Post>({
  name: 'Post',
  columns: {
    id: { type: 'uuid', primary: true, generated: 'uuid' },
    title: { type: 'varchar' },
  },
  relations: {
    author: {
      type: 'many-to-one',
      target: 'User',                 // 또는 () => UserSchema
      joinColumn: { name: 'author_id' },
      inverseSide: 'posts',
      eager: false,
      cascade: false,
      onDelete: 'CASCADE',
    },
  },
})

각 관계 타입은 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many' 문자열로 표현.

Embedded — embeddeds로 안 가고 컬럼 펼치기

EntitySchema에는 embedded 별도 필드가 없다. 같은 prefix 컬럼을 직접 적는다:

interface User {
  id: string
  street: string
  city: string
  zip: string
}
 
const UserSchema = new EntitySchema<User>({
  name: 'User',
  columns: {
    id: { type: 'uuid', primary: true, generated: 'uuid' },
    street: { type: 'varchar' },
    city: { type: 'varchar' },
    zip: { type: 'varchar', length: 10 },
  },
})

값 객체를 코드 차원에서는 묶고 싶다팩토리 함수로 옵션을 합성:

const addressColumns = (prefix = '') => ({
  [`${prefix}street`]: { type: 'varchar' },
  [`${prefix}city`]: { type: 'varchar' },
  [`${prefix}zip`]: { type: 'varchar', length: 10 },
} as const)
 
const OrderSchema = new EntitySchema({
  name: 'Order',
  columns: {
    id: { type: 'uuid', primary: true, generated: 'uuid' },
    ...addressColumns('shipping_'),
    ...addressColumns('billing_'),
  },
})

인덱스·유니크·체크

const UserSchema = new EntitySchema<User>({
  name: 'User',
  columns: { /* ... */ },
  indices: [
    { name: 'idx_user_email', columns: ['email'], unique: true },
    { name: 'idx_user_name', columns: ['name'] },
  ],
  uniques: [
    { name: 'uq_user_email_tenant', columns: ['email', 'tenantId'] },
  ],
  checks: [
    { expression: 'age >= 0' },
  ],
})

View Entity

const UserPostCountSchema = new EntitySchema({
  name: 'UserPostCount',
  type: 'view',
  expression: `
    SELECT u.id AS user_id, COUNT(p.id) AS post_count
    FROM users u LEFT JOIN posts p ON p.author_id = u.id
    GROUP BY u.id
  `,
  columns: {
    userId: { type: 'uuid', primary: true },
    postCount: { type: 'int' },
  },
})

What-if — 자주 틀리는 패턴

함정 1) name을 빼먹기

new EntitySchema({
  // name: 'User',         ❌ 필수
  columns: { /* ... */ },
})

name반드시 적어야 한다. 데코레이터는 클래스명을 자동으로 쓰지만, EntitySchema명시가 원칙이다.

함정 2) target 없이 클래스 메서드 기대

class User {
  getDisplayName() { return this.name }
}
 
const UserSchema = new EntitySchema({
  name: 'User',
  // target: User,         ❌ 없으면 결과가 plain object
  columns: { /* ... */ },
})
 
const u = await repo.findOneBy({ id: 'X' })
u.getDisplayName()                  // ❌ undefined is not a function

해결: target: User추가하라.

함정 3) 데코레이터와 EntitySchema동시에 쓰기

@Entity()
class User {
  @PrimaryGeneratedColumn('uuid') id!: string
}
 
const UserSchema = new EntitySchema({
  name: 'User',
  target: User,                     // ❌ 같은 클래스에 두 메타
  columns: { /* ... */ },
})
 
new DataSource({ entities: [User, UserSchema] })

같은 클래스에 두 종류의 메타를 동시에 박지 마라. 한쪽으로 통일하라.

함정 4) 관계의 target문자열로 잘못 적기

relations: {
  author: {
    type: 'many-to-one',
    target: 'user',         // ❌ EntitySchema의 'name'과 일치해야
  },
}

target은 *대상 엔티티의 name*이거나 클래스 함수거나 () => EntitySchema. 위 경우 'user'가 아니라 'User'(name 그대로).

함정 5) 마이그레이션 생성이 데코레이터와 다르게 나옴

schema:syncmigration:generate둘 다 동작하지만 — 컬럼 순서·이름 규칙이 미묘하게 다를 수 있다. 한 프로젝트에서 둘을 섞으면 마이그레이션 diff가 통제 불가능해진다. 한 프로젝트는 한 방식.

함정 6) NestJS의 @nestjs/typeorm 호환성

NestJS의 TypeOrmModule.forFeature([User])데코레이터 기반 엔티티를 가정한다 — EntitySchema를 넘기면 동작은 하지만 문서가 적다. 회사 표준에 NestJS가 있으면 데코레이터가 사실상 더 안전하다.


Insight — 흥미로운 이야기

EntitySchema데코레이터 spec의 운명에 대한 헷지”

TypeScript의 experimental decorators는 2015년 도입 이래 Stage 2-3을 떠돌고 있다. 2022년 Stage 3 decorators spec이 정착했지만 — 이전 spec과 호환 안 됨. 모든 TypeORM 데코레이터legacy spec이라 언젠가는 옮겨야 한다. 그날이 오면 EntitySchema유일한 호환 가능한 대안이 된다. 메타가 데코레이터에 묶이지 않고 객체 리터럴에 있기 때문이다. Drizzle ORM데코레이터를 완전히 거부하고 빌더 패턴으로 간 이유, Prisma.prisma 파일로 메타를 분리한 이유 — 모두 같은 데코레이터 spec 불안에서 출발한다. EntitySchemaTypeORM이 자기 안에 둔 미래 보험이다.

“클린 아키텍처 진영이 EntitySchema를 사실상 표준으로 쓴다”

Domain-Driven DesignHexagonal Architecture도메인 모델이 인프라를 모른다는 원칙을 가진다. @Entity 한 줄이 그 원칙을 위반한다 — 도메인 클래스가 TypeORM에 의존하기 때문이다. 그래서 큰 NestJS 프로젝트들 중 일부는:

  • src/domain/User.ts — POJO 클래스 (TypeORM 무관)
  • src/infrastructure/typeorm/UserSchema.tsEntitySchema

이렇게 물리적으로 분리한다. 도메인 코드는 TypeORM 없이도 단위 테스트할 수 있고, 영속성을 다른 ORM으로 갈아끼울 수 있다. 데코레이터의 편의를 포기하면 아키텍처적 자유가 돌아온다 — 그 자유의 값이 충분한 팀만 이 길을 간다.


요약 + Mermaid

요약: EntitySchema데코레이터 없이 같은 매핑을 적는 공식 대안이다. 도메인 모델을 POJO로 유지하거나 DI 충돌·동적 스키마가 필요할 때 쓴다. 데코레이터와 섞지 말고 한 프로젝트는 한 방식. NestJS 표준 흐름은 데코레이터 — 회사 표준이 NestJS면 데코레이터가 현실적으로 더 안전하다.


챕터 마무리: 이 챕터(01-entity-decorators)는 6 문서에 걸쳐 @Entity 한 줄에 숨은 6개의 결정 — 테이블 등록·컬럼 타입·PK 전략·시스템 컬럼·재사용 패턴·데코레이터 대안 — 을 분해했다. 다음 챕터(02-relations)는 엔티티들을 어떻게 잇는가@OneToOne/@OneToMany/@ManyToOne/@ManyToMany와 JoinColumn·로딩 전략을 다룬다.