Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Prisma 클라이언트 설정 파악하기

Node.js/TypeScript용 ORM Prisma 살펴보기에서 Prisma의 기본적인 기능을 살펴봤다. 일반적인 웹 애플리케이션 서버라면 이후에는 Prisma 클라이언트를 이용해서 쿼리를 사용하면 되지만 나 같은 경우는 Prisma 클라이언트를 다수 만들고 싶었다. 보통 데이터베이스 종류에 상관없이 사용할 데이터베이스는 한 가지만 사용하겠지만 내가 Prisma를 도입한 프로젝트는 사용자가 원하는 데이터베이스를 선택해서 사용하게 하고 싶었기 때문에 지원하는 데이터베이스를 늘려가면서 여러 타입의 데이터베이스를 지원해야 했기에 클라이언트도 여러 클라이언트가 필요했다.

보일러플레이트로 만들어진 코드 외에 파일의 위치나 구조를 바꾸다 보니 좀 더 Prisma의 구조에 관해 알게 되었다.

임의 위치의 스키마 파일

postgresql/db.schema 파일을 만들어서 이전 글에서도 본 간단한 스키마 파일을 생성해 보자. 일부러 기본 위치인 prisma/schema.prisma가 아닌 임의의 파일명을 사용했다.

// postgresql/db.schema
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

이제 이전처럼 마이그레이션을 시도하면 당연히 schema.prisma 파일을 찾을 수 없다고 오류가 발생한다.

$ ./node_modules/.bin/prisma migrate dev --name init

Error: Could not find a schema.prisma file that is required for this command.
You can either provide it with --schema, set it as `prisma.schema` in your package.json or put it into the default location ./prisma/schema.prisma https://pris.ly/d/prisma-schema-location

기본 위치가 아니기 때문에 못 찾는 것이므로 --schema 옵션으로 스키마 파일을 지정해 주면 된다.

$ ./node_modules/.bin/prisma migrate dev --name init --schema postgresql/db.schema

Prisma schema loaded from postgresql/db.schema
Datasource "db": PostgreSQL database "postgres", schema "public" at "localhost:5432"

