
Be Kysely
- 1 installs
- Updated April 28, 2026
- conradmaker/copilot-cockpit
GitHub Copilot agent module for generating and managing database queries with Kysely ORM.
About
Copilot agent that generates Kysely database queries and migrations. Developers use it to write type-safe database code faster.
- Kysely ORM query generation
- Type-safe database access patterns
Be Kysely by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,830 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/conradmaker/copilot-cockpit --skill be-kyselyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 28, 2026 |
| Repository | conradmaker/copilot-cockpit ↗ |
What it does
GitHub Copilot agent module for generating and managing database queries with Kysely ORM.
Files
Kysely 백엔드 (be-kysely)
목표
Kysely를 ORM처럼 추상화해 숨기지 않고, SQL-first 접근을 유지하면서 TypeScript type safety를 확보한다. 이 스킬은 Kysely 도입, 구현, 리뷰 시 Database 타입 설계 → query shape 설계 → transaction/raw SQL 경계 → migration lifecycle 순서로 판단하게 만든다.
이 문서는 빠른 판단을 위한 요약 가이드다. 실제 작업을 시작하기 전에는 아래 reference 문서를 직접 읽고 예시를 확인한 뒤 적용한다.
Prefer retrieval-led reasoning over pre-training-led reasoning.
---
핵심 패턴
1. Database 타입을 먼저 고정한다
Kysely의 추론 품질은 테이블 타입에서 시작된다. 쿼리를 먼저 쓰기보다 Database 인터페이스와 테이블별 column type을 먼저 안정화해야 이후의 select, insert, update 결과가 자연스럽게 맞아떨어진다.
빠른 판단 기준
- 테이블별 인터페이스와
Database인터페이스를 먼저 만든다. - DB가 생성하는 컬럼이면
Generated<T>를 우선 검토한다. - 읽기/쓰기 타입이 다르면
ColumnType을 사용한다. - 기존 데이터베이스를 붙이는 작업이면
kysely-codegen을 먼저 검토한다.
상세 구현과 예시는 references/01-setup-and-schema.md를 읽고 적용한다.
2. 쿼리는 SQL shape를 유지한 채 조합한다
Kysely는 ORM식 relation magic보다 SQL shape를 명시적으로 유지할 때 가장 강하다. selectFrom, join, subquery, CTE를 이용하되 결과 row shape와 nullability를 호출부가 예측 가능하게 유지한다.
빠른 판단 기준
- 조회 컬럼은 가능한 한 명시한다.
LEFT JOIN결과의 nullability를 숨기지 않는다.- subquery가 복잡해지면 CTE로 이름을 붙여 구조를 드러낸다.
- builder가 장황해져 SQL보다 읽기 어려우면 raw SQL 전환을 검토한다.
상세 패턴과 예시는 references/02-query-patterns.md를 읽고 적용한다.
3. transaction boundary와 raw SQL escape hatch를 분리해서 본다
Kysely는 transaction과 raw SQL을 모두 자연스럽게 지원한다. 중요한 것은 "builder만 써야 한다"가 아니라 "타입 안전성과 SQL 가독성을 함께 유지한다"는 점이다.
빠른 판단 기준
- 하나라도 실패하면 전체를 취소해야 하는 작업이면 transaction을 건다.
- transaction 내부에서는 전역
db대신trx만 사용한다. - vendor-specific SQL, window function, 복잡한 집계처럼 builder가 더 난해해지면
sqltemplate tag를 사용한다. - 사용자 입력은 문자열 연결이 아니라 parameter binding으로 전달한다.
상세 패턴과 예시는 references/04-transactions-and-raw-sql.md를 읽고 적용한다.
4. schema lifecycle은 별도 discipline으로 관리한다
Kysely는 migration 도구를 제공하지만, 운영 안전성까지 대신 보장하지는 않는다. schema evolution은 query 작성과 분리된 별도 의사결정으로 다뤄야 한다.
빠른 판단 기준
- migration은
up과down을 함께 설계한다. - breaking change는 additive 단계로 쪼갠다.
- rename, type change, not-null 강화는 다단계 migration을 우선 검토한다.
- production rollout에서는 앱 배포와 schema 배포 순서를 명시한다.
상세 절차와 예시는 references/03-migrations-and-schema-lifecycle.md를 읽고 적용한다.
5. Kysely가 해주지 않는 일까지 기대하지 않는다
Kysely는 강력한 query builder지만 ORM은 아니다. relation loading, validation, index 전략, query plan 검토, repository boundary 설계는 여전히 애플리케이션이 책임진다.
빠른 판단 기준
- relation abstraction이 필요하다고 해서 ORM 기능을 기대하지 않는다.
- 성능 문제는 query builder보다 SQL shape와 index 설계부터 점검한다.
- 팀의 SQL 숙련도, 기존 DB 구조, relation abstraction 요구 수준에 따라 Prisma/Drizzle와 비교한다.
- Kysely 도입 여부는 타입 안전성뿐 아니라 운영 모델까지 보고 결정한다.
비교표, 운영 원칙, 흔한 실수는 references/05-comparison-and-pitfalls.md를 읽고 적용한다.
---
references/ 가이드
| 파일 | 언제 읽는가 |
|---|---|
| references/01-setup-and-schema.md | Database 타입, column type, codegen, client setup을 잡을 때 |
| references/02-query-patterns.md | CRUD, join, aggregation, subquery, CTE, JSON helper를 작성할 때 |
| references/03-migrations-and-schema-lifecycle.md | migration runner, schema evolution, safe migration 전략이 필요할 때 |
| references/04-transactions-and-raw-sql.md | transaction, isolation level, raw SQL escape hatch가 필요할 때 |
| references/05-comparison-and-pitfalls.md | Kysely 도입 비교, best practice, common pitfalls를 점검할 때 |
---
범위
- API 계약과 response shape 설계는
be-api-design을 사용한다. - Fastify 기반 서버 wiring과 plugin 구조는
fastify-best-practices를 사용한다. - 인증, 인가, 입력 검증, 비밀정보 처리는
dev-security를 사용한다. - Kysely가 아닌 일반 SQL 튜닝이나 DBA 운영 이슈는 별도 데이터베이스 전문 가이드를 우선한다.
Kysely 설정과 스키마 타입
언제 읽는가
- Kysely를 처음 붙일 때
Database인터페이스와 테이블 타입을 정의할 때- 기존 DB에서 타입을 생성할지, 수동으로 관리할지 결정할 때
---
1. 설치
npm install kysely
npm install pg드라이버는 실제 데이터베이스에 맞춰 고른다.
- PostgreSQL:
pg - MySQL:
mysql2 - SQLite:
better-sqlite3 - MSSQL: 해당 dialect 패키지
Kysely는 ORM이 아니라 SQL query builder다. 따라서 연결 풀 설정, 인덱스 전략, relation loading, validation은 직접 책임져야 한다.
---
2. 테이블 타입을 먼저 고정한다
Kysely의 핵심은 Database 인터페이스다. 쿼리 이전에 테이블별 타입을 먼저 안정화해야 이후 모든 추론이 일관된다.
import { Generated, Insertable, Selectable, Updateable } from "kysely"
interface UserTable {
id: Generated<number>
email: string
name: string | null
created_at: Generated<Date>
updated_at: Date
}
interface PostTable {
id: Generated<number>
user_id: number
title: string
content: string | null
published: Generated<boolean>
created_at: Generated<Date>
}
export interface Database {
users: UserTable
posts: PostTable
}
export type User = Selectable<UserTable>
export type NewUser = Insertable<UserTable>
export type UserUpdate = Updateable<UserTable>빠른 판단 기준
- DB가 생성하는 컬럼이면
Generated<T>를 우선 검토한다. - 조회 전용 결과 타입이 필요하면
Selectable<T>를 쓴다. INSERTpayload는Insertable<T>,UPDATEpayload는Updateable<T>를 기본값으로 둔다.- nullable 컬럼은 TypeScript에서도 nullable로 유지한다. Kysely가 대신 숨겨주지 않는다.
---
3. 읽기/쓰기 타입이 다르면 ColumnType을 쓴다
날짜, JSON, numeric, UUID처럼 select/insert/update 타입이 다를 때는 ColumnType이 필요하다.
import { ColumnType, Generated } from "kysely"
interface ProductTable {
id: Generated<string>
price: ColumnType<number, number, number | undefined>
metadata: ColumnType<Record<string, unknown>, string, string>
created_at: ColumnType<Date, string | undefined, never>
}언제 필요한가
- DB 드라이버가 문자열로 받지만 애플리케이션에서는 다른 타입으로 쓰고 싶을 때
- JSON/JSONB 컬럼을 객체로 읽고 문자열로 쓰는 식의 비대칭 매핑이 필요할 때
numeric정밀도를 문자열로 유지할지 숫자로 변환할지 명시해야 할 때
---
4. Kysely 인스턴스 생성
import { Kysely, PostgresDialect } from "kysely"
import { Pool } from "pg"
import type { Database } from "./database"
export const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 10,
}),
}),
})빠른 판단 기준
- 앱 전체에서 공유할 단일
db인스턴스를 만든다. - 서버리스 환경이면 연결 풀과 lifecycle을 프레임워크 런타임에 맞춰 조정한다.
- 트랜잭션 단위 작업에서는 전역
db대신 전달받은trx를 계속 사용한다.
---
5. 기존 DB가 있으면 kysely-codegen을 우선 검토한다
수동으로 타입을 만드는 것보다 기존 스키마에서 생성하는 편이 drift를 줄인다.
npm install --save-dev kysely-codegen
npx kysely-codegen --url "postgresql://user:pass@localhost:5432/mydb"생성 파일 예시:
import type { Generated } from "kysely"
export interface Database {
users: UsersTable
posts: PostsTable
}
export interface UsersTable {
id: Generated<number>
email: string
name: string | null
created_at: Generated<Date>
}수동 정의 vs codegen
- greenfield + 스키마 변화가 잦음: 수동 정의도 가능
- 기존 DB 사용: codegen 우선
- DB가 여러 서비스와 공유됨: codegen 쪽이 drift 관리에 유리
- DB 타입이 커스텀 변환을 많이 요구함: 생성 후 래퍼 타입을 덧씌운다
---
6. 추천 초기 구조
src/db/
├── client.ts
├── database.ts
├── queries/
│ ├── users.ts
│ └── posts.ts
└── migrations/구조 원칙
database.ts: 테이블 타입과Database인터페이스client.ts: dialect와 pool 설정queries/: 도메인별 쿼리 함수migrations/: schema 변경 이력
테이블 타입과 쿼리 함수를 같은 파일에 무리하게 섞지 않는 편이 유지보수에 유리하다.
Kysely 쿼리 패턴
언제 읽는가
selectFrom,insertInto,updateTable,deleteFrom를 작성할 때- join, aggregation, subquery, CTE가 필요한 쿼리를 만들 때
- Postgres JSON helper나 pagination 패턴이 필요할 때
---
1. 기본 CRUD
const users = await db
.selectFrom("users")
.select(["id", "email", "name"])
.where("created_at", ">", new Date("2024-01-01"))
.execute()
const inserted = await db
.insertInto("users")
.values({
email: "alice@example.com",
name: "Alice",
updated_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow()
await db
.updateTable("users")
.set({ name: "Alice Updated", updated_at: new Date() })
.where("id", "=", 1)
.execute()
await db.deleteFrom("users").where("email", "like", "%@spam.com").execute()빠른 판단 기준
- 빌더만 만들고
execute()를 빼먹지 않는다. - 조회 컬럼은 가능한 한 명시한다.
selectAll()은 내부 도구성 코드나 정말 필요한 경우에만 쓴다. executeTakeFirstOrThrow()는 결과가 반드시 있어야 하는 조회/삽입 결과에만 쓴다.
---
2. Join과 nullability
const usersWithPosts = await db
.selectFrom("users")
.innerJoin("posts", "posts.user_id", "users.id")
.select(["users.id", "users.name", "posts.title"])
.execute()
const usersWithOptionalPosts = await db
.selectFrom("users")
.leftJoin("posts", "posts.user_id", "users.id")
.select(["users.id", "users.email", "posts.title"])
.execute()LEFT JOIN 결과는 null 가능성을 그대로 가져간다.
빠른 판단 기준
LEFT JOIN이면 오른쪽 테이블 컬럼을null가능성으로 처리한다.- 충돌 가능한 컬럼명은
asalias로 분리한다. - 조인 조건이 FK와 정확히 맞는지 먼저 점검한다.
---
3. Aggregation과 grouping
const stats = await db
.selectFrom("posts")
.select([
"user_id",
db.fn.count<number>("id").as("post_count"),
db.fn.avg<number>("views").as("avg_views"),
])
.groupBy("user_id")
.having(db.fn.count("id"), ">", 5)
.execute()복잡한 aggregate는 sql과 조합해도 된다.
import { sql } from "kysely"
const advanced = await db
.selectFrom("users")
.leftJoin("posts", "posts.user_id", "users.id")
.select([
"users.id",
sql<number>`COUNT(DISTINCT posts.id)`.as("total_posts"),
sql<Date>`MAX(posts.created_at)`.as("latest_post"),
])
.groupBy("users.id")
.execute()---
4. Subquery
const usersWithPostCount = await db
.selectFrom("users")
.select([
"users.id",
"users.name",
(eb) =>
eb
.selectFrom("posts")
.select(eb.fn.count<number>("id").as("count"))
.whereRef("posts.user_id", "=", "users.id")
.as("post_count"),
])
.execute()EXISTS와 IN도 동일하게 표현할 수 있다.
const activeUsers = await db
.selectFrom("users")
.selectAll()
.where((eb) =>
eb.exists(
eb
.selectFrom("posts")
.select("id")
.whereRef("posts.user_id", "=", "users.id")
.where("created_at", ">", new Date("2024-01-01")),
),
)
.execute()빠른 판단 기준
- 외부 컬럼 참조는
whereRef를 먼저 본다. - membership check는
IN보다EXISTS가 더 자연스러운지 같이 검토한다. - builder가 지나치게 장황해지면 CTE나 raw SQL로 바꾸는 편이 낫다.
---
5. CTE
const result = await db
.with("popular_posts", (db) =>
db.selectFrom("posts").select(["id", "user_id", "title"]).where("views", ">", 1000),
)
.with("active_users", (db) =>
db.selectFrom("users").select(["id", "email"]).where("last_login", ">", new Date("2024-01-01")),
)
.selectFrom("popular_posts")
.innerJoin("active_users", "active_users.id", "popular_posts.user_id")
.selectAll()
.execute()재귀 CTE도 가능하다.
interface OrgNode {
id: number
name: string
parent_id: number | null
level: number
}빠른 판단 기준
- 한 쿼리 안에서 중간 결과를 이름 붙여 설명하고 싶다면 CTE를 우선 검토한다.
- 트리/계층 구조면 recursive CTE를 고려한다.
- DB dialect가 recursive CTE를 어떻게 지원하는지 먼저 확인한다.
---
6. Postgres helper와 중첩 결과
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/postgres"
import { sql } from "kysely"
const usersWithPosts = await db
.selectFrom("users")
.select([
"users.id",
"users.name",
jsonArrayFrom(
db
.selectFrom("posts")
.select(["posts.id", "posts.title", "posts.content"])
.whereRef("posts.user_id", "=", "users.id"),
).as("posts"),
])
.execute()
const nested = await db
.selectFrom("users")
.select([
"users.id",
jsonBuildObject({
name: "users.name",
email: "users.email",
postCount: sql<number>`(SELECT COUNT(*) FROM posts WHERE user_id = users.id)`,
}).as("user_data"),
])
.execute()---
7. Pagination helper
import { SelectQueryBuilder } from "kysely"
function paginate<DB, TB extends keyof DB, O>(
query: SelectQueryBuilder<DB, TB, O>,
page: number,
pageSize: number,
) {
return query.limit(pageSize).offset((page - 1) * pageSize)
}카운트가 필요하면 count query를 별도로 유지한다.
빠른 판단 기준
- offset pagination은 관리 화면이나 낮은 cardinality 목록에 적합하다.
- 무한 스크롤이나 large dataset이면 cursor pagination을 별도로 설계한다.
---
8. Full-text search 예시
import { sql } from "kysely"
const searchResults = await db
.selectFrom("posts")
.selectAll()
.where(sql`search_vector`, "@@", sql`to_tsquery('english', ${query})`)
.execute()Kysely가 인덱스를 대신 잡아주지 않는다. 성능은 DB schema와 index 설계가 좌우한다.
Kysely 마이그레이션과 스키마 수명주기
언제 읽는가
- migration runner를 붙일 때
- 새 테이블/컬럼 추가, 변경, 제거를 설계할 때
- zero-downtime에 가까운 안전한 schema evolution이 필요할 때
---
1. Migration provider 설정
import { FileMigrationProvider, Migrator } from "kysely"
import { promises as fs } from "fs"
import path from "path"
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, "migrations"),
}),
})실행 예시:
async function migrateToLatest() {
const { error, results } = await migrator.migrateToLatest()
results?.forEach((item) => {
if (item.status === "Success") {
console.log(`Migration "${item.migrationName}" executed successfully`)
}
if (item.status === "Error") {
console.error(`Migration "${item.migrationName}" failed`)
}
})
if (error) {
console.error("Migration failed", error)
process.exit(1)
}
}빠른 판단 기준
- 애플리케이션 부트 과정과 migration 실행을 무조건 섞지 않는다.
- 배포 파이프라인에서 migration 실행 시점과 rollback 절차를 먼저 정한다.
- migration 실패 시 프로세스를 계속 띄우지 않는다.
---
2. 기본 migration 파일 패턴
import { Kysely, sql } from "kysely"
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("users")
.addColumn("id", "serial", (col) => col.primaryKey())
.addColumn("email", "varchar(255)", (col) => col.notNull().unique())
.addColumn("name", "varchar(255)")
.addColumn("created_at", "timestamp", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull())
.execute()
await db.schema.createIndex("users_email_idx").on("users").column("email").execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("users").execute()
}빠른 판단 기준
up과down을 함께 설계한다.- 인덱스 생성 여부를 테이블 생성과 같이 검토한다.
- DB vendor별 문법 차이가 큰 부분은
sqlescape hatch를 허용한다.
---
3. 흔한 변경 패턴
Foreign key 추가
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("posts")
.addColumn("id", "serial", (col) => col.primaryKey())
.addColumn("user_id", "integer", (col) =>
col.references("users.id").onDelete("cascade").notNull(),
)
.addColumn("title", "varchar(500)", (col) => col.notNull())
.addColumn("content", "text")
.execute()
}테이블 수정
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("users").addColumn("bio", "text").execute()
}PostgreSQL enum 추가
import { sql } from "kysely"
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE user_role AS ENUM ('admin', 'user', 'guest')`.execute(db)
await db.schema
.alterTable("users")
.addColumn("role", sql`user_role`, (col) => col.defaultTo("user"))
.execute()
}---
4. 안전한 마이그레이션 원칙
기본 원칙
1. backward compatible하게 나눈다. 2. 가능한 한 reversible하게 만든다. 3. big-bang 변경보다 작은 단계로 쪼갠다. 4. 앱 배포와 스키마 배포의 순서를 의식한다.
추천 패턴
- 컬럼 이름 변경: 새 컬럼 추가 → 데이터 복사 → 앱 전환 → 기존 컬럼 제거
- nullable -> not null: 컬럼 추가 또는 backfill 완료 후 별도 migration에서 제약 강화
- 타입 변경: 새 컬럼 추가 → 변환 복사 → 읽기 경로 전환 → 구 컬럼 제거
예시:
import { sql } from "kysely"
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("users").addColumn("full_name", "varchar(255)").execute()
await db
.updateTable("users")
.set({
full_name: sql`concat(first_name, ' ', last_name)`,
})
.execute()
}---
5. 테스트와 운영 체크리스트
- 로컬 또는 staging에서
up과down을 모두 검증한다. - 대량 테이블이면 lock, rewrite, index build 비용을 먼저 본다.
- destructive migration은 백업/rollback 전략을 준비한다.
- 애플리케이션이 구/신 스키마 모두와 잠시 공존할 수 있는지 확인한다.
Kysely는 migration 도구를 제공하지만, production rollout 전략을 대신 설계해주지는 않는다.
Kysely 트랜잭션과 raw SQL
언제 읽는가
- 여러 쿼리를 원자적으로 묶어야 할 때
- isolation level을 조정해야 할 때
- query builder보다 SQL이 더 명확한 쿼리를 써야 할 때
---
1. 기본 트랜잭션
await db.transaction().execute(async (trx) => {
await trx
.insertInto("users")
.values({
email: "alice@example.com",
name: "Alice",
updated_at: new Date(),
})
.execute()
await trx
.insertInto("posts")
.values({
user_id: 1,
title: "First Post",
content: "Hello",
})
.execute()
})에러가 나면 자동 rollback된다.
빠른 판단 기준
- 하나라도 실패하면 전부 취소되어야 하는 작업이면 transaction을 건다.
- 트랜잭션 내부에서는 전역
db가 아니라 전달받은trx만 사용한다. - 외부 API 호출처럼 오래 걸리는 부수효과는 트랜잭션 안에 오래 잡아두지 않는다.
---
2. 결과를 반환하는 트랜잭션
const result = await db.transaction().execute(async (trx) => {
const user = await trx
.insertInto("users")
.values({
email: "bob@example.com",
name: "Bob",
updated_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow()
const post = await trx
.insertInto("posts")
.values({
user_id: user.id,
title: "Bob's Post",
content: "Content",
})
.returningAll()
.executeTakeFirstOrThrow()
return { user, post }
})---
3. Isolation level
await db
.transaction()
.setIsolationLevel("serializable")
.execute(async (trx) => {
const balance = await trx
.selectFrom("accounts")
.select("balance")
.where("id", "=", accountId)
.executeTakeFirstOrThrow()
await trx
.updateTable("accounts")
.set({ balance: balance.balance - amount })
.where("id", "=", accountId)
.execute()
})빠른 판단 기준
- 기본값으로 충분하면 isolation level을 굳이 올리지 않는다.
- race condition이 실제로 문제인 쓰기 작업에만 더 강한 isolation을 검토한다.
- isolation 강화는 정확성뿐 아니라 잠금 비용도 함께 고려한다.
---
4. sql template tag
Kysely는 raw SQL escape hatch를 제공한다. builder보다 SQL이 더 읽기 쉬우면 과감하게 sql을 쓴다.
import { sql } from "kysely"
const result = await db
.selectFrom("users")
.select([
"id",
sql<string>`UPPER(name)`.as("uppercase_name"),
sql<number>`EXTRACT(YEAR FROM created_at)`.as("year_created"),
])
.execute()const filtered = await db
.selectFrom("posts")
.selectAll()
.where(sql`LOWER(title)`, "like", "%typescript%")
.execute()빠른 판단 기준
- 함수식, window function, vendor-specific feature처럼 builder가 지나치게 복잡해지면
sql을 검토한다. - 값 바인딩은 템플릿 보간으로 넘기고 문자열 이어붙이기를 피한다.
- raw SQL을 쓴다고 type safety를 완전히 버릴 필요는 없다. 결과 타입을 함께 선언한다.
---
5. 전체 raw query
const topPosts = await sql<{ id: number; user_id: number; rank: number }>`
WITH ranked_posts AS (
SELECT
p.id,
p.user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY views DESC) AS rank
FROM posts p
)
SELECT * FROM ranked_posts WHERE rank <= 3
`.execute(db)const email = "alice@example.com"
const user = await sql<{ id: number; email: string }>`
SELECT id, email
FROM users
WHERE email = ${email}
`.execute(db)---
6. raw SQL 사용 원칙
- query builder로 자연스럽게 표현되면 builder를 유지한다.
- SQL 자체가 더 설명적이면
sql을 쓴다. - 벤더 종속 SQL은 reference나 helper 함수로 의도를 남긴다.
- 사용자 입력을 문자열로 조립하지 않는다.
- 서비스 계층에서는 raw SQL 여부보다 반환 shape와 transaction boundary를 더 중요하게 본다.
Kysely 비교, 운영 원칙, 함정
언제 읽는가
- Kysely를 Prisma/Drizzle와 비교해야 할 때
- Kysely 도입 여부를 판단할 때
- 흔한 타입 실수나 query-builder 오용을 점검할 때
---
1. Kysely vs Drizzle vs Prisma
| 항목 | Kysely | Drizzle | Prisma |
|---|---|---|---|
| 기본 성격 | SQL-first query builder | schema-first query builder | ORM |
| 타입 안전성 | schema → query → result 전 구간 | schema → query 전 구간 | generated client 중심 |
| SQL 제어력 | 높음 | 높음 | 상대적으로 제한적 |
| raw SQL 친화성 | 매우 높음 | 높음 | 낮음 |
| relation abstraction | 직접 설계 | 일부 제공 | 강함 |
| codegen 의존성 | 선택 | 낮음 | 높음 |
| 적합한 상황 | 복잡한 SQL, 기존 DB, 성능 민감 | TS schema 중심 greenfield | 빠른 개발, ORM 선호 팀 |
Kysely를 고르기 좋은 경우
- 팀이 SQL을 읽고 유지보수할 수 있다.
- 복잡한 join, subquery, CTE, window function이 많다.
- 기존 데이터베이스를 그대로 활용해야 한다.
- ORM의 relation abstraction보다 SQL 제어권이 더 중요하다.
- edge/serverless처럼 bundle 부담을 줄이고 싶다.
다른 선택지가 더 나은 경우
- relation mapping과 높은 추상화가 더 중요하다면 Prisma가 나을 수 있다.
- schema-first 선언 경험을 더 선호하면 Drizzle이 더 잘 맞을 수 있다.
- 팀이 SQL에 익숙하지 않다면 Kysely는 학습 비용이 높아질 수 있다.
---
2. 운영 원칙
1. Kysely를 ORM처럼 다루지 않는다. 2. 결과 shape는 query에서 명시적으로 설계한다. 3. 성능은 인덱스와 SQL shape가 좌우한다. 4. migration은 small batch로 나눈다. 5. raw SQL은 escape hatch이지 실패가 아니다. 6. repository/service 경계에서 return type을 안정화한다.
---
3. 흔한 함정
execute()를 빼먹는 경우
const users = db.selectFrom("users").selectAll()위 코드는 결과가 아니라 query builder다.
const users = await db.selectFrom("users").selectAll().execute()Generated를 빼먹는 경우
interface UserTable {
id: number
}이렇게 두면 INSERT 시 id 입력을 요구받는다.
import { Generated } from "kysely"
interface UserTable {
id: Generated<number>
}LEFT JOIN nullability를 무시하는 경우
const result = await db
.selectFrom("users")
.leftJoin("posts", "posts.user_id", "users.id")
.select(["users.name", "posts.title"])
.execute()여기서 posts.title은 string | null일 수 있다.
builder를 과도하게 고집하는 경우
쿼리가 지나치게 복잡해져 읽기 어려우면 CTE나 raw SQL로 전환한다. Kysely 사용의 목적은 builder 순수주의가 아니라 type-safe SQL 유지다.
---
4. 실무 체크리스트
- 결과 row shape가 호출부 기대와 정확히 맞는가
- nullable 컬럼과 join nullability를 처리했는가
- transaction boundary가 명확한가
- dialect-specific SQL을 별도 helper나 reference로 남겼는가
- migration이 backward compatible한가
- 인덱스와 query plan을 별도로 검토했는가
---
5. 참고 리소스
- 공식 문서: https://kysely.dev
- GitHub: https://github.com/kysely-org/kysely
- Playground: https://kysely-org.github.io/kysely-playground/
- codegen: https://github.com/RobinBlomberg/kysely-codegen