Prisma 와 kysely 함께 사용하기

June·2024년 5월 12일
0

Prisma

목록 보기
2/2
post-thumbnail

prisma 는 스스로 Next-generation Typescript orm 이라고 할만큼 강력한 타입 안정성과 비교적 간단한 스키마 작성 등 좋은 개발자 경험을 제공하는 ORM 입니다. 하지만 다른 ORM 대비 여러 단점도 가지고 있습니다.

Prisma의 단점

  • RDBMS 사용 시 database level Join을 사용하지 않고 application level join을 사용한다.
    • 현재 (24년 5월) 기준, preview 기능 으로 database-level 을 선택할 수 있는 옵션이 추가되었다.
  • Lock 을 지원하지 않는다.
    • Lock을 사용하기 위해서는 무조건 raw 쿼리를 사용해야 한다.
  • 레코드의 다중 생성 시 생성된 레코드를 반환 받을 수 없다.
    • RDBMS의 RETURNING 구문을 사용할 수 있는 옵션이 없다.
    • 관련 github issue에서의 격한 반응들 처럼 많은 개발자들이 원하고 있지만 아직 지원 계획이 없는 것 같다.
  • 복잡한 sql을 사용하기 위한 지원이 부족하다.
    • raw 쿼리를 위한 기능을 제공하긴 하나 매우 불편하다.
    • 다른 ORM이 가진 쿼리 빌더를 제공하지 않는다.

단점 극복을 위해 kysely extension 사용하기

해당 포스트의 모든 예시는 Bun 런타임에서 작성되었으나 node 환경에서도 마찬가지로 사용할 수 있습니다. 전체 코드 예시는 여기 에서 확인하실 수 있습니다.

kysely란?

강력한 타입 안정성을 제공하는 typescript sql 빌더 입니다. (orm이 아닙니다) 참고

prisma kysely extension 설정

1. 의존성 설치 (prisma-kysely, prisma-extension-kysely)

bun install kysely prisma-extension-kysely prisma-kysely pg
  • prisma-kysely는 prisma schema로 부터 kysely의 타입을 생성할 수 있는 라이브러리
  • prisma-extension-kysely 는 prisma의 extension을 이용해 kysely와 연결을 지원하는 라이브러리

2. kysely generation 설정

  • schema.prisma kysely generator 설정
generator kysely {
  provider = "prisma-kysely"
}
  • prisma client generate
    - generator 설정 후 client를 생성(예시 프로젝트에서 bun db:generate)하면 자동으로 스키마 정의에 따라 prisma의 하위 경로(generated/types.ts)에 아래와 같은 kysely 를 위한 typescript 타입 정의 파일이 생성된다
/** prisma/generated/types.ts */
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
  ? ColumnType<S, I | undefined, U>
  : ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;

export type Comment = {
  id: Generated<number>;
  text: string;
  post_id: number;
  user_id: number;
  created_at: Timestamp;
  updated_at: Timestamp;
};
export type Post = {
  id: Generated<number>;
  title: string;
  created_at: Timestamp;
  updated_at: Timestamp;
  author_id: number;
  likes: Generated<number>;
};
export type PostLikes = {
  id: Generated<number>;
  post_id: number;
  user_id: number;
  created_at: Timestamp;
  updated_at: Timestamp;
};
export type User = {
  id: Generated<number>;
  name: string;
};
export type DB = {
  comments: Comment;
  post_likes: PostLikes;
  posts: Post;
  users: User;
};

3. prisma client 확장

  • prisma-extension-kyselykyselyExtension 를 통해 prismClient 를 확장한다. 이 때, prisma-kysely 를 통해 자동 생성된 타입을 kysely의 generic 타입으로 설정하여 사용할 수 있다.
import { PrismaClient } from "@prisma/client";
import {
  Kysely,
  PostgresAdapter,
  PostgresIntrospector,
  PostgresQueryCompiler,
} from "kysely";
import kyselyExtension from "prisma-extension-kysely";
import type { DB } from "./generated/types";

export const prisma = new PrismaClient({
  log: ["query"],
}).$extends(
  kyselyExtension({
    kysely: (driver) =>
      new Kysely<DB>({
        dialect: {
          createDriver: () => driver,
          createAdapter: () => new PostgresAdapter(),
          createIntrospector: (db) => new PostgresIntrospector(db),
          createQueryCompiler: () => new PostgresQueryCompiler(),
        },
      }),
  })
);
  • kysely 확장 확인
    • 아래에서 볼 수 있는 것 처럼 extension이 설정되고나면 PrismaClient의 인스턴스에 $kysely 속성이 추가된다.

prisma raw 쿼리와의 비교

prisma의 raw 쿼리

Prisma의 raw query 기능을 활용하면 아래처럼 코드를 작성하게 되는데 코드를 작성할 때 부터 hint 지원을 받을 수 없을 뿐만 아니라 SQL injection 공격을 막기 위한 parameter 변환 구문도 불편하고, 응답 타입 또한 제네릭을 설정하지 않으면 unknown으로 설정되기 때문에 타입으로 인한 런타임 버그의 확률이 늘어나게 된다.