Applying migration `20220829163109_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20220829163109_init/
    └─ migration.sql

Your database is now in sync with your schema.

Running generate... (Use --skip-generate to skip the generators)

added 2 packages, and audited 5 packages in 3s

found 0 vulnerabilities

✔ Generated Prisma Client (4.2.1 | library) to ./node_modules/@prisma/client in 45ms

이때 생성되는 migrations 폴더도 스키마 파일 옆에 생성된다. 그래서 여기서는 postgresql/migrations에 생성된다. --schema 옵션은 prisma generate로 마이그레이션 없이 Prisma 클라이언트를 생성할 때도 사용할 수 있다.

Prisma 클라이언트

처음 npm install prisma로 설치하면 아래처럼 설치가 된다.

node_modules
├── @prisma
│   └── engines
└── prisma
    ├── LICENSE
    ├── README.md
    ├── build
    ├── install
    ├── package.json
    ├── preinstall
    ├── prisma-client
    └── scripts

./node_modules/.bin/prisma generate --schema postgresql/db.schema로 클라이언트를 생성하면 package.json에도 "@prisma/client가 추가되고 node_modules에도 추가 파일이 생성된다.

node_modules
├── .prisma
│   └── client
├── @prisma
│   ├── client
│   ├── engines
│   └── engines-version
└── prisma
    ├── LICENSE
    ├── README.md
    ├── build
    ├── install
    ├── libquery_engine-darwin-arm64.dylib.node
    ├── package.json
    ├── preinstall
    ├── prisma-client
    └── scripts

.prisma/client, @prisma/client, @prisma/engines-version이 추가되고 prisma 아래도 libquery_engine-darwin-arm64.dylib.node가 추가되었다.

처음 사용할 때 클라이언트를 생성하면 node_modules아래 추가되는 게 좀 어색하게 느껴졌다. 처음에는 클라이언트가 생성되는 구조를 잘 몰랐기 때문에 내가 설치하지도 않은 패키지를 임포트해서 사용하면서 왜 동작하는지도 의아했다. package.json에 추가해 주는 것도 이러한 어색함을 줄여주기 위함으로 보인다.

하지만 이렇게 사용할 경우 node_modules/@prisma/client에 클라이언트가 설치되기 때문에 앞에서 얘기했듯이 여러 클라이언트를 만들어야 한다면 사용할 수 없다.

임의 위치의 Prisma Client

클라이언트를 다른 곳에 생성하려면 스키마 파일의 generator에서 output을 지정해 주면 된다. 여기서 상대 경로는 스키마 파일의 위치를 기준으로 상대 경로가 된다.

// postgresql/db.schema
generator client {
  provider = "prisma-client-js"
  output   = "./client"
}

이렇게 하고 클라이언트를 생성하면 node_modules/@prisma/client는 추가되지만 실제 사용하는 클라이언트는 postgresql/client 위치에 설치된다.

postgresql/client
├── index-browser.js
├── index.d.ts
├── index.js
├── libquery_engine-darwin-arm64.dylib.node
├── package.json
├── runtime
│   ├── edge.js
│   ├── index-browser.d.ts
│   ├── index-browser.js
│   ├── index.d.ts
│   └── index.js
└── schema.prisma

이제 아래처럼 node_modules아래서가 아니라 클라이언트 위치에서 임포트해서 Prisma 클라이언트를 생성할 수 있다.

const { PrismaClient } = require('./postgresql/client');


Prisma 엔진

이때쯤 Prisma 엔진의 존재를 알아야 하는데 Prisma가 node_modules 아래 클라이언트를 생성하는 것 이유가 있어 보인다.

Typical flow of the query engine at run time
출처: The query engine at runtime 문서

내가 일반적으로 쓰던 ORM과 달리 Prisma에는 엔진이라는 중간 서버가 있다. 그래서 Prisma 클라이언트가 바로 데이터베이스에 접속하는 게 아니라 처음 실행될 때 바이너리로 된 Prisma 엔진(쿼리 엔진)이 실행되고 클라이언트는 HTTP로 Prisma 엔진에 요청을 보내면 엔진이 SQL을 만들어서 데이터베이스에 질의하고 그 결과를 다시 클라이언트로 보내준다.

클라이언트를 생성했을 때 만들어진 libquery_engine-darwin-arm64.dylib.node 파일이 쿼리 엔진이고 이 파일은 클라이언트가 Node-API로 불러온다.

// connect.js
const { PrismaClient } = require('./postgresql/client');

const prisma = new PrismaClient();

prisma.$connect()
  .then(() => {
    console.log('connected');
  })
  .catch((err) => {
    console.log(err);
  });

아래 JavaScript 파일로 데이터베이스 연결을 테스트해보자. DEBUG=* 환경변수를 지정해서 prisma의 디버그 로그를 켰다.

$ DEBUG=* node connect.js
  prisma:tryLoadEnv  Environment variables not found at null +0ms
  prisma:tryLoadEnv  Environment variables not found at undefined +0ms
  prisma:tryLoadEnv  No Environment variables loaded +0ms
  prisma:tryLoadEnv  Environment variables not found at null +1ms
  prisma:tryLoadEnv  Environment variables not found at undefined +0ms
  prisma:tryLoadEnv  No Environment variables loaded +0ms
  prisma:client  dirname /Users/outsider/prisma-demo/postgresql/client +0ms
  prisma:client  relativePath .. +0ms
  prisma:client  cwd /Users/outsider/temp/prisma-demo2/postgresql +0ms
  prisma:client  clientVersion 4.2.1 +0ms
  prisma:client  clientEngineType library +0ms
  prisma:client:libraryEngine  internalSetup +0ms
  prisma:client:libraryEngine:loader  Searching for Query Engine Library in /Users/outsider/prisma-demo/.prisma/client +0ms
  prisma:client:libraryEngine:loader  Searching for Query Engine Library in /Users/outsider/prisma-demo/postgresql/client +0ms
  prisma:client:libraryEngine:loader  loadEngine using /Users/outsider/prisma-demo/postgresql/client/libquery_engine-darwin-arm64.dylib.node +1ms
  prisma:client:libraryEngine  library starting +6ms
prisma:info Starting a postgresql pool with 21 connections.
  prisma:client:libraryEngine  library started +34ms
connected
  prisma:client:libraryEngine:exitHooks  exit event received: beforeExit +0ms
  prisma:client:libraryEngine:exitHooks  exit event received: exit +1ms

위 로그를 살펴보면 엔진이 로딩되는 것을 볼 수 있다. 중요한 부분을 보면 아래와 같은데 엔진 타입은 library이고 엔진 파일인 libquery_engine-darwin-arm64.dylib.node로 엔진을 불러온 것을 알 수 있다.

prisma:client  clientEngineType library
prisma:client:libraryEngine:loader  loadEngine using /Users/outsider/prisma-demo/postgresql/client/libquery_engine-darwin-arm64.dylib.node +1ms

Node-API 대신 실행할 수 있는 바이너리로 엔진을 실행할 수도 있다. 이때는 스키마에서 engineType = "binary"을 지정하면 된다.

// postgresql/db.schema
generator client {
  provider = "prisma-client-js"
  output   = "./client"
  engineType = "binary"
}

이 설정으로 클라이언트를 생성하면 libquery_engine-darwin-arm64.dylib.node 대신 query-engine-darwin-arm64가 생성된다. 다시 연결 테스트를 해보자.

$ DEBUG=prisma:* node connect.js
  prisma:tryLoadEnv  Environment variables not found at null +0ms
  prisma:tryLoadEnv  Environment variables not found at undefined +0ms
  prisma:tryLoadEnv  No Environment variables loaded +0ms
  prisma:tryLoadEnv  Environment variables not found at null +1ms
  prisma:tryLoadEnv  Environment variables not found at undefined +0ms
  prisma:tryLoadEnv  No Environment variables loaded +0ms
  prisma:client  dirname /Users/outsider/prisma-demo/postgresql/client +0ms
  prisma:client  relativePath .. +0ms
  prisma:client  cwd /Users/outsider/prisma-demo/postgresql +0ms
  prisma:client  clientVersion 4.2.1 +0ms
  prisma:client  clientEngineType binary +0ms
  prisma:engine  { cwd: '/Users/outsider/prisma-demo/postgresql' } +0ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/.prisma/client +1ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/postgresql/client +0ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/.prisma/client +0ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/postgresql/client +0ms
  prisma:engine  {
  flags: [
    '--enable-raw-queries',
    '--enable-metrics',
    '--enable-open-telemetry',
    '--port',
    '51376'
  ]
} +9ms
  prisma:engine  stdout Starting a postgresql pool with 21 connections. +409ms
  prisma:engine  stdout Started query engine http server on http://127.0.0.1:51376 +29ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/.prisma/client +7ms
  prisma:engine  Search for Query Engine in /Users/outsider/prisma-demo/postgresql/client +0ms
connected
  prisma:engine  Client Version: 4.2.1 +6ms
  prisma:engine  Engine Version: query-engine 2920a97877e12e055c1333079b8d19cee7f33826 +0ms
  prisma:engine  Active provider: postgresql +0ms

아까와 달리 clientEngineType binary로 지정된 것을 알 수 있고 prisma:engine 로그가 다수 출력된 것을 볼 수 있다. 별도의 바이너리로 실행되었기 때문에 HTTP 서버로 실행되어서 클라이언트와 데이터베이스 사이에 통신 역할을 한다.

이 쿼리 엔진은 커넥션 풀을 이용해서 데이터베이스의 연결을 관리하고 클라이언트에서 쿼리 요청받으면 SQL을 생성해서 데이터베이스에 SQL을 질의하고 받은 응답을 다시 클라이언트에 보내준다. 문서를 좀 봤는데도 엔진에서 library 타입과 binary 타입의 장단점을 잘 모르겠다.

앞에서 Prisma 클라이언트가 node_modules에 만들어지는 이유를 알 것 같다고 했는데 아마도 이 엔진 때문일 것으로 생각한다. 스키마를 기반으로 만들어진 클라이언트는 스키마에 묶이기 때문에 언제든 재생성할 수 있지만 Git 같은 버전 관리에 넣는다고 해도 크게 문제 될 것은 없어 보인다.(재생성할 수 있으니 굳이 넣지 않을 뿐...) 하지만 엔진 바이너리인 libquery_engine-darwin-arm64.dylib.node같은 경우 파일명에서 알 수 있듯이 macOS ARM64용 파일이다. 로컬에서 macOS로 개발하고 이를 Docker에 넣어서 Linux에서 실행한다면 당연히 엔진이 실행되지 않을 것이다. node_modules는 버전 관리에 넣지 않는 것이 일반적이므로 일부러 여기 넣어서 각 배포환경에서 매번 클라이언트를 생성하도록 하려는 의도일 것이고 이 글에서 보았듯이 다른 곳에 클라이언트를 생성하게 설정했다고 하더라도 바이너리 때문에 매번 생성하는 것이 바람직해 보인다.

// postgresql/db.schema
generator client {
  provider = "prisma-client-js"
  output   = "./client"
  binaryTargets = ["darwin", "darwin-arm64", "windows", "debian-openssl-1.1.x", "linux-musl"]
}

특별한 이유로 다양한 플랫폼에 바이너리를 생성해야 한다면 위처럼 binaryTargets에 지정하면 된다. 지원하는 타깃은 문서에서 확인할 수 있다.

최근에 다양한 테스트를 해보면서 약간의 Prisma 구조는 파악했지만, 아직도 모르는 부분이 더 많다. 이것 때문에 고생해서 그런지 몰라도 왜 엔진을 별도로 바이너리로 제공하는지도 약간 의문이기도 하다. 써보다 보면 좀 더 파악되지 않을까 싶다.

2022/08/30 03:37 2022/08/30 03:37