TypeORM과 Prisma 비교하기

제이슨·2024년 12월 26일
1

개요

새로 참여하게 된 사이드 프로젝트 똑소에서 백엔드 리드 개발자님께 "TypeORM에서 Prisma로 마이그레이션"하는 것에 대한 리서치를 해달라고 하셨다.

옛날에는 TypeORM을 사용하다가 1년 전쯤부터 계속 Prisma만 사용하고 있어서, TypeORM과 Prisma가 ORM의 역할에 비추어 볼 때 어떻게 다른지 알아야 합리적인 의사결정을 할 수 있을 것 같아 이 글을 작성하게 되었다.

ORM이 무엇인지부터 알아보자.

ORM이란 무엇인가

ORM은 객체 지향 프로그래밍(OOP)의 객체와 관계형 데이터베이스(RDB)의 데이터를 이어주는 기술이다.
다르게 말하자면, 객체 지향 프로그래밍의 '객체' 세계와 관계형 데이터베이스의 '데이터' 세계를 이어주는 다리이다.

그렇다면 매핑이 왜 필요할까? 객체 지향 프로그래밍과 관계형 데이터베이스의 사상 차이(객체 중심 <-> 관계 중심) 때문이다.

ORM을 사용하는 가장 중요한 목적은 '객체와 관계형 모델 간의 불일치 해결'에 있지만, 아래와 같은 추가적인 이점도 있다.

  • SQL 작성 없이 친숙한 프로그래밍 언어로 데이터베이스를 다룰 수 있다.
  • 데이터베이스 변경 자동화: 수동으로 데이터베이스를 업데이트하거나 별도의 관리 도구를 만들 필요가 없다.
  • 체계적인 버전 관리: 마이그레이션 메커니즘을 통해 데이터베이스 스키마 변경 이력을 체계적으로 관리할 수 있다.

이제 Node.js 진영의 ORM을 살펴보자. 이 글에서는 전통적으로 많이 사용되던 TypeORM과, 2023년을 기점으로 급부상한 Prisma를 비교해보겠다.

TypeORM이란?

TypeORM은 Code-First ORM이다.

Code-First ORM이란 무엇일까? 엔티티 클래스를 만들고, 클래스 프로퍼티에 데코레이터를 추가한다.

TypeORM 데코레이터?
데코레이터는 ORM에 해당 객체, 속성 등의 메타데이터를 전달하는 역할을 한다.
예를 들어, Entity는 클래스 레벨에 사용되며, "해당 클래스는 데이터베이스 테이블과 매핑되는 엔티티야!"를 알려주는 역할이다.

이렇게 코드를 먼저 작성하고 ORM은 데코레이터가 제공한 메타테이터를 사용해 클래스를 데이터베이스 데이터와 매핑하게 되기 때문에 TypeORM은 Code-First ORM이라 할 수 있다.

// TypeORM 엔티티 예시
@Entity()
class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
}

이러한 방식은 TypeORM 뿐 아니라 Java 진영의 JPA 방식과 유사하다.

Prisma란?

Prisma는 Schema-First ORM이다.
코드에 데코레이터를 사용하여 ORM에 메타데이터를 전달하는 TypeORM과 달리, Prisma는 schema.prisma 파일에 데이터베이스 스키마를 명세하는 방식을 취한다.

// 데이터베이스 연결 정보
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 모델(테이블) 구조와 관계 정의
model User {
  id      Int      @id @default(autoincrement())
  name    String
}

명세하는 정보는

  • 데이터베이스 연결 정보
  • 각 모델(테이블)의 구조
  • 모델 간의 관계
  • 필드의 타입과 제약조건
    가 있으며, 특수한 경우가 아니라면 스키마 파일 하나에서 모든 것을 정의해놓고 사용한다.

이후 prisma generate 명령어를 사용하면, Prisma는 Type-Safe한 클라이언트를 자동으로 생성하며, 이 클라이언트를 통해 서비스 내에 정의된 모든 스키마를 관리할 수 있다.

