🗄️ TypeORM8. 이론 & 대안 (Prisma · Drizzle · MikroORM)04 — TypeORM vs MikroORM (가장 가까운 친척)

04 — TypeORM vs MikroORM (가장 가까운 친척)

한 줄 답: MikroORM은 TypeORM과 가장 가까운 친척이다 — 둘 다 데코레이터 기반 DataMapper. 다른 점은 MikroORM이 진짜 Unit of Work와 Identity Map을 구현했다는 것. DDD·복잡 도메인·트랜잭션이 본질이면 MikroORM, NestJS 통합·익숙함·간단 CRUD면 TypeORM.


Why — 왜 MikroORM이 TypeORM의 그림자에서 자라났나

MikroORM은 Martin Adámek이 2018년 TypeORM의 한계를 메우려 시작했다. 그의 README를 보면:

“I was using TypeORM for a long time, but I was always missing the real Unit of Work and Identity Map. So I built MikroORM.”

그래서 MikroORM은 처음부터 Doctrine (PHP) + Hibernate (Java)의 정통 DataMapperTypeScript로 옮기는 것이 목표였다. 결과적으로:

항목TypeORMMikroORM
패턴 분류DataMapper (느슨)DataMapper (정통)
Unit of Work없음 (명시적 save)완전 구현 (em.flush)
Identity Map부분완전 구현
Persistent State 추적없음있음 (managed/new/removed/detached)
변경 감지 (dirty checking)없음있음
Lazy LoadingPromise<T> 타입Proxy 기반 자동
Bytecode-style enhancement없음Proxy로 모방

한 줄로: MikroORM은 Hibernate를 TypeScript로 옮긴 것이고, TypeORM은 Hibernate를 살짝 닮은 데코레이터 ORM이다.


How — 두 도구의 근본 메커니즘이 어떻게 다른가

1) TypeORM — 명시적 save

const user = await userRepo.findOne({ where: { id: 1 } })
const order = await orderRepo.findOne({ where: { id: 100 } })
 
user.email = 'new@example.com'
order.status = 'paid'
 
// ← 두 번의 *명시적 save*
await userRepo.save(user)
await orderRepo.save(order)
 
// 트랜잭션으로 묶으려면 *명시적 트랜잭션 블록* 필요
await dataSource.transaction(async (manager) => {
  await manager.save(user)
  await manager.save(order)
})

2) MikroORM — Unit of Work 기반 flush

const user = await em.findOne(User, 1)
const order = await em.findOne(Order, 100)
 
user.email = 'new@example.com'
order.status = 'paid'
 
// ← em.flush() 한 번으로 *모든 변경*이 *한 트랜잭션*에 묶임
await em.flush()
// → 내부적으로:
//   BEGIN
//   UPDATE users SET email = 'new@example.com' WHERE id = 1
//   UPDATE orders SET status = 'paid' WHERE id = 100
//   COMMIT
  • 핵심: user.email = ...을 호출한 순간 MikroORM이 그 변경을 추적한다 (Proxy 기반).
  • em.flush() 시점에 변경된 객체들을 모아 한 트랜잭션의 SQL로 변환.
  • 이게 Hibernate의 정통 동작이고, TypeORM이 못 한 부분.

3) Identity Map의 동작 차이

TypeORM:

const a = await userRepo.findOne({ where: { id: 1 } })
const b = await userRepo.findOne({ where: { id: 1 } })
console.log(a === b)   // ← false (다른 객체)

MikroORM:

const a = await em.findOne(User, 1)
const b = await em.findOne(User, 1)
console.log(a === b)   // ← true (같은 객체 — Identity Map)
a.email = 'x@x.com'
console.log(b.email)   // ← 'x@x.com' (a와 b가 *동일 객체*라 즉시 반영)

What — 구체적 시나리오로 비교

시나리오 A — 여러 엔티티 변경 후 한 트랜잭션

TypeORM:

await dataSource.transaction(async (manager) => {
  const user = await manager.findOne(User, { where: { id: 1 } })
  user.email = 'x@x.com'
  await manager.save(user)   // ← 명시적
 
  const order = await manager.findOne(Order, { where: { id: 100 } })
  order.status = 'paid'
  await manager.save(order)   // ← 명시적
})

MikroORM:

const user = await em.findOne(User, 1)
const order = await em.findOne(Order, 100)
user.email = 'x@x.com'
order.status = 'paid'
await em.flush()   // ← 한 번

판정: MikroORM이 압도적으로 간결. 큰 도메인 작업에서 코드 라인 수가 절반. 특히 서비스 레이어에서 차이가 극명.

시나리오 B — N+1 자동 방지

TypeORM (기본):

const posts = await postRepo.find()
for (const post of posts) {
  console.log(post.author.name)
  // ← 각 post마다 *별도 쿼리* (lazy 또는 누락 시 에러)
}

MikroORM (auto-flush + auto-populate):

const posts = await em.find(Post, {}, { populate: ['author'] })
for (const post of posts) {
  console.log(post.author.name)
  // ← 자동으로 join된 결과. *N+1 없음*
}

판정: 둘 다 명시적 populate/relations가 필요. MikroORM이 살짝 더 직관적이지만 큰 차이는 아님.

시나리오 C — 도메인 객체의 진짜 변경 감지

TypeORM:

