Kysely 쓰는 법

Typescript에서 ORM을 찾으면 Kysely에 도달한다.

2025.02.12

/

6m to read

Kysely?

Kysely 는 Typescript를 위한 Query Builder다. 프로그래머가 정의한 모델을 읽고 Query가 맞는지, 틀린지를 컴파일 에러로 가르쳐준다. Kysely로 간단한 Select에서 복잡한 CTE ( Common Table Expression )까지 모두 다룰 수 있다.

이 글에서는 Alleys를 개발하면서 Kysely를 활용한 방법에 대해 소개하겠다.

지원되는 DB

불필요하게 글을 읽는 상황을 방지하기 위해 지원되는 DB부터 살펴보자. Kysely는 RDBMS 또는 Relation이 있는 NoSQL들을 지원한다 .

Database Structure 생성하기

먼저 Query가 맞다 틀리다를 구별할 기준(Source of Truth)이 필요하다. ORM에서는 Entity이라 부르고, Kysely에서는 Database Structure라고 부른다.

// https://kysely.dev/docs/getting-started#types
import {
  ColumnType,     // Select, Insert, Update에 대해 각각 다른 타입을 지정할 때 쓰는 Util type
  Generated,      // DB에서 값을 생성하는 column을 표시
} from 'kysely'

export interface User {
  id: Generated<number>
  first_name: string
  last_name: string | null
  gender: 'man' | 'woman' | 'other'
  created_at: ColumnType<Date, string | undefined, never>
}

💡

Kysely에서는 undefined 타입 사용을 금지한다. undefined 타입은 테이블의 컬럼을 선택하는데 쓰이기 때문이다.

그런데 이미 소제목에서 스포일러를 했다. 굳이 코딩할 필요 없이 Kysely가 Data structure를 생성하는 방법 이 있다.

Third-party librarySupported DB
kysely-codegen Kysely에서 지원하는 드라이버가 있으면 전부 가능
prisma-kysely Prisma 파일에서 타입 생성
kanel-kysely Kanel이라고 하는 PostgreSQL 타입 생성기를 이용
kysely-schema-generator MySQL에 직접 접근

나는 kysely-codegen이 제일 사용하기 편해서 이것을 쓰고 있다.

사용1. Client

Client 만드는 방법. 처음에 제대로 library를 만들어야 나중에 다시 찾을 일이 없다.

import { Kysely, PostgresDialect } from "kysely"
import pg from "pg"
import type { DB } from "./db"  // Kysely가 생성한 Database Structure 타입

export function getKysely(connStr: string) {
  return new Kysely<DB>({
    dialect: (
      new PostgresDialect({
        pool: new pg.Pool({
          connectionString: connStr,
        }),
      })
    ),
  })
}

Dialect는 Kysely와 DB 사이의 인터페이스다. Postgres 외의 DB는 여기를 참고.

사용2. Query

기본적인 사용 방법은 공식 가이드 에 잘 나와있다.

const persons = await db
  .selectFrom('person')
  .select('id')
  .where('first_name', '=', 'Arnold')
  .execute()
SELECT
  "id"
FROM
  "person"
WHERE
  "first_name" = $1

대충 이런 식이다. 조심해야 할 부분은, execute()를 호출하지 않으면 아무 일도 일어나지 않는다는 점이다.

사용3. Conflict

Alleys를 만들 때 정말 자주 썼던 패턴이다.

import { sql } from "kysely"

try {
  user = await kysely
    .insertInto("user")
    .values({
      id: sql`uuid_generate_v4()`,
      unique_name,
      email,
      email_verified: true,
      password_hash,
      created_at: now,
      updated_at: now,
    })
    .onConflict(oc => oc.column("unique_name").doNothing())
    .returning("id")
    .executeTakeFirst()

  if (!user) return c.body("unique name conflict", 409)
} catch (e) {
  console.error("db user insert error", e)
  return c.body("internal", 500)
}

