서론
들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,
전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다.
Prisma는 왜 Type-Safe할까?
TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장
mag1c.tistory.com
오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니다.
제가 처음으로 Prisma 생성한 이번 PR은 6.14.0에 반영된 Prisma의 퍼포먼스 개선에서 발생한 이슈를 해결한 내용으로, 퍼포먼스 개선 PR을 분석해보고, 제 PR이 이전 PR의 어떤 문제를 해결했는지의 순서로 작성되었습니다.
문제 원인: 타입 퍼포먼스 개선
6.14.0 릴리즈 노트 안에는, IDE에서 타입 추론에 대한 성능 개선의 내용도 있었습니다.
벤치마크 테스트 코드를 보면, 타입 체킹이 약 1300만회에서 1000회 수준으로 엄청난 퍼포먼스 개선이 있었습니다.
하지만 이 변경사항으로 6.12.0에 Preview로 등장한 ESM 호환 Generator를 사용할 때, 기존 처럼 PrismaClient를 사용할 수 없게 되었습니다.
PR에서, 타입 퍼포먼스 개선을 위해 변경된 주요 코드 PrismaClient 생성자 인터페이스고, 변경 사항은 다음과 같습니다.
// <=6.13.0: ClientOptions와 로그 옵션 제네릭에 기본값이 있었다.
export interface PrismaClientConstructor {
new <
ClientOptions extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
U = LogOptions<ClientOptions>,
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Prisma.Subset<ClientOptions, Prisma.PrismaClientOptions>):
PrismaClient<ClientOptions, U, ExtArgs>
}
// >=6.14.0: 타입 체크 성능을 높이기 위해 개선
// 1. 기존의 ClientOptions을 분리 (기존의 Options, LogOpts, OmitOpts)
// 2. ⭐️ PrismaClientOptions, LogOptions에 타입 기본값이 제거됨 ⭐️
export interface PrismaClientConstructor {
new <
Options extends Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
>(options?: Options): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}
아래는, 6.14.0에 반영된 타입 퍼포먼스 개선에 대한 PR과, 개선의 이유를 정리해봤습니다.
타입 퍼포먼스 개선의 이유
1. 문제 원인
제가 Prisma의 의도를 정확히 파악할 수는 없겠으나, TypeScript의 덕 타이핑에서 문제를 찾을 수 있었습니다.
type PrismaClientOptions = {
log?: LogLevel[] | LogDefinition[]
datasourceUrl?: string
omit?: {
[ModelName: string]: {
[FieldName: string]: boolean
}
}
}
// <=6.13.0: 하나의 제네릭
type OldPrismaClient<ClientOptions extends PrismaClientOptions> = {
user: UserDelegate<ClientOptions>
post: PostDelegate<ClientOptions>
// schema.prisma에 정의한 여러 모델들.
}
type UserDelegate<ClientOptions> = {
findMany: (args?: UserFindManyArgs<ClientOptions>) => Promise<User[]>
findFirst: (args?: UserFindFirstArgs<ClientOptions>) => Promise<User | null>
create: (args: UserCreateArgs<ClientOptions>) => Promise<User>
// Prisma에서 지원하는 수많은 인터페이스들
}
type UserFindManyArgs<ClientOptions> = {
where?: UserWhereInput
select?: UserSelect<ClientOptions>
include?: UserInclude<ClientOptions>
omit?: ExtractOmit<ClientOptions, 'user'> // 재귀적으로 Omit하여 Payload 생성
}
type ExtractOmit<ClientOptions, Model> =
ClientOptions extends { omit: infer O }
? O extends { [K in Model]: infer Fields }
? Fields
: never
: never
우리는 PrismaClient 인스턴스를 생성하는 데서 끝이지만, 내부적으로 TypeScript는 거대한 하나의 제네릭 타입을 통해 아래와 같이 타입을 추론합니다. 현재 Nest를 사용하고 있기 때문에, Nest로 예를 들어보겠습니다.
일반적으로 Nest에서 PrimsaClient는 다음과 같은 방식으로 사용됩니다.
// 1. PrismaClient를 상속한 DB Connector Service (NestJS 공식문서 예제)
class PrismaService extends PrismaClient { // 상속 시 타입 체크
constructor() {
super({ log: ['query'] })
}
}
// 2.의존성 주입으로 사용.
class UserService {
constructor(private prisma: PrismaClient) {} // 파라미터 타입 체크
}
// 3. 트랜잭션을 위한 유틸리티 함수
async function withTransaction(
prisma: PrismaClient, // 파라미터 타입 체크
callback: (tx: PrismaClient) => Promise<void>
) {
}
// 4. 혹은 일반적인 타입 명시
let myClient: PrismaClient;
myClient = new PrismaClient({ log: ['query'] });
TypeScript는 덕 타이핑을 통해 타입 호환성을 체크합니다.
이 때, 하나의 거대한 제네릭인 ClientOptions가 모든 곳에 전파되어 모든 모델의 메서드가 ClientOptions에 의존하는 상황이 만들어집니다. 덕 타이핑 때문에, 모든 ClientOptions가 있는 생성된 프리즈마 코드들을 돌면서, 구조 전체를 체크해야만 했습니다.
간단하게 다시 설명해보겠습니다.
model User {
id Int @id @default(autoincrement())
name String
}
위처럼 모델이 user 하나만 있다고 가정해도, 타입 호환성의 체크는 다음과 같이 이루어질 것으로 예상됩니다.
class PrismaService extends PrismaClient {
constructor() {
super({ log: ['query'] })
}
}
// TS는 PrismaService의 인스턴스가 PrismaClient와 호환되는지 체크합니다.
// 프로퍼티, 함수 시그니처, 파라미터 등.
// 프로퍼티 체크
// PrismaService는 PrismaClient의 모든 프로퍼티를 가지는가?
// user: UserDelegate
// $connect(): Promise<void>
// $disconnect(): Promise<void>
// ... 수십 개의 프로퍼티와 메서드
// 메서드 시그니처 체크
// (args?: UserFindManyArgs<{ log: ['query'] }>) => Promise<User[]>
// ↑ ClientOptions 전체가 전파
// 메서드 파라미터의 깊은 체크
// UserFindManyArgs의 모든 프로퍼티 체크
// - where?: UserWhereInput
// - select?: UserSelect<{ log: ['query'] }>
// - include?: UserInclude<{ log: ['query'] }>
// - omit?: ExtractOmit<{ log: ['query'] }, 'user'>
// 중첩된 타입들의 재귀적 체크
// select의 각 필드, include의 관계들 등등....
// 이 과정이 모든 모델, 모든 메서드에 대해 반복됩니다.
모델이 커지면 커질수록, 모델의 필드가 많아질수록 비례해서 증가하게 되겠죠. 이는 Prisma의 벤치마크 코드에도 잘 드러나있습니다.
// Prisma의 benchmark 코드 일부
bench('log config applied', () => {
const client = new PrismaClientConstructor({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
})
/**
* TypeScript는 덕타이핑을 사용하기 때문에,
* passClientAround(client) 호출 시점에 client가
* PrismaClient 타입과 구조적으로 동일한지 모든 프로퍼티와 메서드를 비교합니다.
*/
const passClientAround = (prisma: PrismaClient) => {
return prisma
}
return passClientAround(client)
}).types([13720983, 'instantiations']) // 1300만건
2. 개선
위에서 정리한 내용을 바탕으로, 이 문제의 원인은 ClientOptions라는 거대한 하나의 타입만 사용했기 때문입니다.
그래서 Prisma에서는 아래와 같이 타입들을 목적별로 분리한 것 같아요.
- LogOpts: $on() 메서드에만 영향을 주는 로그 옵션들
- OmitOpts: 모델 메서드에만 영향을 주는 옵션들
이를 통해 불필요한 타입 전파를 차단하려고 하는 시도였다고 볼 수 있을 것 같습니다. 기대 효과는, 변경된 벤치마크 코드에서도 볼 수 있다시피 엄청난 개선이 되었습니다. 위의 로그 옵션에 대한 벤치마크 코드를 다시 볼까요?
bench('log config applied', () => {
const client = new PrismaClientConstructor({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
})
const passClientAround = (prisma: PrismaClient) => {
// @ts-expect-error - using a non-existent event type is a type error
prisma.$on('foobarbaz', (event) => {
console.log(event)
})
return prisma
}
const passToAnyClientAround = (prisma: PrismaClient<any>) => {
prisma.$on('info', (event) => {
console.log(event)
})
return prisma
}
client.$on('query', (event) => {
console.log(event)
})
// @ts-expect-error - info is not a valid event type because we do not pass it in the client options
client.$on('info', (event) => {
console.log(event)
})
passClientAround(client)
passToAnyClientAround(client)
}).types([697, 'instantiations']) // 1300만 > 697
타입 세분화를 통해 로그 옵션에 대한 타입 호환성을 체크하다보니, 같은 모델을 정의했다고 하더라도 약 1300만 회에서 700회 정도로 말도 안되는 수치로 감소했던 것을 볼 수 있습니다.
breaking changes 해결하기
다시 제 기여 얘기로 돌아와서, 위 변경사항 때문에 6.14.0으로 업데이트를 했을 때 PrismaClient를 사용하는 거의 모든 코드에서 빨간 줄이 등장(?)하게 되었습니다.
(6.12.0에 도입된 ESM-Generator을 사용할 때 발생하며, 기존의 js Generator은 이상 없는 것으로 확인했습니다.)
NestJS 기준의 코드 예시인데요, 어떤 프레임워크든 간에 Prisma를 사용하는 많은 개발자들이 PrismaClient에 제네릭을 명시하지 않고 사용했기 때문에 위 변경 사항은 breaking changes가 될 수 밖에 없습니다. 모든 PrismaClient관련 코드를 수정해야했으니까요. 아래처럼요.
PR 내용만 보면 별 거 없는 딱 두 줄의 추가인데요. 저는 기존 퍼포먼스 향상을 유지하면서도 많은 개발자들이 코드를 수정하지 않고도 기존 코드 그대로 사용이 가능하도록, PrismaClient에 디폴트 타입을 추가해줬습니다.
// Options, LogOpts에 디폴트 옵션 추가.
export interface PrismaClientConstructor {
${indent(this.jsDoc, TAB_SIZE)}
new <
Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
LogOpts extends LogOptions<Options> = LogOptions<Options>,
OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
>(options?: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>
이렇게 해서, 바로 PR이 merge가 되었고, 제 PR만 단독으로 머지된 탓에, 아마 Latest Commit에 제 프로필 사진이 올라가지 않았나 싶네요.
정리
Prisma를 거의 처음 사용해보면서, 오픈소스 기여를 위해 관련 코드를 깊이 파헤쳐보고 궁금증이 생겨 Prisma가 왜 Type-Safe한 ORM인지까지 돌아봤습니다. 물론 프리즈마 엔진 코드가 생소한 Rust이고, Prisma에 익숙하지 않아 분석이 다소 완벽하지 않았네요.
단 한 줄, 두 줄의 코드 변경으로 대다수의 개발자들에게 breaking changes 없이 확장된 기능, 더 좋은 퍼포먼스를 제공할 수 있는 기여를 했다는 생각에 현재까지 기여 중에 코드 길이 대비 가장 뿌듯했던 기여 순간이었던 것 같습니다. 더불어 TypeScript와도 조금 더 친해지는 계기가 되었던 것 같아요.
오픈소스 기여는 이렇게 단 한줄의 변경으로 수 억명의 사람들에게 임팩트를 줄 수 있고, 더불어 사용하고 있는 기술에 대한 깊은 이해, 기술의 기반이 되는 더 깊이 있는 지식까지도 습득할 수 있는 좋은 기회인 것 같습니다. 앞으로도 여기저기 사용하는 기술들에 대해 관심 있게 둘러 볼 예정입니다.
오픈 소스 기여에 어려움을 겪고 계신 분들이 있다면, 인제님이 운영하시는 오픈소스 기여 모임에 참여해보시는 것은 어떨까요?
다양한 분야에서 여러 기여를 하신 운영진분들과 참여자분들과 소통하면서, 이슈 선정부터 PR 기여까지 많은 도움을 얻을 수 있습니다!
참조
https://www.typescriptlang.org/docs/handbook/2/generics.html
https://github.com/prisma/prisma/releases/tag/6.14.0
https://github.com/prisma/prisma/pull/27777