TypeORM - Prisma 쿼리 비교

쿼리 비교 코드 출처: Prisma 공식 문서 - TypeORM vs Prisma ORM

Read 시나리오

심플한 select 쿼리

게시글의 제목과 ID만 필요한 상황을 가정해보자. 전체 게시글 데이터가 아닌 이 두 필드만 조회하고 싶다면 각 ORM에서는 어떻게 할까?

// TypeORM
const posts = await postRepository.find({
  where: { published: true },
  // 예시에서 나온 구형(deprecated) 방식
  //  select: ['id', 'title']
  // 요즘 TypeORM에서 select할 때 사용하는 방식
  select: {
    id: true,
    title: true
  }
});

// Prisma
const posts = await prisma.post.findMany({
  where: { published: true },
  select: {
    id: true,
    title: true
  }
});

매우 비슷하지만, TypeORM에서는 각 엔티티마다 해당 엔티티를 관리하도록 구성된 자체 리포지토리 클래스가 있다. 반면에, Prisma는 클라이언트 하위 필드로 게시글을 조회한다는 차이가 있다.

한 가지 예시를 더 살펴보자. 이번에는 게시글과 함께 해당 게시글을 작성한 사용자 정보도 필요한 상황을 가정해보자.

// TypeORM
const posts = await postRepository.find({
  where: { published: true },
  relations: {
    author: true
  }
});

// Prisma
const posts = await prisma.post.findMany({
  where: { published: true },
  include: {
    author: true
  }
});

언뜻 보기에는 두 ORM의 구현 방식이 매우 비슷해 보이지만, 내부적으로는 흥미로운 차이가 있다.

쿼리 실행 방식을 살펴보면: TypeORM은 JOIN을 사용해 한 번에 모든 정보를 가져온다. 게시글과 작성자 정보를 하나의 쿼리로 조회하는 것이다.

반면 Prisma는 조금 다른 접근 방식을 취한다. 먼저 게시글 목록을 가져오고, 그 다음에 작성자 정보를 가져오는 두 단계를 거친다. 재미있는 점은, 여러 게시글을 조회하더라도 항상 쿼리는 딱 두 번만 실행된다는 것이다. 한 번은 게시글을 위해, 또 한 번은 모든 작성자 정보를 위해서다. 흔히 걱정하는 N+1 문제 대신 깔끔한 1+1 방식인 셈이다.

타입 시스템에서도 두 ORM은 큰 차이를 보인다: TypeORM을 사용할 때는 조심해야 할 부분이 있다. relations에 author를 지정하지 않았는데도 타입 시스템은 마치 author 필드가 항상 존재하는 것처럼 동작한다. 하지만 실제 실행 시에는 author가 undefined로 설정되어 있어서 예상치 못한 문제가 발생할 수 있다.

반면 Prisma는 타입과 실제 데이터가 항상 일치하도록 보장한다. include에 author를 넣었는지 여부에 따라 타입이 정확하게 반영된다. author 정보를 포함했다면 타입에도 author 필드가 있고, 포함하지 않았다면 타입에서도 author 필드가 없다. 실제 데이터가 어떤 모습일지 타입만 보고도 정확히 알 수 있는 것이다!

중첩 join 쿼리

이번에는 좀 더 복잡한 중첩 조인 상황을 살펴보자. 좋아요가 10개 이상인 게시글과 그 게시글의 댓글, 그리고 댓글 작성자 정보까지 한번에 가져와야 하는 상황을 가정해보자.

// TypeORM
const posts = await postRepository
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.comments', 'comment')
  .leftJoinAndSelect('comment.author', 'author')
  .innerJoin('post.likes', 'likes')
  .where('likes.count > 10');

