Prisma는 왜 Type-Safe할까?

OpenSource 2025. 8. 21. 23:52
728x90
728x90

 

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.

제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠)

 

이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.

TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다.

 

 

 

 

Prisma

Prisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다. (prisma generate)

 

https://www.prisma.io/typescript

 

그래서일까요? 많은 레퍼런스들에서도 Type-Safe하다고 언급하고, 공식문서에서도 fully type-safe, zero-cost type-safe하다고 명시되어있습니다. 이쪽 진영(?)의 ORM이 익숙하지 않으신 분들을 위해 예시를 준비했습니다.

 

비교 대상은 이름부터 엄청나게 type-safe할 것 같은 TypeORM입니다.

 

 

TypeORM vs Prisma

class UserEntity {
    @PrimaryGeneratedColumn('increment')
    id!: number;
    @Column()
    name!: string;
}

async getName(id: number) {
    const user = await this.userRepostiory.findOne({
    	select: ['name'],
        where: { id }
    });
    return user;
}

 

코드를 통해 getName()의 리턴 타입이 Pick<UserEntity, 'name'>일 것으로 예상됩니다.

하지만 TypeORM은 Entity 자체를 타입으로 추론합니다. 엉뚱하죠? 실제 동작과 타입 추론이 어긋납니다.

getName()에서 user를 할당한 후, user.id에 접근도 잘 됩니다. IDE도 올바르게 추적해주고, 경고나 에러도 발생시키지 않죠.

 

물론, getName을 호출하는 함수의 리턴타입을 강제하는 방법도 있습니다만, 우리는 그걸 원치 않습니다.

물론, TypeORM을 사용하면서 전역으로 타입을 래핑하는 인터페이스를 만들 수도 있습니다만, 우리는 그걸 원치 않습니다.

오픈소스를 사용하는 이유는 다양하지만, 편리하고 다양한 기능을 제공받아 사용하는 이유도 있으니까요. 결국 개발자가 직접 래핑해야 하는 번거로움이 생기죠 (귀찮아요 ㅡㅡ (?))

 

 

 

 

Prisma는 어떨까요?

model User {
  id   Int    @id @default(autoincrement())
  name String
}

class PrismaService extends PrismaClient {
  async getUserName(id: number) {
    return await this.user.findFirst({
      where: { id },
      select: {
        name: true,
      },
    });
  }
}

 

 

Prisma에서 같은 쿼리를 실행시켜보면, 결과는 정확하게 select된 프로퍼티들만 타입으로 추론합니다.

 

 

 

TypeORM의 타입 추론

간략한 이유는 다음과 같습니다.

 

TypeORM은, 테이블 모델 설계 문서가 아닌 실제 클래스 형태로 엔터티를 작성합니다. 그리고 Repository Interface 사용을 위해 TypeORM의 Repository 클래스의 제네릭으로 엔터티 타입을 명시하여 사용하죠. Repository의 각 인터페이스들은 이 Entity 제네릭 타입을 받아서 타입 추론을 하도록 구성되어있습니다. 아래처럼요.

const userRepository = new Repository<UserEntity>();

class Repository<Entity extends ObjectLiteral> {
	findOne(options?: FindOneOptions<Entity>): Promise<Entity | undefined>;
}

 

ORM에서 FindOptions의 select의 키인 keyof Entity로 타입 추론을 해주지 않는 이상, 위에서 말한 것 처럼 개발자가 직접 타입 좁히기를 통해 type-safe하게 만들어서 사용해야합니다.

 

 

Prisma의 타입 추론

반면에 Prisma는 다르죠. Prisma는 스키마 기반 코드 생성 ORM입니다.

런타임 전에 schema.prisma를 해석해서(prisma generate) @prisma/client를 만들어 둡니다. 그래서 쿼리 작성 시점부터 타입이 이미 결정되어 있고, select/include에 따라 반환 타입이 정교하게 내로잉됩니다.

 

