01 — What is TypeORM
이 문서가 답하는 질문: TypeORM은 도대체 무엇이며, ‘데코레이터 ORM’이라는 표현 안에는 어떤 것들이 들어 있나? 한 줄 답 (Pyramid Top): “TypeORM은 데코레이터로 클래스에 메타데이터를 박아 컴파일 타임 타입과 런타임 메타를 동시에 얻는 DataMapper ORM이다 — 데코레이터·reflect-metadata·Repository 셋이 한 묶음이다.”
한 문장 답 (Pyramid Top)
TypeORM은 세 가지를 하나로 묶은 라이브러리다 — ① 데코레이터(
@Entity,@Column)로 클래스에 메타를 박는 패러다임, ②reflect-metadata로 그 메타를 런타임에 읽는 메커니즘, ③Repository<T>·DataSource로 메타를 SQL로 번역하는 API 표면. 이 셋이 동시에 성립해야 TypeORM이다 — 하나라도 빠지면 다른 ORM이 된다.
챕터 지도 (Mermaid)
세 가지가 한 자리에 모인다 — 코드, 메타 저장소, 런타임 빌더.
Why — 왜 이 정의가 중요한가
TypeORM을 처음 만나는 사람은 거의 항상 다음 셋 중 하나로 오해한다.
| 흔한 오해 | 현실 |
|---|---|
| ”TypeORM = TypeScript용 Hibernate” | 영감은 Hibernate지만 Unit of Work를 의도적으로 빼고 DataMapper만 가져왔다. MikroORM이 오히려 Hibernate에 가깝다. |
| ”TypeORM = 데코레이터 라이브러리” | 데코레이터는 메타를 박는 표면일 뿐. 진짜 핵심은 런타임에 그 메타를 그래프로 만드는 빌더다. |
| ”ActiveRecord와 DataMapper 둘 다 동등하게 지원한다” | 공식 문서는 두 모드를 같이 소개하지만, DataMapper가 권장이고 ActiveRecord는 호환용 옵션이다. |
이 셋을 분리하면 “왜 @Column() 한 줄이 마법처럼 동작하나?”(메타 박기 + 빌더 두 단계로 나뉜다), “왜 user.save() 대신 repo.save(user)인가?”(도메인 객체가 영속성을 모르게 하는 설계), “왜 TypeORM은 트랜잭션을 자동으로 묶어주지 않나?”(Unit of Work를 의도적으로 뺐기 때문) 같은 질문들이 동시에 풀린다.
How — 어떻게 동작하나
1) 세 개의 분리된 레이어
TypeORM을 한 단어로 부르지만, 실제로는 세 레이어가 순서대로 작동한다.
- Layer 1 — Declaration: 개발자가 클래스에 박는 데코레이터. 이 시점엔 SQL은 아직 없다.
- Layer 2 — Reflection: 클래스가 로드되는 순간 데코레이터가 실행되어
MetadataArgsStorage라는 전역 저장소에 인자들이 쌓인다. - Layer 3 — Translation:
DataSource.initialize()가 호출되면 그 저장소를 읽어 EntityMetadata 그래프를 만들고, 이후 모든 SQL은 이 그래프에서 생성된다.
데코레이터는 마법이 아니라 함수 호출이고, 그 함수가 하는 일은 글로벌 저장소에 줄을 추가하는 것이다.
2) DataMapper — 도메인이 영속성을 모른다
TypeORM이 권장하는 패턴은 DataMapper다.
// DataMapper (권장)
const user = new User();
user.name = "Alice";
await dataSource.getRepository(User).save(user);
// ActiveRecord (호환 옵션)
const user = new User();
user.name = "Alice";
await user.save(); // User가 BaseEntity를 상속해야 함차이는 작아 보이지만 큰 설계 결정이다 — DataMapper에서는 User 클래스가 데이터베이스의 존재를 모른다. 영속성을 아는 것은 Repository고, 도메인 객체는 데이터 + 행동만 가진다.
| 패턴 | 누가 영속성을 아는가 | 테스트 격리 | 도메인 순수성 |
|---|---|---|---|
DataMapper (repo.save(user)) | Repository | 쉬움 (repo만 mock) | 높음 |
ActiveRecord (user.save()) | Entity 자체 | 어려움 (DB 전역 의존) | 낮음 |
TypeORM 공식 문서는 “대규모 앱에서는 DataMapper를 추천한다” 고 명시한다.
3) DataSource — 연결의 단일 정체성
0.2.x까지 있던 getConnection()/createConnection()은 0.3.x에서 DataSource로 통합됐다.
import { DataSource } from "typeorm";
const AppDataSource = new DataSource({
type: "postgres",
host: "localhost",
port: 5432,
username: "test",
password: "test",
database: "test",
entities: [User, Post],
synchronize: false, // 프로덕션에서는 반드시 false
});
await AppDataSource.initialize();
const userRepo = AppDataSource.getRepository(User);DataSource는 연결 풀 + 메타데이터 그래프 + Repository 팩토리가 한 객체에 묶인 형태다. 앱 하나에 여러 개를 가질 수 있다 (multi-tenant 등).
4) Repository — 제네릭 게이트웨이
Repository<T>는 엔티티 한 종류에 대한 모든 CRUD를 표준화한다.
const userRepo = AppDataSource.getRepository(User);
// 읽기
const user = await userRepo.findOne({ where: { id: 1 } });
const users = await userRepo.find({ where: { active: true }, take: 10 });
// 쓰기
await userRepo.save({ name: "Alice" });
await userRepo.update({ id: 1 }, { name: "Bob" });
await userRepo.delete({ id: 1 });
// 더 복잡한 쿼리는 QueryBuilder
const result = await userRepo
.createQueryBuilder("u")
.leftJoinAndSelect("u.posts", "p")
.where("u.id = :id", { id: 1 })
.getOne();Repository는 얇은 게이트웨이고, find의 한계를 만나면 QueryBuilder가 SQL 추상의 손잡이를 다시 손에 쥐어준다.
What — 구체 사양
TypeORM이 정의하는 것
| 영역 | 내용 |
|---|---|
| 데코레이터 | @Entity, @Column, @PrimaryGeneratedColumn, @ManyToOne, @OneToMany, @OneToOne, @ManyToMany, @JoinColumn, @Index, @Unique, @Tree 등 60여 개 |
| DataSource | 연결 정보 + 엔티티 목록 + 마이그레이션 설정 + 로깅 등 |
| Repository | find, findOne, save, update, delete, softDelete, count, createQueryBuilder 등 |
| QueryBuilder | select, from, where, leftJoinAndSelect, subQuery, addSelect, raw 등 |
| Transaction | dataSource.transaction(), @Transaction (deprecated), QueryRunner |
| Migration | migration:generate, migration:run, migration:revert |
| 지원 DB | PostgreSQL, MySQL/MariaDB, SQLite, MS SQL Server, Oracle, MongoDB, SAP HANA, CockroachDB, Spanner, Better-SQLite3 등 |
TypeORM이 정의하지 않는 것
| 영역 | 대안 |
|---|---|
Schema-first DSL (Prisma schema.prisma 같은 것) | 없음 — 클래스가 곧 스키마 |
| Unit of Work (자동 dirty tracking 후 일괄 flush) | 없음 — 명시적 save() 필요. MikroORM은 가진다. |
| 컴파일 타임 SQL 검증 (Drizzle/Kysely 같은) | 없음 — 런타임에 메타 그래프로만 검증 |
| 자동 시드 데이터 | 없음 — 마이그레이션에 직접 SQL |
| 강한 타입의 raw SQL | 약함 — query()는 any[] 반환 |
→ 즉 TypeORM은 “클래스에 박은 메타로 CRUD와 마이그레이션을 만든다” 만 한다. 그 외는 생태계나 사용자가 채워야 한다.
ActiveRecord vs DataMapper 비교
// ActiveRecord 모드
import { BaseEntity, Entity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn() id!: number;
@Column() name!: string;
}
const u = new User();
u.name = "Alice";
await u.save();
const found = await User.findOne({ where: { id: 1 } });// DataMapper 모드 (권장)
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn() id!: number;
@Column() name!: string;
}
const repo = AppDataSource.getRepository(User);
const u = repo.create({ name: "Alice" });
await repo.save(u);
const found = await repo.findOne({ where: { id: 1 } });같은 코드량이지만 의존 방향이 정반대다.
What-if — 잘못 이해하면
1) “데코레이터는 마법”이라고 믿으면
→ experimentalDecorators: false로 빌드하거나 SWC/esbuild 설정 실수 시 모든 엔티티가 빈 객체가 되는데 원인을 못 찾는다.
대응: tsconfig.json에 experimentalDecorators: true와 emitDecoratorMetadata: true가 둘 다 있는지 확인. SWC 사용 시 swcrc에 decorators: true와 decoratorMetadata: true 필요.
2) ActiveRecord 모드를 기본으로 쓰면
→ 도메인 객체가 DB를 알아버려 테스트 시 매번 실제 DB나 in-memory SQLite가 필요해진다.
대응: 새 프로젝트는 처음부터 DataMapper로. BaseEntity 상속 금지를 ESLint로 강제.
3) synchronize: true를 프로덕션에 켜면
→ 엔티티를 잘못 수정한 한 줄이 컬럼을 DROP시킨다. 데이터는 영구 손실된다.
대응: 프로덕션은 반드시 synchronize: false + migration:generate + migration:run. (자세한 건 06-migration-schema)
4) getRepository()를 매 요청마다 새로 호출하는 게 비싸다고 오해하면
→ Repository를 전역 캐시하다가 DataSource가 닫힌 후에도 사용해 connection 누수 발생.
대응: Repository는 가벼운 핸들이고 매번 새로 받아도 비용 거의 없다. DataSource만 한 번 init하면 된다.
5) save()가 항상 update라고 믿으면
→ repo.save(entity)는 id가 있으면 update, 없으면 insert다. 명시적으로 분리하려면 insert()/update()를 따로 쓴다.
대응: 의도가 명확할 땐 insert()/update(). save()는 Upsert 의도에만.
Insight — 흥미로운 이야기
”Hibernate의 영감, 그러나 Unit of Work는 의도적으로 뺐다”
Umed Khudoiberdiev가 2016년 TypeORM을 시작할 때 영감을 명시적으로 인정한 라이브러리는 *Hibernate(Java)와 Doctrine(PHP)*였다. 그러나 핵심 추상 중 Unit of Work는 옮기지 않았다 — JavaScript에서 Proxy 기반 dirty tracking을 강제하면 디버깅이 어려워지고, 명시적 save()가 더 예측 가능하다는 판단이었다. 이 결정이 2020년 MikroORM이 “Unit of Work를 가진 TypeORM 대안”이라는 포지셔닝으로 등장한 이유다.
”왜 @Column()은 함수 호출 형태인가”
@Column 대신 @Column()로 반드시 괄호가 붙는다. 이유는 데코레이터 spec에서 데코레이터 팩토리(factory) 와 데코레이터 자체를 구분하기 때문이다 — @Column만 쓰면 그것은 데코레이터 함수 자체고, @Column()는 데코레이터를 반환하는 팩토리다. TypeORM은 옵션을 받기 위해 팩토리 형태를 채택했고, 그래서 빈 괄호라도 반드시 필요하다.
”왜 reflect-metadata가 polyfill인가”
Reflect.metadata는 원래 JavaScript 표준이 아니다 — TC39 Reflect 메타데이터 제안서 Stage 0에서 멈춰 있다. 그런데도 TypeScript가 emitDecoratorMetadata: true일 때 Reflect.metadata 호출을 자동 삽입하기 때문에, reflect-metadata 라이브러리를 글로벌에 한 번 import 해야 한다. TypeORM의 모든 진입점은 import "reflect-metadata"로 시작한다 — 이 한 줄을 빼면 모든 데코레이터가 무용지물이다.
요약 + 다이어그램
TypeORM은 데코레이터 + reflect-metadata + DataMapper 셋의 묶음이다. 클래스에 데코레이터를 박으면 런타임 메타 저장소에 쌓이고,
DataSource.initialize()가 이를 그래프로 만들어 Repository와 QueryBuilder의 SQL 생성에 쓴다. ActiveRecord 모드도 있지만 호환 옵션이고, DataMapper가 권장이다.
다음 문서:
02-history-and-ecosystem.mdx— 이 ORM은 누가 만들었고, 왜 NestJS와 결혼했나?