// Prisma
const posts = await prisma.post.findMany({
  where: {
    likes: {
      count: {
        gt: 10
      }
    }
  },
  include: {
    comments: {
      include: {
        author: true
      }
    },
    likes: true
  }
})

여기서도 두 ORM의 접근 방식이 상당히 다르다는 것을 알 수 있다. TypeORM은 SQL과 매우 유사한 방식으로 쿼리를 작성한다. QueryBuilder를 사용해 명시적으로 각각의 조인을 지정하고, where 절도 SQL 스타일로 작성한다.

반면 Prisma는 관계형 데이터 모델을 더 자연스럽게 표현한다. 중첩된 include를 사용해 원하는 관계를 계층적으로 표현할 수 있고, where 절도 객체 구조를 따라 직관적으로 작성할 수 있다.

한 가지 주목할 만한 점은 Prisma 5.8.0부터는 관계 쿼리의 실행 방식을 쿼리별로 제어할 수 있는 preview 기능을 제공한다는 것이다. relationLoadStrategy 옵션을 통해 관계 데이터를 불러오는 전략을 선택할 수 있다:

const posts = await prisma.post.findMany({
  relationLoadStrategy: 'join', // 또는 'query'
  where: {
    likes: {
      count: {
        gt: 10
      }
    }
  },
  include: {
    comments: {
      include: {
        author: true
      }
    },
    likes: true
  }
})

이 옵션은 두 가지 전략을 제공한다:

  • join: 데이터베이스 레벨의 LATERAL JOIN(PostgreSQL) 또는 상관 서브쿼리(MySQL)를 사용하여 단일 쿼리로 모든 데이터를 가져온다.
  • query: 테이블당 하나의 쿼리를 실행하고 애플리케이션 레벨에서 데이터를 조합한다.

상황에 따라 적절한 전략을 선택함으로써 성능을 최적화할 수 있다는 점이 이 기능의 큰 장점이지만? 아직 실험적인 기능이기 때문에 개인 프로젝트가 아닌 이상 이 기능을 사용하지는 않을 것 같다.

Create 시나리오

이번에는 유저를 생성하는 시나리오를 생각해보자.

TypeORM을 사용한다면, 아래와 같은 방식을 통해 유저를 생성할 수 있다.

// 1. 엔티티 인스턴스를 생성한 후 저장하는 방식 (Active Record)
const user = new User()
user.name = 'Alice'
user.email = 'alice@prisma.io'
await user.save()

// 2. Repository를 통해 인스턴스를 생성하고 저장하는 방식 (Mixed)
const userRepository = getRepository(User)
const user = await userRepository.create({
  name: 'Alice',
  email: 'alice@prisma.io',
})
await user.save()

// 3. Repository의 단일 insert 메서드를 사용하는 방식 (Repository)
const userRepository = getRepository(User)
await userRepository.insert({
  name: 'Alice',
  email: 'alice@prisma.io',
})

보통 TypeORM을 사용한 많은 사이드 프로젝트를 보면 save를 통해서 객체를 생성하는 경우를 많이 볼 수 있는데, save 메서드는

  1. 먼저 SELECT 쿼리로 기존 엔티티를 검색한다.
  2. 1단계에서 레코드가 발견되면 UPDATE를, 그렇지 않으면 INSERT 쿼리를 실행한다.

즉, upsert처럼 동작하는 것이다!! 따라서, TypeORM에서는 의도하는 게

  • 새로운 데이터를 삽입하는 것이라면 -> insert를 사용해 데이터베이스에 데이터 저장
  • 기존 데이터를 업데이트하는 것이라면 -> update를 통해 데이터베이스에 데이터 저장

하는 방식이 부하를 줄일 수 있는 방식이라고 할 수 있다.

Prisma를 사용한다면, 아래와 같은 방식을 통해 유저를 생성할 수 있다.

const newUser = await prisma.user.create({
  data: {
    name: 'Alice',
    email: 'alice@prisma.io'
  },
})

