Drizzle와 Nile Database 사용하기

이 튜토리얼은 Drizzle ORM을 Nile Database와 함께 사용하는 방법을 보여줍니다. Nile은 멀티 테넌트 애플리케이션을 위해 재설계된 Postgres입니다.

이 튜토리얼에서는 Drizzle과 Nile의 가상 테넌트 데이터베이스를 사용하여 안전하고 확장 가능한 멀티 테넌트 애플리케이션을 개발하는 방법을 알아봅니다.

예제 애플리케이션을 단계별로 구축해 보겠습니다. 전체 예제를 먼저 보고 싶다면 Github 저장소를 참고하세요.

This guide assumes familiarity with:
  • Drizzle ORM과 Drizzle kit을 설치해야 합니다. 다음 명령어로 설치할 수 있습니다:
npm
yarn
pnpm
bun
npm i drizzle-orm
npm i -D drizzle-kit
  • 환경 변수 관리를 위해 dotenv 패키지를 설치해야 합니다. 자세한 내용은 여기를 참고하세요
npm
yarn
pnpm
bun
npm i dotenv
  • Postgres 데이터베이스 연결을 위해 node-postgres 패키지를 설치해야 합니다. 자세한 내용은 여기를 참고하세요
npm
yarn
pnpm
bun
npm i node-postgres
  • 웹 프레임워크로 express 패키지를 설치해야 합니다. Express에 대한 자세한 내용은 여기를 참고하세요
npm
yarn
pnpm
bun
npm i express
  • 이 가이드는 테넌트 컨텍스트 관리를 위해 AsyncLocalStorage를 사용합니다. 프레임워크나 런타임이 AsyncLocalStorage를 지원하지 않는 경우 Drizzle<>Nile 문서에서 대안을 확인하세요.

Nile과 Drizzle ORM 설정하기

Nile에 가입하고 데이터베이스 생성하기

아직 가입하지 않았다면 Nile에 가입하고 앱 안내에 따라 새 데이터베이스를 생성하세요.

데이터베이스 연결 문자열 가져오기

왼쪽 사이드바 메뉴에서 “Settings” 옵션을 선택하고, Postgres 로고를 클릭한 다음 “generate credentials”를 클릭합니다. 연결 문자열을 복사하여 프로젝트의 .env 파일에 추가하세요:

NILEDB_URL=postgres://youruser:yourpassword@us-west-2.db.thenile.dev:5432:5432/your_db_name

Drizzle ORM을 데이터베이스에 연결하기

src/db 디렉토리에 db.ts 파일을 생성하고 데이터베이스 설정을 구성하세요:

src/db/db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import dotenv from "dotenv/config";
import { sql } from "drizzle-orm";
import { AsyncLocalStorage } from "async_hooks";

export const db = drizzle(process.env.NILEDB_URL);
export const tenantContext = new AsyncLocalStorage<string | undefined>();

export function tenantDB<T>(cb: (tx: any) => T | Promise<T>): Promise<T> {
  return db.transaction(async (tx) => {
    const tenantId = tenantContext.getStore();
    console.log("executing query with tenant: " + tenantId);
    // if there's a tenant ID, set it in the transaction context
    if (tenantId) {
      await tx.execute(sql`set local nile.tenant_id = '${sql.raw(tenantId)}'`);
    }

    return cb(tx);
  }) as Promise<T>;
}

Drizzle 설정 파일 설정하기

Drizzle config - Drizzle Kit에서 사용하는 설정 파일로, 데이터베이스 연결, 마이그레이션 폴더, 스키마 파일에 대한 모든 정보를 포함합니다.

프로젝트 루트에 drizzle.config.ts 파일을 생성하고 다음 내용을 추가하세요:

drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.NILEDB_URL!,
  },
});

Nile 데이터베이스 인트로스펙트하기

Nile 데이터베이스에는 내장 테이블이 있습니다. 가장 중요한 테이블은 테넌트를 생성하고 관리하는 데 사용되는 tenants 테이블입니다. 애플리케이션에서 이 테이블을 사용하기 위해 Drizzle Kit CLI를 사용하여 이 스키마를 포함하는 스키마 파일을 생성하겠습니다.

npx drizzle-kit pull

인트로스펙션 결과로 schema.ts 파일, 데이터베이스 스키마 스냅샷이 포함된 meta 폴더, 마이그레이션 sql 파일, 관계형 쿼리를 위한 relations.ts 파일이 생성됩니다.