코드를 보면서 간략하게 타입을 만들어내는 파이프라인을 요약해봤습니다.

schema.prisma > Rust/WASM 파서 > DMMF JSON > DMMF Helper 클래스 > TSClient 코드 생성 > 파일 출력(@prisma/client로)

 

각 과정들을 조금 더 자세히 보도록 하죠.

 

1. prisma generate

npx prisma generate와 같은 명령어를 사용해보셨을거에요. Prisma의 타입 생성은 이 명령어로 시작됩니다.

이 명령은 CLI 내부의 Generate.ts에서 실행 흐름이 잡히고, 이후 각 generator을 초기화하고 실행시킵니다.

// packages/cli/src/Generate.ts
export class Generate implements Command {
  async execute() {
    // generator 등록 및 실행 흐름 담당
  }
}

 

이 때, getGenerators 함수가 팩토리 역할을 수행하고, schema.prisma의 generator client { ... } 설정을 읽고 @prisma/client 생성기를 찾아 초기화합니다.

 

 

2. Schema 파싱 및 가공

schema.prisma를 중간 포맷인 DMMF(Data Model Meta Format)으로 파싱합니다.

이 과정은 Rust로 구현된 프리즈마 엔진의 파서를 WASM(WebAssembly)로 컴파일해서 NodeJS에서 실행합니다.

// packages/client/src/getDmmf.ts
const dmmf = await wasm.getDMMF({ datamodel: schema })

 

직접 wasm이 구현되어 있는 Rust코드를 당장에는 이해할 수 없어, 축약하자면 getDMMF를 통해 스키마가 해석되고, 결과는 JSON 형태의 DMMF로 변환됩니다. 이 JSON형태 위에 모델, 필드, 연관 관계등을 쉽게 탐색할 수 있도록 DMMF 클래스로 래핑되고, 모델/타입/연산을 빠르게 조회할 수 있는 Map이 만들어집니다.

 

 

3. TypeScript 코드 생성(PrismaClient)

이 과정 후에, PrismaClientTsGenerator에 의해 Prisma Client 코드를 생성하는데요.

export class TSClient {
  protected readonly dmmf: DMMFHelper
  protected readonly genericsInfo: GenericArgsInfo

  constructor(protected readonly options: TSClientOptions) {
    this.dmmf = new DMMFHelper(options.dmmf)
    this.genericsInfo = new GenericArgsInfo(this.dmmf)
  }

  generateClientFiles(): FileMap {
    const context = new GenerateContext({
      dmmf: this.dmmf,
      genericArgsInfo: this.genericsInfo,
      runtimeImport: `${this.options.runtimeBase}/${this.options.runtimeName}`,
      outputFileName: generatedFileNameMapper(this.options.generatedFileExtension),
      importFileName: importFileNameMapper(this.options.importFileExtension),
      generator: this.options.generator,
    })

    const modelNames = Object.values(context.dmmf.typeAndModelMap)
      .filter((model) => context.dmmf.outputTypeMap.model[model.name])
      .map((model) => model.name)

    const modelsFileMap: FileMap = modelNames.reduce((acc, modelName) => {
      acc[context.outputFileName(modelName)] = createModelFile(context, modelName)
      return acc
    }, {})

    return {
      [context.outputFileName('client')]: createClientFile(context, this.options),
      [context.outputFileName('enums')]: createEnumsFile(context),
      [context.outputFileName('commonInputTypes')]: createCommonInputTypeFiles(context),
      [context.outputFileName('models')]: createModelsFile(context, modelNames),
      models: modelsFileMap,
      internal: {
        [context.outputFileName('prismaNamespace')]: createPrismaNamespaceFile(context, this.options),
        [context.outputFileName('class')]: createClassFile(context, this.options),
      },
    }
  }
}

 