만약, 개발자가 TypeORM의 save와 같은 upsert(update or insert) 행위를 의도한다면, prisma는 upsert 함수를 지원한다(save보다 upsert라는 네이밍이 더 명확하게 느껴진다)!!!

const newUser = await prisma.user.upsert({
 where: {
   email: 'alice@prisma.io'
 },
 update: {
   name: 'Alice',
 },
 create: {
   name: 'Alice',
   email: 'alice@prisma.io'
 },
})

Update 시나리오

이번에는 유저를 수정하는 시나리오를 생각해보자.
TypeORM을 사용한다면, 아래와 같은 방식을 통해 유저를 수정할 수 있다.

// 1. 엔티티 인스턴스를 찾은 후 수정하고 저장하는 방식 (Active Record)
const user = await User.findOneBy({ id: 2 })
user.name = 'James'
user.email = 'james@prisma.io'
await user.save()

// 2. Repository를 통해 인스턴스를 찾고 수정한 후 저장하는 방식 (Mixed)
const userRepository = getRepository(User)
const user = await userRepository.findOneBy({ id: 2 })
user.name = 'James'
user.email = 'james@prisma.io'
await user.save() // -> 업데이트된 entity 객체 반환

// 3. Repository의 단일 update 메서드를 사용하는 방식 (Repository)
const userRepository = getRepository(User)
// -> '데이터베이스 작업 결과[영향받은 행의 수(affected),
//      데이터베이스가 반한환 원시 결괏값(row)]'반환
await userRepository.update(2, {
  name: 'James',
  email: 'james@prisma.io',
}) 

create 시나리오와 마찬가지로, TypeORM을 사용한 많은 사이드 프로젝트에서 save를 통해 객체를 수정하는 경우가 많은데, 이는 불필요한 조회 쿼리를 발생시킨다. save 메서드는
1. 먼저 SELECT 쿼리로 기존 엔티티를 검색한다.
2. 1단계에서 레코드가 발견되면 UPDATE를, 그렇지 않으면 INSERT 쿼리를 실행한다.

즉, 단순 수정임에도 조회와 수정 쿼리가 모두 발생하는 것이다!! 따라서, TypeORM에서 update 작업을 할 때는 Repository의 update 메서드를 사용하는 것이 더 효율적이다.

Prisma를 사용한다면, 아래와 같은 방식을 통해 유저를 수정할 수 있다.

const updatedUser = await prisma.user.update({
  where: {
    id: 2
  },
  data: {
    name: 'James',
    email: 'james@prisma.io'
  },
  // select:
})

Prisma는 update 함수를 통해 명시적으로 데이터 수정 의도를 표현할 수 있다. MySQL을 사용할 경우 내부적으로는 UPDATE 쿼리와 SELECT 쿼리가 실행되지만, 이는 업데이트된 전체 데이터를 반환하기 위한 것으로, TypeORM의 save 메서드처럼 업데이트 여부를 판단하기 위한 사전 조회는 아니다.

요약하자면, TypeORM을 사용하는 경우
1. SELECT 쿼리 1방
2. UPDATE 쿼리 1방

나가고

Prisma를 사용하는 경우
1. UPDATE 쿼리 1방
-> 이 때, 업데이트할 데이터가 없으면 prisma 에러가 발생하므로 적절한 예외 처리는 필수다.
2. SELECT 쿼리 1방

나간다.

Delete 시나리오

이번에는 유저를 삭제하는 시나리오를 생각해보자.
TypeORM을 사용한다면, 아래와 같은 방식을 통해 유저를 삭제할 수 있다.

Hard Delete

// 1. 엔티티 인스턴스를 찾은 후 삭제하는 방식 (Active Record)
const user = await User.findOneBy({ id: 2 })
await user.remove()

// 2. Repository를 통해 인스턴스를 찾고 삭제하는 방식 (Mixed)
const userRepository = getRepository(User)
const user = await userRepository.findOneBy({ id: 2 })
await userRepository.remove(user)