const user = await userRepo.findOne({ where: { id: 1 } })
user.email = 'x@x.com'
// ← save를 *명시적으로* 호출 안 하면 DB에 *반영 안 됨*

MikroORM:

const user = await em.findOne(User, 1)
user.email = 'x@x.com'
await em.flush()   // ← *Proxy가 user의 변경을 추적*하다가 flush 시 SQL 생성
// → 또는 RequestContext 미들웨어로 *요청 끝에서 자동 flush* 가능

판정: MikroORM이 정통 ORM. Hibernate를 써본 사람에게는 집에 온 느낌이다.

시나리오 D — NestJS 통합

TypeORM:

@Module({
  imports: [TypeOrmModule.forRoot({ ... }), TypeOrmModule.forFeature([User])],
  // ...
})
// ← *NestJS 1급 통합*. NestJS 공식 문서가 TypeORM 예제로 가득.

MikroORM:

@Module({
  imports: [MikroOrmModule.forRoot({ ... }), MikroOrmModule.forFeature([User])],
  // ...
})
// ← *@mikro-orm/nestjs*도 잘 만들어져 있지만 *생태계 자료가 적다*.

판정: TypeORM 우세. NestJS 진영의 기본 선택지 자리는 여전히 TypeORM. 채용·온보딩·자료 접근성에서 차이.

시나리오 E — 학습 곡선

TypeORM: 데코레이터 + Repository.find만 알면 3시간에 CRUD 가능. MikroORM: EntityManager + UnitOfWork + IdentityMap + RequestContext를 이해해야 진짜 가치가 나온다. 3일 학습.

판정: 작은 팀·짧은 일정은 TypeORM, 큰 도메인·긴 호흡은 MikroORM.


What-if — 잘못 선택하면

1) 간단 CRUD인데 MikroORM

→ Unit of Work·Identity Map의 학습 비용만 지불하고 가치를 못 본다. TypeORM의 단순함이 더 적합. 대응: 도메인이 얕고 CRUD 위주면 TypeORM.

2) DDD/CQRS인데 TypeORM

→ Aggregate root의 consistency boundaryUnit of Work 없이 짜는 게 반복적 보일러플레이트. 한 Aggregate의 모든 변경을 명시적 save 호출로 동기화해야. 대응: 진지한 DDD면 MikroORM 또는 Hibernate-like 도구.

3) NestJS의 모든 모듈을 쓰는데 MikroORM

@nestjs/typeorm이 들어간 third-party 모듈들을 교체해야. 2~3주 추가 작업. 대응: NestJS 모듈 생태계를 활용하려면 TypeORM.

4) 변경 추적의 예측 가능성이 매출인데 MikroORM

→ Unit of Work는 flush 시점이 곧 SQL 시점. 언제 flush되는지 헷갈리면 트랜잭션 문제 발생. 대응: 명시적 flush만 사용하고 auto-flush는 끄기.

5) “MikroORM이 무조건 옳다”는 과신

→ 작은 팀이 MikroORM의 정통성에 끌려 학습 비용만 지불하고 제품이 늦어진다. 대응: 팀의 ORM 경험도메인 복잡도에 맞춰 선택.


Insight — 흥미로운 이야기

”Doctrine의 TypeScript 환생”

MikroORM의 직접적 영감Doctrine ORM (PHP)이다. Doctrine는 Hibernate를 PHP로 옮긴 ORM이고 — *Unit of Work, Identity Map, em.flush()*의 동작이 거의 그대로다. Martin Adámek은 Doctrine 경험자였다.

이게 MikroORM의 API 설계가 TypeORM과 비슷한데 다른 이유다 — 데코레이터 모양은 같지만 EntityManager의 동작완전히 다르다. em.flush()는 Doctrine의 EntityManager::flush() 그대로.

”TypeORM은 Unit of Work를 안 만들었나”

TypeORM 0.3 마이그레이션 가이드를 보면 *“명시성”*이라는 단어가 반복된다. Umed Khudoiberdiev의 입장은:

“TypeScript에서 변경 추적은 Proxy밖에 없는데, Proxy는 너무 많은 곳에서 깨진다. 명시적 save가 TypeScript 가치관에 맞다.”

MikroORM은 그 Proxy 한계받아들이고 구현한다 — wrap(entity).toReference(), wrap(entity).init() 같은 명시적 helper를 제공해 Proxy의 한계를 우회. 철학적 선택의 차이다.

”GitHub stars의 격차”

2024년 기준:

  • TypeORM: ~33k stars, ~5.5k forks
  • MikroORM: ~8k stars, ~570 forks

격차는 4배. 하지만 MikroORM 커뮤니티의 issue 응답 속도Discord 활성도는 TypeORM보다 높다. 작지만 진지한 커뮤니티 — DDD를 진지하게 하는 팀들이 모인다.

특히 Nest.js Reddit/Discord에서 *“진지한 도메인 모델이면 MikroORM”*이라는 권장이 자주 보인다.


요약 + 다이어그램

TypeORM vs MikroORM = 느슨한 DataMapper vs 정통 DataMapper. MikroORM은 진짜 Unit of Work + Identity Map을 구현했다 — TypeORM이 되고 싶었지만 못 한 부분. DDD·복잡 도메인·트랜잭션이 본질이면 MikroORM, NestJS·익숙함·간단 CRUD면 TypeORM.

다음 문서: 05-typeorm-vs-sequelize.mdxJavaScript 시대의 ORM과 비교하면?