위에서 열심히 만든 JSON 래핑 클래스인 DMMFHelper을 토대로 PrismaClient를 생성합니다. 모델별 CRUD 메서드와 쿼리 옵션, 타입 시그니처들이 생성되게 되죠. 생성되는 대표 파일 구성은 대략 다음과 같습니다.

  • client: class PrismaClient { user: UserDelegate; ... } 및 각 모델 delegate 메서드 시그니처
  • models/*, models.ts: 모델 단위 타입 조각들
  • enums.ts: 스키마 enum → TS enum/type
  • commonInputTypes.ts: WhereInput, OrderBy, ScalarWhereWithAggregates 같은 공용 인풋 타입
  • internal/prismaNamespace.ts: Prisma.UserGetPayload<S>, Prisma.UserSelect 등 타입 네임스페이스
  • 런타임 번들 참조: 환경별(node, edge, react-native, wasm) 런타임 import 경로 세팅

 

 

그래서 왜 Prisma는 type-safe한데?

간략히 살펴본 generate 파이프라인의 결과물이 미리 계산된 타입 정의인데요.

이 타입 안에는 Model, Output, Payload라는 개념이 녹아있습니다.

  • Model: 스키마의 row에 대한 기본 스칼라가 typescript 인터페이스로 정의된 타입.
  • Output: 집계/그룹핑 처럼 형태가 확정적인 연산 결과는 AggregateUser, UserGroupByOutputType같은 명시적 Output 타입으로 노출돼요. row 관점의 원형 출력은 별도 파일/타입명으로 고정되어있지 않고 아래 Payload가 그 역할을 수행합니다.
  • Payload: 기본 정의된 Payload를 기반으로 제네릭 타입이 들어오면, 조건부/매핑 유틸리티 타입이 이를 해석해서 Payload를 즉석해서 추론합니다.
export type $UserPayload<ExtArgs = $Extensions.DefaultArgs> = {
  name: 'User';
  objects: {};                      // 관계(객체) 필드
  scalars: { id: number; name: string };  // 스칼라 필드
  composites: {};
}

type UserGetPayload<S extends boolean | null | undefined | UserDefaultArgs> =
  $Result.GetResult<Prisma.$UserPayload, S>;

 

 

다시 돌아와서, 위의 쿼리를 다시 살펴볼까요?

const user = await this.user.findFirst({
  where: { id },
  select: {
    name: true,
  },
});

>>

findFirst<T extends UserFindFirstArgs>(
  args?: SelectSubset<T, UserFindFirstArgs<ExtArgs>>,
): Prisma__UserClient<
  $Result.GetResult<
    Prisma.$UserPayload<ExtArgs>,  // 모델 전체의 스칼라 정의
    T,                             // args의 타입 ( { select: { name: true }}
    'findFirst',
    GlobalOmitOptions
  > | null,
  null,
  ExtArgs,
  GlobalOmitOptions
>;

 

여기서 일어나는 타입 계산을 단계별로 풀어보면

  • T 캡처: args 타입인 ({ select: { name: ture } })가 그대로 T로 캡처됩니다.
  • 유효성 검사: SelectSubset에 의해 허용되는 옵션만 통과시키도록 컴파일 타임에 필터링돼요.
  • 반환 타입 계산: 내부의 $Result.GetResult가 $UserPayload에서 T.select를 읽어 name만 뽑아 { name: string } 객체를 만듭니다. findFirst 특성상 결과가 없을 수 있기 때문에 | null이 붙게 돼요.

결과적으로, user.id에 접근하면 IDE에서 바로 타입 에러가 발생하는 메시지가 보이게 되는거죠.

 

 

 

 

 

정리

사실 한 번에 뜯어보고 이해를 온전히 할 수 없었어요.

제가 많은 오픈소스를 보지는 않았지만, TS기반의 오픈소스 중 가장 해석하기 힘들었습니다. 하지만 이런 파이프라인을 통해 왜 type-safe하다는 건지 어느정도 실마리를 잡은 것 같아요. 다음엔, 이렇게 Prisma를 뜯어보게 된 원인이 되었던 Prisma 6.14.0 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.

 

모자란 글이지만 읽어주셔서 감사합니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록