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의 한계를 만나면 QueryBuilderSQL 추상의 손잡이를 다시 손에 쥐어준다.


What — 구체 사양

TypeORM이 정의하는

영역내용
데코레이터@Entity, @Column, @PrimaryGeneratedColumn, @ManyToOne, @OneToMany, @OneToOne, @ManyToMany, @JoinColumn, @Index, @Unique, @Tree 등 60여 개
DataSource연결 정보 + 엔티티 목록 + 마이그레이션 설정 + 로깅 등
Repositoryfind, findOne, save, update, delete, softDelete, count, createQueryBuilder
QueryBuilderselect, from, where, leftJoinAndSelect, subQuery, addSelect, raw 등
TransactiondataSource.transaction(), @Transaction (deprecated), QueryRunner
Migrationmigration:generate, migration:run, migration:revert
지원 DBPostgreSQL, 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.jsonexperimentalDecorators: trueemitDecoratorMetadata: true둘 다 있는지 확인. SWC 사용 시 swcrcdecorators: truedecoratorMetadata: 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와 결혼했나?