TypeORM vs Prisma 비교 | 타입 안전성·마이그레이션·쿼리·성능 실전 가이드
이 글의 핵심
Node.js에서 TypeORM과 Prisma를 타입·마이그레이션·쿼리로 비교하고, 연결 풀·PgBouncer·엔진·락처럼 운영에서 터지는 축을 추가로 짚습니다.
처음 TypeORM 쓸 때 나도 그랬어요. ERD에 선 하나 그어지면 @ManyToOne이랑 @OneToMany부터 손이 가잖아요. 유저에 주문이 달리고, 주문에 아이템이 달리고, 아이템에 상품·옵션·쿠폰·리뷰가 달리고… 다 엮어두면 “객체 지향적으로 깔끔하다”는 느낌이 드는데, 막상 리스트 API 하나 치면 leftJoinAndSelect가 줄줄이 붙고, 응답 JSON 만들려고 relations만 세 번 돌다가 N+1에 커넥션 풀 바닥 나는 그림이 나와요. 그때 깨닫죠. 관계 너무 걸지 마세요. DB에 외래키는 있어도, ORM 엔티티에 꼭 다 박을 필요는 없다는 걸요. userId만 들고 있고, 진짜 조인이 필요한 화면만 QueryBuilder나 Raw, 아니면 뷰로 때우는 쪽이 훨씬 사는 게 편해요. TypeORM이 “JPA 느낌”을 주는 대신, 그 함정(과한 매핑)도 같이 가져온다는 말이에요.
Prisma 쪽은 겉이 달라요. schema.prisma가 진실이고, prisma generate로 타입이 딱 떨어지니까 “타입 끊김이 덜하다”는 말이 나오죠. 대신 include로 감싸다 보면 Prisma도 똑같이 “한 번에 다 가져올 수 있어!”에 취해서 리스트 쿼리가 늪이 될 수 있어요. 쿼리 횟수·행 수·불필요한 조인이 문제지, “ORM이 누가 나쁘다”가 아니에요. 둘 다 스키마를 누가 주인인지(코드냐 DB냐) 싸움이 있고, 그 차이는 Prisma는 한 파일·한 파이프에 스키마·마이그레이션·클라이언트를 묶는 쪽에 강하고, TypeORM은 데코레이터로 클래스가 테이블이 되는 흐름에 익숙한 팀이면 생산성이 잘 맞는 편이에요.
돌아가는 걸 꼽자면, Prisma는 Node 안에서 러스트 엔진 붙이는 구조라서 이미지·플랫폼 binaryTargets 안 맞으면 CI에서 “엔진 로딩”부터 터질 수 있고, PgBouncer transaction 모드랑 prep statement 얘기가 자주 붙어요. 그때는 문서에 나온 대로 directUrl·연결 파라미터·마이그레이션 전용 URL 분리를 하면 됩니다. TypeORM은 pg 풀 설정(extra의 max 같은 것)이랑, QueryRunner 잡고 놓쳐서 풀 누수 나는 케이스—트랜잭션을 길게 붙잡는 코드—이 실전에서 더 자주 찍혀요. 둘 다 배포가 겹치면 마이그레이션 락·대기 이슈는 똑같고, “대용량 DDL을 배포 몇 분 전에” 같은 건 ORM 떠나서 DBA랑 머리 맞대야 합니다.
이 글에선 Drizzle과 Kysely까지 엮어, 네 가지 툴이 각각 어느 축(스키마 주도, SQL 친화, 러스틱/바이너리, 쿼리 빌더 순수성)에 서 있는지 정리하겠습니다. 숫자로 이기는 “단일 승자”는 없고, 워크로드·팀 취향·인프라에 맞는 선택이 승리합니다.
TypeORM, Prisma, Drizzle, Kysely: 역할이 조금씩 다릅니다
| 도구 | 한 줄 | 스키마 소스 | 런타임 특징 |
| --- | --- | --- | --- |
| TypeORM | 데코레이터·엔티티·Repository/QueryBuilder | 주로 TS 클래스(또는 엔티티 + 동기화) | 전통적 ORM, Active Record/Repository |
| Prisma | schema.prisma + 생성 클라이언트 + Migrate | schema.prisma가 단일 진실 | 러스트 쿼리 엔진, 바이너리 배포 이슈 가능 |
| Drizzle | 스키마를 TS/SQL로 쓰고, Drizzle 쿼리 API | schema.ts + SQL-like 체이닝 | 가볍고 번들/Edge에 강한 편 |
| Kysely | “타입이 있는 SQL” 쿼리 빌더 | DB 스키마는 마이그레이션/별도 도구와 분리되기 쉬움 | ORM이 아니라 쿼리 빌더에 가깝다 |
Drizzle은 ORM처럼 불리지만 “SQL에 가깝고 얇은 계층”에 가깝고, Kysely는 조인·서브쿼리를 손으로 조립하되 타입으로 안전망을 씌우는 쪽이에요. Prisma/TypeORM은 “객체/관계” 추상이 두껍고, Drizzle/Kysely는 “내가 쓴 SQL이 무엇인지” 투명도가 높은 편입니다.
짧은 코드로 보는 쿼리 스타일
Prisma는 관계·필터를 선언적으로 묶고, select로 불필요한 컬럼을 거를 수 있어요.
// Prisma: 스키마 기반, include/select로 형태를 고정
const users = await prisma.user.findMany({
where: { active: true },
select: { id: true, email: true, posts: { select: { id: true, title: true } } },
take: 20,
});
TypeORM은 QueryBuilder로 조인·별칭을 직접 쌓는 패턴이 흔해요(문자열 alias에 타입이 새는 지점이기도 합니다).
// TypeORM: QueryBuilder — 유연하나 팀 룰 없으면 런타임 이슈로 새기 쉬움
const users = await dataSource
.getRepository(User)
.createQueryBuilder("u")
.leftJoinAndSelect("u.posts", "p")
.where("u.active = :active", { active: true })
.select(["u.id", "u.email", "p.id", "p.title"])
.take(20)
.getMany();
Drizzle은 schema 정의와 db.select가 한 흐름으로 읽혀, 번들/Edge 쪽 예제가 많아요.
// Drizzle: SQL에 가깝고 가벼움(프로젝트 설정에 따라 import 경로 다름)
import { eq } from "drizzle-orm";
import { users, posts } from "./schema";
const rows = await db
.select({ id: users.id, email: users.email, postId: posts.id, title: posts.title })
.from(users)
.leftJoin(posts, eq(posts.userId, users.id))
.where(eq(users.active, true))
.limit(20);
Kysely는 “테이블 타입을 정의”하고, 메서드 체인이 SQL을 닮게 갑니다(ORM의 관계 자동화는 직접 풀어야 합니다).
// Kysely: 타입 안전 쿼리 빌더 — 스키마 타입을 직접 유지
const rows = await kysely
.selectFrom("users")
.leftJoin("posts", "posts.userId", "users.id")
.select(["users.id as userId", "users.email", "posts.id as postId", "posts.title"])
.where("users.active", "=", true)
.limit(20)
.execute();
타입 안정성: “컴파일이 잡아주는 것”의 폭
-
Prisma
prisma generate이후UserWhereInput,UserSelect등이 자동으로 맞춰집니다. 스키마 변경 → 재생성 → TS 에러의 루프가 명확해요. Raw/$queryRaw는 여전히 Escape 계약을 지키는 쪽이 안전하고,Prisma.sql템플릿이 도움이 됩니다. -
TypeORM
엔티티 클래스 기반은 좋은데, QueryBuilder의 문자열
alias·getRawMany()는 타입이 느슨해질 수 있어요.strictPropertyInitialization·래퍼 타입·DTO 검증(예: zod)을 병행하는 팀이 많습니다. -
Drizzle
schema를 TS로 두면 컬럼명이 리터럴로 잡혀, 잘못된 컬럼 조합이 빨리 드러나는 편이에요. “관계”는 Prisma만큼 자동이 아니라서, 팀이 익숙한 SQL 모델이 있으면 강합니다. -
Kysely
Database 인터페이스에 테이블/컬럼 타입을 정의하는 패턴이 핵심이에요. 조인/서브쿼리의 타입 추론이 꽤 강하지만, 스키마 정의를 동기화시키는 비용(마이그레이션 도구와의 결합)이 따릅니다.
요약하면, “스키마를 한곳에 두고 생성”은 Prisma가 앞서고, “SQL에 가깝게 타입만 씌운다”는 Kysely/Drizzle 축이 강해요. TypeORM은 팀의 사용 패턴에 따라 상·하한 폭이 큽니다.
쿼리 성능·벤치마크: 절대값보다 “경향”을 본다
공개 벤치마크는 하드웨어·Node 버전·쿼리 모양·풀·네트워크 RTT에 민감해서, “A가 B보다 3배” 같은 숫자를 그대로 믿기는 어렵습니다. 대신 실무에서 자주 보는 경향은 다음과 같아요.
-
단순 PK 조회
ORM/빌더 오버헤드는 대체로 작고, 병목은 DB·네트워크·인덱스인 경우가 많아요.
-
복잡한 조인·집계
“한 번에 잘 쓴 SQL”이 이기는 경우가 많고, ORM이 자동 생성한 쿼리가 이상한 플랜을 타면 Prisma/TypeORM 모두 튜닝이 필요해요.
-
N+1
Prisma
include나 TypeORMrelations는 편하지만, 리스트 + 관계를 무분별하게 켜면 쿼리 수 폭발이 납니다.$transaction+in로 묶기, DataLoader, 뷰/CTE 같은 패턴이 공통 처방이에요. -
Drizzle/Kysely
작성한 쿼리가 그대로 드러가므로, EXPLAIN으로 플랜을 읽는 습관이 있으면 튜닝이 수월한 편입니다.
그래서 벤치마크 숫자 하나로 승자를 가르기보다, 프로파일(APM)·슬로 쿼리 로그로 팀의 실제 워크로드를 보는 쪽이 공정하고 재현 가능합니다.
마이그레이션 전략 비교
| 전략 | Prisma | TypeORM | Drizzle | Kysely |
| --- | --- | --- | --- | --- |
| 스키마 ↔ 마이그 | migrate + schema.prisma | 엔티티 기반/역방향, 기록 일관성은 팀 의존 | drizzle-kit로 SQL/TS 스키마 동기화 | 주로 kysely-migration-cli 등 별도 CLI + SQL 파일 |
| 리뷰 문화 | migrations 폴더 SQL diff가 읽기 쉬움 | 생성물 손댐·환경 diff가 쌓이면 꼬이기 쉬움 | SQL이 명시적 | 순수 SQL에 가깝 |
| zero-downtime | 대형 DDL은 DBA와 별도 설계(ORM 공통) | 동일 | 동일 | 동일 |
실무 팁
-
프리뷰/스테이징에 같은 마이그레이션 히스토리를 먼저 적용해 보고, 롤백/호환(expand-contract)을 문서화합니다.
-
PgBouncer·풀 뒤에 있으면, Prisma·TypeORM 둘 다 연결 모드/프리페어드 스테이트먼트 이슈를 봅니다(앞의 FAQ·운영 축).
DX(개발자 경험): 문서, IDE, “첫 러닝 커브”
-
Prisma
문서·예제·스타터가 풍부하고,
format·studio·마이그레이션 루프가 한 제품처럼 느껴져요. 대신generate·바이너리는 CI 캐시·멀티 아키텍처를 염두에 둡니다. -
TypeORM
Nest·레거시 자료가 많고, “엔티티 먼저” 팀엔 익숙한 UX예요. 다만 버전·옵션이 늘면 설정이 길어지는 느낌이 있을 수 있어요.
-
Drizzle
가볍고, “SQL답다”는 만족감. 스키마·relation을 직접 잡는 초기 비용이 있습니다.
-
Kysely
타입 만족도가 높은 편이나, “마이그레이션+스키마 타입”을 누가 유지할지 팀 합의가 필요해요.
Edge Runtime(Cloudflare Workers 등) 지원
-
Drizzle
drizzle-orm+ 드라이버 조합(HTTP 기반 D1, Tursolibsql, WASM 등) 예제가 많고, Edge 친화 흐름이 잘 잡혀 있어요. -
Prisma
driverAdapters·Data Proxy/Accelerate·Edge 타깃이 진화 중이고, 런타임/제품 조합마다 제약(파일시스템, 바이너리)을 문서로 확인하는 게 좋아요. -
Kysely
“번들” 자체는 가벼울 수 있으나, Node 전용
pg러너와 Edge(불가능/제한)는 다릅니다. Edge용 클라이언트·어댑터를 따로 잡는 설계가 필요해요. -
TypeORM
전통적 Node+풀·트랜잭션에 가깝고, Workers 네이티브로 가져가기엔 제약이 큰 편이에요(대개 별 API/BFF에 두는 식).
여기서 “Edge를 쓰면 무조건 Drizzle”이 아니라, 데이터베이스에 어떻게 붙는지(HTTP, TCP, D1, Pooler)에 따라 결정이 갈립니다.
장단점 매트릭스(한 장 요약)
| 항목 | TypeORM | Prisma | Drizzle | Kysely |
| --- | --- | --- | --- | --- |
| 학습/레퍼런스(레거시 포함) | ◎ Nest·자료多 | ◎ 문서·스타터 | △ 늘어나는 중 | △ 쿼리빌더 중심 |
| 스키마 단일성 | △ 팀by팀 | ◎ | ○~◎ | △(직접 정의) |
| 복잡 쿼리/튜닝 | ○ QB·Raw | ○(한계 시 Raw) | ◎ SQL답 | ◎ |
| Edge·경량 배포 | △ | △(조건부) | ◎ | △(환경 의존) |
| N+1·관계 기본기 | “편한 대신” 함정 | 동일 | 수동 | 수동 |
| 이진/CI 이슈 | △(적음) | 주의(타깃) | △(적음) | △(적음) |
(◎·○·△는 대략적 상대 평가이며, 팀 숙련도에 따라 뒤집힐 수 있어요.)
프로젝트 선택 가이드
-
스키마 주도 + 마이그레이션 일관성 + 풀 제품같은 DX → Prisma 후보.
-
Nest + 데코레이터 + 기존 TypeORM → 비용이 크지 않다면 이행보다 점진적 개선이 가성비 좋을 때가 많아요.
-
SQL 투명도·Edge·번들이 중요 → Drizzle 후보(스키마/관계 정의는 계획과 함께).
-
“타입이 있는 SQL”만 원하고 ORM 마법은 싫다 → Kysely + 마이그레이션 툴을 분리해 설계.
-
리포트/BI급 SQL이 중심이면, 어느 쪽이든 Raw/뷰/별 스키마로 분리하는 하이브리드가 흔해요.
의사결정 체크리스트로는 (a) 기존 코드 자산, (b) PgBouncer/풀/멀티 리전, (c) Edge 배포 유무, (d) 팀의 SQL 역량, (e) 컴파일·CI 시간까지 한 번에 적어 보는 걸 추천해요.
실제 마이그레이션 경험담(합성 사례)
한 중규모 BFF는 TypeORM+QueryBuilder로 4년을 굴렸어요. 리스트 API마다 leftJoin이 쌓이고, 일부는 getRawMany()로 타입이 비어 “런타임 DTO”만 믿는 코드가 늘었죠. 팀이 Prisma로 일부 읽기 전용 API만 옮기기로 하고, 주문/결제 같이 트랜잭션·락이 민감한 곳은 당분간 TypeORM에 두었어요(리스크를 나눈 거예요). 스키마는 schema.prisma로 재정의했고, 마이그레이션 히스토리는 “과거 TypeORM + 신규 Prisma”를 동시에 유지하긴 괴로워서, 스테이징에서 migrate diff를 반복하며 한 번에 정리하는 주막을 가졌습니다.
그다음 턴에 Edge 실험을 위해 읽기 경로 일부를 Drizzle+HTTP 엔드포인트로 옮기는 PoC를 했는데, 여기서는 “Prisma냐 Drizzle이냐”보다 DB 연결 모델(풀 vs HTTP)이 먼저 정리돼야 했어요. Kysely는 백오피스 전용 서비스에서 도입돼, 복잡한 집계를 타입으로 잡는 데 쓰였습니다—ORM 전체를 대체하진 않았어요.
교훈은 세 가지예요. (1) 빅뱅 전환은 히스토리·롤백·런북이 없으면 위험하다. (2) 읽기/쓰기 경로를 나누어 점진 이행할 수 있으면 부담이 줄어든다. (3) 도구 싸움 전에, 앞서 말한 엔티티 관계·N+1부터 줄이면 ROI가 더 큽니다.
Prisma·TypeORM은 생성/QueryBuilder 흐름에 따라 타입이 달라지고, escape된 raw나 QueryBuilder로 감싸는 하이브리드가 리포트·레거시에선 가장 덜 스트레스인 경우가 많아요. 신규에 뭐 쓰냐고만 따지면, 팀이 스키마 주도·빨리 DX 챙기자면 Prisma 쪽이 무난한 편이고, 데코레이터·Nest·기존 TypeORM 박혀 있으면 굳이 갈아엎을 이유는 없죠. 그보다 먼저, 엔티티에 화살표 몇 개를 그을지부터 줄이는 쪽이 제일 ROI가 좋다—그게 이 글에서 제일 강조하고 싶은 소리예요. 더 파고 싶으면 Prisma 완벽 가이드랑 시리즈 목차 정도 보면 돼요. Drizzle 쪽 냄새는 Drizzle ORM 심화에 있고요.