// 3. Repository의 단일 delete 메서드를 사용하는 방식 (Repository)
const userRepository = getRepository(User)
await userRepository.delete(2)

여기서도 흥미로운 차이점이 있다. TypeORM은 removedelete 두 가지 삭제 메서드를 제공한다.

  • remove: 엔티티 인스턴스가 필요하며, 삭제 전에 엔티티의 이벤트 리스너와 캐스케이드가 동작한다.
  • delete: ID만으로 직접 삭제가 가능하며, 데이터베이스에 직접 DELETE 쿼리를 실행한다.

Prisma를 사용한다면, 아래와 같은 방식을 통해 유저를 삭제할 수 있다.

const deletedUser = await prisma.user.delete({
  where: {
    id: 2
  }
})

Prisma는 삭제에 있어서도 명시적이고 단순한 접근 방식을 취한다. 삭제된 레코드의 데이터를 반환하며, 존재하지 않는 레코드를 삭제하려고 하면 에러가 발생한다.

Soft Delete

실제 프로덕션 환경에서는 데이터를 완전히 삭제하는 대신, 삭제 여부를 플래그로 표시하는 Soft Delete를 더 많이 사용한다. 각 ORM에서는 이를 어떻게 구현할 수 있을까?

TypeORM을 사용한다면, @DeleteDateColumn 데코레이터를 통해 Soft Delete를 구현할 수 있다:

@Entity()
class User {
  @DeleteDateColumn()
  deletedAt?: Date
}

// Soft Delete 실행하기
const userRepository = getRepository(User)
await userRepository.softDelete(2)  // DELETE 대신 UPDATE deletedAt = CURRENT_TIMESTAMP

// Soft Delete된 데이터 제외하고 조회
const users = await userRepository.find()  // deletedAt IS NULL 조건 자동 추가

// Soft Delete된 데이터 포함해서 조회
const allUsers = await userRepository.find({
  withDeleted: true
})

Prisma에서도 Soft Delete를 구현할 수 있지만, TypeORM과 달리 내장 기능으로 제공하지는 않는다. 대신 스키마에 deleted 필드를 추가하고 명시적으로 처리해야 한다:

model User {
  id        Int       @id @default(autoincrement())
  name      String
  deletedAt DateTime? // Soft Delete용 필드
}
// Soft Delete 실행하기
const softDeletedUser = await prisma.user.update({
  where: { id: 2 },
  data: {
    deletedAt: new Date()
  }
})

// Soft Delete된 데이터 제외하고 조회
const users = await prisma.user.findMany({
  where: {
    deletedAt: null
  }
})

// Soft Delete된 데이터 포함해서 조회
const allUsers = await prisma.user.findMany()

두 ORM의 차이점을 살펴보면:
1. TypeORM은 Soft Delete를 위한 내장 기능을 제공하여 사용이 편리하다

  • @DeleteDateColumn 데코레이터만 추가하면 됨
  • softDelete() 메서드 제공
  • 조회 시 자동으로 삭제된 데이터 제외
  1. Prisma는 더 명시적인 방식으로 구현해야 한다
    • 스키마에 삭제 필드를 직접 정의
    • update를 사용해 Soft Delete 구현
    • 조회 시 삭제 필드 조건을 직접 지정

물론, Prisma에서도 middleware 기능을 사용해서 soft delete 기능을 구현할 수 있다. 이는 기능적으로는 동일하지만, 미들웨어 관리와 예외 처리라는 추가적인 책임은 prisma를 사용하는 개발자에게 있다.

따라서 soft delete 측면에서는 TypeORM의 접근 방식이 더 간편하고 안정적이라고 할 수 있다.

결론 - 그래서, TypeORM과 Prisma 중 뭘 써야 할까?

지금까지 두 ORM의 주요 시나리오별 사용 방식과 차이점을 살펴보았다.