We recommend transferring the generated code from drizzle/schema.ts and drizzle/relations.ts to the actual schema file. In this guide we transferred code to src/db/schema.ts. Generated files for schema and relations can be deleted. This way you can manage your schema in a more structured way.

 ├ 📂 drizzle
 │ ├ 📂 20242409125510_premium_mister_fear
 │ │ ├ 📜 snapshot.json
 │ │ └ 📜 migration.sql
 │ ├ 📜 relations.ts ────────┐
 │ └ 📜 schema.ts ───────────┤
 ├ 📂 src                    │ 
 │ ├ 📂 db                   │
 │ │ ├ 📜 relations.ts <─────┤
 │ │ └ 📜 schema.ts <────────┘
 │ └ 📜 index.ts         
 └ …

다음은 생성된 schema.ts 파일의 예시입니다:

src/db/schema.ts
// table schema generated by introspection
import { pgTable, uuid, text, timestamp, varchar, vector, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"

export const tenants = pgTable("tenants", {
	id: uuid().default(sql`public.uuid_generate_v7()`).primaryKey().notNull(),
	name: text(),
	created: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	updated: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	deleted: timestamp({ mode: 'string' }),
});

추가 테이블 생성하기

내장 테이블 외에도 애플리케이션이 데이터를 저장하기 위한 테이블이 필요합니다. 이전에 생성한 src/db/schema.ts에 추가하면 파일은 다음과 같이 됩니다:

src/db/schema.ts
// table schema generated by introspection
import { pgTable, uuid, text, timestamp, varchar, vector, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"

export const tenants = pgTable("tenants", {
	id: uuid().default(sql`public.uuid_generate_v7()`).primaryKey().notNull(),
	name: text(),
	created: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	updated: timestamp({ mode: 'string' }).default(sql`LOCALTIMESTAMP`).notNull(),
	deleted: timestamp({ mode: 'string' }),
});

export const todos = pgTable("todos", {
	id: uuid().defaultRandom(),
	tenantId: uuid("tenant_id"),
	title: varchar({ length: 256 }),
	estimate: varchar({ length: 256 }),
	embedding: vector({ dimensions: 3 }),
	complete: boolean(),
});

데이터베이스에 변경사항 적용하기

You can directly apply changes to your database using the drizzle-kit push command. This is a convenient method for quickly testing new schema designs or modifications in a local development environment, allowing for rapid iterations without the need to manage migration files:

npx drizzle-kit push

Read more about the push command in documentation.

Tips

Alternatively, you can generate migrations using the drizzle-kit generate command and then apply them using the drizzle-kit migrate command:

Generate migrations:

npx drizzle-kit generate

Apply migrations:

npx drizzle-kit migrate

Read more about migration process in documentation.

웹 애플리케이션 초기화하기

이제 Drizzle을 Nile에 연결하도록 설정하고 스키마를 준비했으므로, 멀티 테넌트 웹 애플리케이션에서 사용할 수 있습니다. 이 예제에서는 Express를 웹 프레임워크로 사용하지만, Nile과 Drizzle은 모든 웹 프레임워크에서 사용할 수 있습니다.

예제를 간단하게 유지하기 위해 단일 파일 src/app.ts에 웹앱을 구현하겠습니다. 먼저 웹앱을 초기화합니다:

src/app.ts
import express from "express";
import { tenantDB, tenantContext, db } from "./db/db";
import {
  tenants as tenantSchema,
  todos as todoSchema,
} from "./db/schema";
import { eq } from "drizzle-orm";

const PORT = process.env.PORT || 3001;

const app = express();
app.listen(PORT, () => console.log(`Server is running on port ${PORT}`));
app.use(express.json());

테넌트 인식 미들웨어 초기화하기

다음으로 예제에 미들웨어를 추가하겠습니다. 이 미들웨어는 경로 매개변수에서 테넌트 ID를 가져와 AsyncLocalStorage에 저장합니다. src/db/index.ts에서 생성한 tenantDB 래퍼는 쿼리 실행 시 이 테넌트 ID를 사용하여 nile.tenant_id를 설정하며, 이를 통해 쿼리가 해당 테넌트의 가상 데이터베이스에 대해 실행되는 것을 보장합니다.

src/app.ts
// set the tenant ID in the context based on the URL parameter
app.use('/api/tenants/:tenantId/*', (req, res, next) => {
  const tenantId = req.params.tenantId;
  console.log("setting context to tenant: " + tenantId);
  tenantContext.run(tenantId, next);
});

이 예제는 경로 매개변수에서 테넌트 ID를 가져오지만, x-tenant-id와 같은 헤더나 쿠키에 테넌트 ID를 설정하는 것도 일반적입니다.

라우트 추가하기

마지막으로 테넌트와 할 일을 생성하고 조회하는 라우트를 추가해야 합니다. tenantDB 래퍼를 사용하여 테넌트의 가상 데이터베이스에 연결하는 방법을 주목하세요. 또한 app.get("/api/tenants/:tenantId/todos"에서 쿼리에 where tenant_id=...를 지정할 필요가 없다는 점도 주목하세요. 이는 정확히 해당 테넌트의 데이터베이스로 라우팅되기 때문이며, 쿼리는 다른 테넌트의 데이터를 반환할 수 없습니다.

src/app.ts
// create new tenant
app.post("/api/tenants", async (req, res) => {
  try {
    const name = req.body.name;
    var tenants: any = null;
    tenants = await tenantDB(async (tx) => {
        return await tx.insert(tenantSchema).values({ name }).returning();
    });
    res.json(tenants);
  } catch (error: any) {
    console.log("error creating tenant: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// return list of tenants
app.get("/api/tenants", async (req, res) => {
  let tenants: any = [];
  try {
      tenants = await tenantDB(async (tx) => {
        return await tx.select().from(tenantSchema);
      });
    res.json(tenants);
  } catch (error: any) {
    console.log("error listing tenants: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// add new task for tenant
app.post("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    const { title, complete } = req.body;
    if (!title) {
      res.status(400).json({message: "No task title provided",});
    }
    const tenantId = req.params.tenantId;

    const newTodo = await tenantDB(async (tx) => {
      return await tx
        .insert(todoSchema)
        .values({ tenantId, title, complete })
        .returning();
    });
    // return without the embedding vector, since it is huge and useless
    res.json(newTodo);
  } catch (error: any) {
    console.log("error adding task: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// update tasks for tenant
// No need for where clause because we have the tenant in the context
app.put("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    const { id, complete } = req.body;
    await tenantDB(async (tx) => {
      return await tx
        .update(todoSchema)
        .set({ complete })
        .where(eq(todoSchema.id, id));
    });
    res.sendStatus(200);
  } catch (error: any) {
    console.log("error updating tasks: " + error.message);
    res.status(500).json({message: "Internal Server Error",});
  }
});

// get all tasks for tenant
app.get("/api/tenants/:tenantId/todos", async (req, res) => {
  try {
    // No need for a "where" clause here because we are setting the tenant ID in the context
    const todos = await tenantDB(async (tx) => {
      return await tx
        .select({
          id: todoSchema.id,
          tenant_id: todoSchema.tenantId,
          title: todoSchema.title,
          estimate: todoSchema.estimate,
        })
        .from(todoSchema);
    });
    res.json(todos);
  } catch (error: any) {
    console.log("error listing tasks: " + error.message);
    res.status(500).json({message: error.message,});
  }
});

실행해보기!

이제 새로운 웹 애플리케이션을 실행할 수 있습니다:

npx tsx src/app.ts

그리고 curl을 사용하여 방금 생성한 라우트를 테스트할 수 있습니다:

# create a tenant
curl --location --request POST 'localhost:3001/api/tenants' \
--header 'Content-Type: application/json' \
--data-raw '{"name":"my first customer"}'

# get tenants
curl  -X GET 'http://localhost:3001/api/tenants'

# create a todo (don't forget to use a real tenant-id in the URL)
curl  -X POST \
  'http://localhost:3001/api/tenants/108124a5-2e34-418a-9735-b93082e9fbf2/todos' \
  --header 'Content-Type: application/json' \
  --data-raw '{"title": "feed the cat", "complete": false}'

# list todos for tenant (don't forget to use a real tenant-id in the URL)
curl  -X GET \
  'http://localhost:3001/api/tenants/108124a5-2e34-418a-9735-b93082e9fbf2/todos'

프로젝트 파일 구조

다음은 프로젝트의 파일 구조입니다. src/db 디렉토리에는 db.ts의 연결 설정과 schema.ts의 스키마 정의를 포함한 데이터베이스 관련 파일이 있습니다. 마이그레이션 및 인트로스펙션으로 생성된 파일은 ./drizzle에 있습니다

📦 <project root>
 ├ 📂 src
 │   ├ 📂 db
 │   │  ├ 📜 db.ts
 │   │  └ 📜 schema.ts
 │   └ 📜 app.ts
 ├ 📂 drizzle
 │   ├ 📂 meta
 │   │  ├ 📜 _journal.json
 │   │  └ 📜 0000_snapshot.json
 │   ├ 📜 relations.ts
 │   ├ 📜 schema.ts
 │   └ 📜 0000_watery_spencer_smythe.sql
 ├ 📜 .env
 ├ 📜 drizzle.config.ts
 └ 📜 package.json