onConflict()를 통해 conflict를 어떻게 대처할지 정할 수 있다. 이 함수가 없으면 일반적인 에러와 conflict를 구분하기 어렵기 때문이다.

만약 conflict가 두 가지 이상의 컬럼에서 발생한다면 에러 처리를 어떻게 할까?

.onConflict(oc => oc
  .columns(["user_id", "exhibition_id"])
  .doNothing()
)

두 컬럼을 구분할 필요가 없다면 이렇게 간단하게 해결할 수 있다.

만약 conflict가 발생한 컬럼을 특정해야한다면? 그런 끔찍한 상황은 상상하기 싫지만, onConflict()함수를 포기하고 직접 에러 처리를 해야 할 것이다.

(Conflict 에러 예시)
✘ [ERROR] db user insert error error: duplicate key value violates unique constraint "user_unique_name_key"

      at Object.parseErrorMessage
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1591:6)
      at Object.handlePacket
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1552:48)
      at Object.parse
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1536:63)
      at yt.Di
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:1598:74)
      at yt.emit
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:400:63)
      at null.<anonymous>
  (file:///home/humbledragon/Documents/GitHub/alleys-mono-v2/node_modules/@neondatabase/serverless/index.js:928:19)
  {
    length: 213,
    severity: 'ERROR',
    code: '23505',
    detail: 'Key (unique_name)=(muffin) already exists.',
    hint: undefined,
    position: undefined,
    internalPosition: undefined,
    internalQuery: undefined,
    where: undefined,
    schema: 'public',
    table: 'user',
    column: undefined,
    dataType: undefined,
    constraint: 'user_unique_name_key',
    file: 'nbtinsert.c',
    line: '666',
    routine: '_bt_check_unique'
  }

사용4. Dynamic where

단순한 Where 사용은 여기를 참고.

만약 적용해야하는 Where 조건이 API 파라미터에 따라 있기도 하고, 없기도 하다면? Kysely가 아주 잘 해결해줄 수 있다.

  try {
    let q = kysely
      .selectFrom("exhibition as e")
      .select(eb => eb.fn.countAll<number>("e").as("total"))
    
    if (req.location_ids) {
      q = q.where("e.location_id", "in", req.location_ids)
    }
    if (req.price_min) {
      q = q.where("e.price", ">=", req.price_min)
    }
    if (req.price_max) {
      q = q.where("e.price", "<=", req.price_max)
    }
    // ...

    // Execute
    data = await q.executeTakeFirst()
    if (!data) {
      console.error("db select exhibition total error: result was empty")
      return c.text("internal", 500)
    }
  } catch (e) {
    console.error("db select exhibition error:", e)
    return c.text("internal", 500)
  }

이렇게 where()함수를 여러번 호출하면 Kysely가 알아서 and로 엮어준다. ( 출처 )

사용5. NarrowType

가끔 컬럼 타입이 예상과 다른 상황이 생긴다.

.selectFrom("review as r")
.leftJoin("user as u", "r.user_id", "u.id")
.leftJoin("exhibition as e", "r.exhibition_id", "e.id")
.select([
  // ...
  "u.unique_name",
])
.$narrowType<{ user_id: string, unique_name: string }>()

Review는 User에 Foreign key를 가지고 있기 때문에 반드시 Join이 성공해야한다. 하지만 Kysely는 Relation을 모르기 때문에 Join이 실패할 수도 있다고 생각한다. 이런 특수한 경우 .$narrowType<>() 함수를 통해 타입을 억지로 교정할 수 있다.

마치며

이정도면 Kysely가 얼마나 유용한 라이브러리인지 알 수 있을 것이다.

그런데 생각해보면 Kysely의 강력함은 Typescript의 타입 시스템에 의해 가능한 것이다. 검색해보니 이정도로 강력한 타입 시스템을 가진 언어는 Scala, Haskell, Idris, Kotlin, Rust, Swift, F#... 생각보다 많네 ?

아무튼 타입은 프로그래머에게 있어서 소중한 기능이다.