두 ORM은 각자의 장단점이 뚜렷하다. TypeORM은 전통적인 ORM의 안정성과 다양한 편의 기능(예: Soft Delete)을 제공하지만, 타입 시스템과의 통합 측면에서는 아쉬움이 있다. 실제 데이터와 타입이 일치하지 않는 경우가 발생할 수 있기 때문이다. 반면 Prisma는 강력한 타입 안전성과 더 직관적인 API를 제공하지만, 일부 기능(예: Soft Delete)은 직접 구현해야 하는 번거로움이 있다.

한편, 문서 관리의 측면에서 TypeORMPrisma를 비교하자면, Prisma의 손을 들어주고 싶다.

"TypeORM 문서는 배우고 싶은 사람한테 친절하지 않다"는 느낌이 들었다. 예를 들어, save 라는 메서드는 TypeORM 사용시 굉장히 빈번하게 사용될법한 메서드인데, save를 사용하는 사례에 대한 검색만 지원될 뿐 save 자체에 대한 설명은 검색창으로 찾아볼 수 없었다.

결국 save는 Repository APIs에서 찾아볼 수 있었는데,, 이걸 왜 사용하는 사람이 찾아다녀야 하는지 잘 모르겠다.

Prisma에서는 비슷한 기능의 upsert 검색시 빠르게 upsert 그 자체에 대해서 친절하게 설명된 문서를 볼 수 있다. 설명 자체도 차이가 많이 난다.

또한, 개인적으로 심화 사용 사례에 대한 설명도 Prisma가 훨씬 잘 설명되어 있다고 느꼈다.
이미 TypeORM을 많이 써 본 사람이라면 불편함을 못느낄 수 있겠지만, 불친절한 문서는 새로운 기술을 익히려는 사람들에게 그 자체로 큰 진입 장벽이 될 수 있다.

TypeORM과 Prisma를 비교한 결과, Prisma가 더 나은 선택이라 판단했다. 핵심적인 이유는 타입 안정성이다.

프로젝트 규모가 커질수록 타입스크립트의 타입 시스템과의 연계는 더욱 중요해진다.

Prisma는 generate시 데이터베이스와 앱을 연결하는 타입을 '모두, 미리' 생성하기 때문에 컴파일 타임에 타입 검사를 수행하여 데이터베이스 스키마와 관련된 문제를 조기에 발견할 수 있다.

반면 TypeORM은 데이터베이스와 앱을 연결하는 타입을 별도로 생성하지 않는다. 그저 사용자가 정의한대로만 데이터베이스와 연결할 뿐이다.

TypeORM은 Entity 데코레이터를 통해 클래스와 데이터베이스 테이블을 매핑하는 방식을 사용하는데, 엔티티의 필드 타입이 실제 데이터베이스 칼럼 타입과 일치하지 않아도 TypeORM은 이를 컴파일 타임에 확인하지 못한다.
이러한 불일치는 런타임에서야 발견되며, 이는 개발자의 수고로움을 더한다.

ORM이애플리케이션과 데이터베이스를 이어주는 '가교'라는 역할임을 고려했을 때, 더 튼튼한 다리인 Prisma를가 더 낫다고 생각했다.

하지만 프로젝트가 이미 TypeORM을 사용해 서비스 중이기 때문에, Prisma로의 마이그레이션이 가져올 이점이 TypeORM 유지의 기회비용보다 큰지 더 조사가 필요해 보인다.

결론적으로, 새로운 개인 프로젝트라면 주저 없이 Prisma를 선택하겠지만, 기존 TypeORM 프로젝트의 마이그레이션은 신중해야 한다.

최소한 다음 과정을 거친 후 결정하는 것이 바람직하다:
1. e2e 테스트를 통한 성능 및 결과 검증
2. 변경이 필요한 코드량 산정

profile
계속 읽고 싶은 글을 쓰고 싶어요 ☺

0개의 댓글

관련 채용 정보