public async findMany(ids: number[]) {
    return await prisma.$queryRaw(
      Prisma.sql`
        SELECT 
          id as "id", 
          title as "title", 
          created_at as "createdAt", 
          updated_at as "updatedAt", 
          author_id as "authorId" 
        FROM posts as p 
        INNER JOIN users as u ON p.author_id = u.id
        WHERE id IN (${Prisma.join(ids)})
        ORDER BY p.id DESC
      `
    );
  }

kysely 확장 사용

  • TypeORM 등의 다른 ORM에서 제공하는 쿼리 빌더를 사용해봤다면 익숙한 방식으로 빌더 패턴을 사용해서 쿼리 생성할 수 있다.
  • 아래의 예시에서 볼 수 있는 것 처럼 명시적으로 join 으로 쿼리를 생성할 수 있고, 다양한 옵션으로 prisma 에서는 힘든 복잡한 쿼리를 작성할 수 있다.
  public async findMany(count = 20): Promise<Post[]> {
    return await prisma.$kysely
      .selectFrom("posts as p")
      .innerJoin("users as u", "p.author_id", "u.id")
      .select([
        "p.id as id",
        "p.title as title",
        "p.created_at as createdAt",
        "p.updated_at as updatedAt",
        "u.id as authorId",
        "u.name as authorName",
      ])
      .orderBy("p.id", "desc")
      .limit(count)
      .execute()
      .then((rows) =>
        rows.map((row) =>
          Post.of({
            id: row.id,
            title: row.title,
            createdAt: row.createdAt,
            updatedAt: row.updatedAt,
            author: {
              id: row.authorId,
              name: row.authorName,
            },
          })
        )
      );
  }
  • subQuery 예시
const res = await prisma.$kysely.selectFrom("posts as p").leftJoin(
      (qb) =>
        qb.selectFrom("users as u")
  		  .select(["u.name", "u.id"])
          .as("author"),
      (join) => join.onRef("p.author_id", "=", "author.id")
    );
  • 무엇보다 DX가 좋은 점은 아래에서 확인할 수 있는 것처럼 테이블이름이나 필드명을 aliasing을 하더라도 ide에서 aliasing 한 문자에 따라 hint를 제공받을 수 있고, 타입으로 인한 런타임 버그를 줄일 수 있다.

  • 또한 select 에서 aliasing 한 필드명으로 결과 type이 지정되어 타입이 명확하지 않아 발생할 수 있는 런타임 버그를 줄일 수 있다.

Transaction scope 내부에서 사용

  • kysely도 자체적으로 transaction 기능을 지원하지만 위의 공식 문서 의 transaction 항목에서 명시되어 있는 것처럼 kyselytransaction 메서드를 직접 사용하는 것은 지원하지 않고 prisma$transaction 스코프에서 콜백 인자의 $kysely를 사용해야 한다.
    • extension이 정상적으로 설정되어 있다면 콜백 인자인 tx에도 $kysely 속성이 존재한다.
  • 해당 스코프안에서 $kysely와 기존 prisma의 메서드를 같이 사용해도 트랜잭션의 원자성이 보장된다.

이외에 간단한 사용 사례

lock

  public async findIdWithLock(
    id: number,
    tx: PrismaTransactionManager
  ): Promise<number | null> {
    return await tx.$kysely
      .selectFrom("posts as p")
      .select("p.id")
      .where("p.id", "=", id)
      .forUpdate() // 이외에도 forShare, forKeyShare 등 메서드 지원
      .execute()
      .then((rows) => {
        return rows.length > 0 ? rows[0].id : null;
      });
  }

returning

 public async createMany(
    posts: (Omit<IPost, "id" | "author"> & { authorId: number })[]
  ): Promise<Post[]> {
    const ids = await prisma.$kysely
      .insertInto("posts")
      .values(
        posts.map((p) => ({
          author_id: p.authorId,
          title: p.title,
          created_at: p.createdAt,
          updated_at: p.updatedAt,
        }))
      )
      .returning("id") // 이외에 returningAll() 메서드 지원
      .execute()
      .then((rows) => rows.map((row) => row.id))

결론

  • prisma 는 매우 강력한 타입을 지원하는 ORM 이지만 서비스를 개발하면서 복잡한 쿼리나 쿼리 최적화를 해야만 하는 일이 꽤나 자주 발생하기 때문에 단점도 꽤나 분명하다고 느꼈는데, kysely 확장을 통해 해당 단점들을 많이 상쇄할 수 있다고 생각한다.
  • 위에서 언급한 것처럼 prisma 자체적으로 database level join 기능을 preview 기능을 최근에 추가해줘서 간단한 join을 위해서는 쿼리 결과를 transform 하는 개발 비용을 줄이기 위해서prisma의 것을 사용하는게 더 좋지 않을까 생각한다.
    • 간단한 실험을 했는데 prisma preview 기능의 database levele join 사용시 기본적으로 lateral join을 사용하고 있었다. 해당 부분은 다른 포스팅에서 다뤄볼 예정

0개의 댓글

관련 채용 정보