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:sync나 migration: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 불안에서 출발한다.EntitySchema는 TypeORM이 자기 안에 둔 미래 보험이다.
“클린 아키텍처 진영이 EntitySchema를 사실상 표준으로 쓴다”
Domain-Driven Design과 Hexagonal Architecture는 도메인 모델이 인프라를 모른다는 원칙을 가진다.
@Entity한 줄이 그 원칙을 위반한다 — 도메인 클래스가 TypeORM에 의존하기 때문이다. 그래서 큰 NestJS 프로젝트들 중 일부는:
src/domain/User.ts— POJO 클래스 (TypeORM 무관)src/infrastructure/typeorm/UserSchema.ts—EntitySchema이렇게 물리적으로 분리한다. 도메인 코드는 TypeORM 없이도 단위 테스트할 수 있고, 영속성을 다른 ORM으로 갈아끼울 수 있다. 데코레이터의 편의를 포기하면 아키텍처적 자유가 돌아온다 — 그 자유의 값이 충분한 팀만 이 길을 간다.
요약 + Mermaid
요약:
EntitySchema는 데코레이터 없이 같은 매핑을 적는 공식 대안이다. 도메인 모델을 POJO로 유지하거나 DI 충돌·동적 스키마가 필요할 때 쓴다. 데코레이터와 섞지 말고 한 프로젝트는 한 방식. NestJS 표준 흐름은 데코레이터 — 회사 표준이 NestJS면 데코레이터가 현실적으로 더 안전하다.
챕터 마무리: 이 챕터(
01-entity-decorators)는 6 문서에 걸쳐@Entity한 줄에 숨은 6개의 결정 — 테이블 등록·컬럼 타입·PK 전략·시스템 컬럼·재사용 패턴·데코레이터 대안 — 을 분해했다. 다음 챕터(02-relations)는 엔티티들을 어떻게 잇는가 —@OneToOne/@OneToMany/@ManyToOne/@ManyToMany와 JoinColumn·로딩 전략을 다룬다.