이채강

읽는 데 11분조회 3

MyHome Drizzle ORM 도입기 — Next.js에서 PostgreSQL을 타입 안전하게 다루기

버전 1.0.0

작성 기준: 2026-09-30

MyHome 은 Next.js App Router 와 PostgreSQL 로 만들었다. 처음에는 postgres 드라이버로 SQL 을 직접 썼다가, 표 구조와 TypeScript 타입을 따로 관리하는 일이 반복돼 Drizzle ORM 으로 돌아왔다.

Drizzle 을 붙이는 일 자체는 어렵지 않았다. 어려웠던 것은 Next.js 개발 서버에서 연결을 재사용하는 법, 빌드할 때 DB 를 찾지 않게 하는 법, 개발 DB 와 운영 DB 를 확실히 나누는 법, Docker 이미지 안에서 마이그레이션을 적용하는 법이었다.

이 글은 새 예제를 따로 만들지 않는다. 지금 운영 중인 ~/workspace/myhome의 설정과 코드를 그대로 따라가며, Next.js 에서 Drizzle 을 어떻게 쓰고 있는지 정리한 기록이다.


1. 현재 조합

현재 MyHome 의 관련 버전은 이렇다.

$ npm list --depth=0 drizzle-orm drizzle-kit postgres next
[email protected] /home/leechis/workspace/myhome
├── [email protected]
├── [email protected]
├── [email protected]
└── [email protected]

각 도구의 역할은 나눠져 있다.

도구역할설치 위치
drizzle-orm앱에서 타입이 붙은 조회와 변경을 만든다운영 의존성
postgresPostgreSQL 연결과 실제 SQL 실행을 맡는다운영 의존성
drizzle-kitTypeScript 스키마 차이를 SQL 마이그레이션으로 만든다개발 의존성

Drizzle 은 DB 드라이버를 대신하지 않는다. MyHome 은 postgres가 연결을 만들고, Drizzle 이 그 연결 위에 타입과 쿼리 빌더를 얹는다.

새 프로젝트라면 설치 모양은 다음과 같다.

npm install drizzle-orm postgres
npm install -D drizzle-kit

앱이 실행될 때 필요한 것과 개발할 때만 필요한 것을 나누는 것이 첫 단계였다. 이 구분은 뒤에서 Docker 이미지와 마이그레이션 방식을 정할 때 그대로 이어졌다.

2. 파일은 세 군데에서 시작한다

MyHome 의 기본 구조는 단순하다.

drizzle.config.ts       drizzle-kit 설정
lib/db/schema.ts        테이블·제약·인덱스·타입
lib/db/index.ts         PostgreSQL 연결과 Drizzle 인스턴스
drizzle/                생성된 SQL 과 스키마 snapshot
scripts/migrate.mjs     운영 배포에서 SQL 을 적용하는 실행기

흐름으로 보면 이렇다.

schema.ts 변경
    │
    ▼
drizzle-kit generate
    │
    ├── drizzle/0032_....sql
    └── drizzle/meta/...snapshot.json
              │
              ▼
       scripts/migrate.mjs
              │
              ▼
          PostgreSQL

공식 문서도 TypeScript 스키마를 읽어 SQL 을 만들고, 적용 기록을 drizzle.__drizzle_migrations에 남기는 흐름을 설명한다. MyHome 은 SQL 생성은 그대로 쓰고, 적용 부분만 운영 이미지에 맞게 바꿨다.

3. drizzle.config.ts에서 운영과 개발을 나눈다

설정 파일은 다음과 같다.

import { defineConfig } from "drizzle-kit";

const envFiles =
  process.env.DRIZZLE_ENV === "dev"
    ? [".env.development.local", ".env.local"]
    : [".env.local"];

for (const file of envFiles) {
  try {
    process.loadEnvFile(file);
  } catch {
    // 파일이 없으면 이미 설정된 환경변수를 그대로 쓴다
  }
}

export default defineConfig({
  dialect: "postgresql",
  schema: "./lib/db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

여기서 가장 중요했던 것은 파일을 읽는 순서였다. Node 의 process.loadEnvFile()은 이미 들어 있는 환경변수를 덮어쓰지 않는다. .env.local을 먼저 읽고 .env.development.local을 나중에 읽으면 개발 명령인데도 운영 DATABASE_URL이 남는다.

실제로 db:seed:dev가 운영 DB 를 바라보는 상태가 한 번 있었다. 실행 전에 발견해서 데이터는 건드리지 않았지만, 이름에 :dev가 붙었다고 개발 DB 가 되는 것은 아니었다.

그래서 개발일 때는 개발 파일을 먼저 읽는다.

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio",
    "db:migrate": "node scripts/migrate.mjs",
    "db:migrate:dev": "DRIZZLE_ENV=dev node scripts/migrate.mjs"
  }
}

db:push도 남겨 두었지만 운영 변경에는 쓰지 않는다. 빠르게 스키마를 시험할 때는 편하지만, 무엇이 언제 바뀌었는지 SQL 파일로 남지 않는다. 운영은 generate로 SQL 을 만들고 검토한 다음 적용한다.

4. 스키마가 DB 구조와 TypeScript 타입의 원본이다

블로그 글 표의 일부를 줄이면 다음과 같다.

import { sql } from "drizzle-orm";
import {
  boolean,
  check,
  index,
  pgTable,
  serial,
  text,
  timestamp,
} from "drizzle-orm/pg-core";

export const posts = pgTable(
  "posts",
  {
    id: serial("id").primaryKey(),
    kind: text("kind").notNull().default("post"),
    title: text("title").notNull(),
    summary: text("summary"),
    content: text("content").notNull(),
    tags: text("tags").array().notNull().default([]),
    publishedAt: timestamp("published_at", { withTimezone: true }),
    published: boolean("published").notNull().default(false),
    private: boolean("private").notNull().default(false),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    check("posts_kind", sql`${t.kind} in ('post', 'note')`),
    index("posts_kind_published_idx").on(t.kind, t.publishedAt.desc()),
    index("posts_tags_idx").using("gin", t.tags),
  ],
);

export type Post = typeof posts.$inferSelect;

notNull, 기본값, check, index 를 TypeScript 파일 한 곳에서 본다. Post 타입은 따로 다시 적지 않고 $inferSelect로 얻는다. 컬럼을 바꾸면 그 컬럼을 쓰는 코드도 타입 검사에서 함께 드러난다.

다만 스키마 파일이 모든 PostgreSQL 기능을 대신하지는 않았다. MyHome 은 pg_trgm, COMMENT ON, 데이터 backfill처럼 SQL 로 표현하는 편이 분명한 부분을 생성된 마이그레이션에 직접 보탰다.

최근 편집기 설정을 추가한 SQL 도 그런 예다.

ALTER TABLE "site_settings"
  ADD COLUMN IF NOT EXISTS "editor" text;

ALTER TABLE "site_settings"
  ADD CONSTRAINT "site_settings_editor"
  CHECK (
    "editor" IS NULL
    OR "editor" IN ('milkdown', 'tiptap', 'toast')
  );

COMMENT ON COLUMN site_settings.editor
  IS '글 편집기: milkdown / tiptap / toast. 비우면 milkdown';

Drizzle 스키마는 SQL 을 없애는 자리가 아니라, SQL 과 애플리케이션 타입이 만나는 자리였다.

5. Next.js 개발 서버에서는 연결을 한 번만 만든다

연결 코드는 lib/db/index.ts에 있다.

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

type Db = ReturnType<typeof create>;

function create() {
  const url = process.env.DATABASE_URL;
  if (!url) {
    throw new Error("DATABASE_URL이 없습니다. .env.local을 확인하세요");
  }

  const client = postgres(url, {
    max: 5,
    onnotice: (notice) => console.log(`[NOTICE] ${notice.message}`),
  });

  return drizzle(client, { schema });
}

const globalForDb = globalThis as unknown as { db?: Db };

export function getDb(): Db {
  if (!globalForDb.db) {
    globalForDb.db = create();
  }
  return globalForDb.db;
}

export * from "./schema";

두 가지를 일부러 넣었다.

첫째, Drizzle 인스턴스를 globalThis에 둔다. Next.js 개발 서버는 파일이 바뀔 때 모듈을 다시 평가한다. 모듈 최상단에서 매번 postgres()를 호출하면 저장할 때마다 새 연결 풀이 생길 수 있다. 프로세스가 살아 있는 동안 하나를 재사용한다.

둘째, 접속을 getDb()를 처음 부를 때까지 미룬다. Docker 이미지를 빌드하는 시점에는 PostgreSQL 이 없을 수 있다. 모듈 import 만으로 접속하면 페이지 코드를 묶는 단계가 DB 때문에 실패한다.

DB 를 읽는 페이지는 정적 생성 대상에서도 뺐다.

// DB에서 읽으므로 빌드 시점에 미리 렌더하지 않는다.
export const dynamic = "force-dynamic";

이 조합으로 빌드는 DB 없이 통과하고, 요청이 들어왔을 때만 연결한다.

6. Server Component에서 바로 조회한다

App Router 의 Server Component 는 서버에서 실행되므로 별도 API 를 만들지 않고 조회할 수 있다. 소개 화면의 실제 코드는 다음 모양이다.

import { desc, eq } from "drizzle-orm";
import { getDb, profile, careers } from "@/lib/db";

export const dynamic = "force-dynamic";

export default async function AboutPage() {
  const db = getDb();

  const [profileRows, careerRows] = await Promise.all([
    db.select().from(profile).where(eq(profile.id, 1)).limit(1),
    db.select().from(careers).orderBy(desc(careers.startedOn)),
  ]);

  const me = profileRows.at(0);

  // JSX 렌더링...
}

브라우저로 보낼 필요가 없는 쿼리 코드와 DB 주소가 서버에 남는다. 두 조회가 서로 기다릴 이유도 없어 Promise.all()로 함께 실행한다.

목록에서는 필요한 컬럼만 고른다.

const listColumns = {
  id: posts.id,
  title: posts.title,
  summary: posts.summary,
  tags: posts.tags,
  publishedAt: posts.publishedAt,
};

const rows = await getDb()
  .select(listColumns)
  .from(posts)
  .where(eq(posts.published, true))
  .orderBy(desc(posts.publishedAt))
  .limit(10);

글 목록에 본문 전체를 가져오지 않으려는 선택이다. Drizzle 이 반환 타입도 선택한 컬럼에 맞춰 좁혀 준다.

7. Server Action에서 쓰고 화면을 갱신한다

관리 화면의 글 저장은 Server Action 이다. 입력을 정리하고 권한을 확인한 뒤 Drizzle 로 넣는다.

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getDb, posts } from "@/lib/db";
import { requireAdmin } from "@/lib/auth";

export async function createPost(formData: FormData) {
  await requireAdmin();

  const title = String(formData.get("title") ?? "").trim();
  const content = String(formData.get("content") ?? "").trim();
  if (!title || !content) redirect("/admin/posts/new?e=required");

  const created = await getDb()
    .insert(posts)
    .values({
      title,
      content,
      published: false,
      private: false,
      publishedAt: null,
    })
    .returning({ id: posts.id });

  revalidatePath("/blog");
  redirect(`/admin/posts/${created[0].id}?ok=1`);
}

수정과 삭제도 같은 모양이다.

await db
  .update(posts)
  .set({ title, content, updatedAt: new Date() })
  .where(eq(posts.id, id));

await db
  .delete(posts)
  .where(eq(posts.id, id))
  .returning({ id: posts.id });

여기서 Drizzle 이 권한 확인이나 입력 검증까지 해 주는 것은 아니다. requireAdmin()과 값 검사는 애플리케이션 책임이고, DB 의 not null, check, unique, foreign key 는 마지막 경계다.

8. 복잡한 곳에서는 SQL 을 섞는다

모든 것을 메서드만으로 쓰려고 하지 않았다. 글 검색은 PostgreSQL 의 ILIKE가 더 직접적이다.

const pattern = `%${escapeLike(q)}%`;

const match = or(
  sql`${posts.title} ilike ${pattern}`,
  sql`coalesce(${posts.summary}, '') ilike ${pattern}`,
  sql`${posts.content} ilike ${pattern}`,
);

개정일이 있으면 개정일, 없으면 발행일로 정렬하는 부분도 SQL 조각으로 둔다.

const sortedAt = sql`coalesce(${posts.revisedAt}, ${posts.publishedAt})`;

const rows = await db
  .select()
  .from(posts)
  .orderBy(desc(sortedAt));

한 번은 lte(sortedAt, date)처럼 원시 SQL 조각과 JavaScript Date를 바로 섞었다가 드라이버가 형을 정하지 못해 글 화면이 500이 됐다. 지금은 ISO 문자열로 넘기고 PostgreSQL 형을 붙인다.

const stamp = at.toISOString();
const condition = sql`${sortedAt} <= ${stamp}::timestamptz`;

ORM의 장점은 SQL 을 몰라도 된다는 데 있지 않았다. 평범한 조회는 타입이 붙은 빌더로 쓰고, DB 기능이 더 분명한 곳은 매개변수 바인딩을 유지한 채 SQL 로 내려갈 수 있다는 데 있었다.

9. 오류는 cause 안까지 내려가서 본다

PostgreSQL 의 unique 위반 코드는 23505, foreign key 위반 코드는 23503이다. 그런데 Drizzle 이 쿼리 오류를 한 겹 감싸면 코드는 바깥 오류가 아니라 cause 안에 있다.

function hasPgCode(err: unknown, code: string) {
  let current: unknown = err;

  for (let depth = 0; depth < 5 && current; depth += 1) {
    if (
      typeof current === "object" &&
      current !== null &&
      "code" in current &&
      (current as { code?: string }).code === code
    ) {
      return true;
    }
    current = (current as { cause?: unknown }).cause;
  }

  return false;
}

겉만 검사했을 때는 중복 입력을 사용자가 고칠 수 있는 오류로 돌려주지 못하고 500으로 끝났다. ORM을 쓰더라도 드라이버와 PostgreSQL 오류 구조를 알아야 하는 부분이었다.

10. 마이그레이션 SQL 은 만들고 읽는다

스키마를 바꾼 뒤에는 다음 명령으로 SQL 을 만든다.

npm run db:generate

Drizzle Kit 은 이전 snapshot 과 현재 schema.ts를 비교해 drizzle/ 아래에 SQL 과 새 snapshot 을 만든다. 공식 문서의 generate 설명과 같은 code-first 흐름이다.

생성됐다고 바로 적용하지 않는다. 다음을 먼저 본다.

  1. 컬럼 삭제나 이름 변경을 새 컬럼 추가로 오해하지 않았는가
  2. NOT NULL을 넣기 전에 기존 행을 채워야 하지 않는가
  3. index와 check 이름이 의도한 것인가
  4. COMMENT ON이나 extension처럼 손으로 보탤 SQL 이 있는가
  5. 옛 앱으로 되돌렸을 때도 읽을 수 있는 변화인가

특히 데이터 이동과 컬럼 삭제는 한 판에 하지 않는다. 새 코드가 양쪽 구조를 읽게 내보내고, 데이터를 옮긴 다음, 다음 판에서 옛 컬럼을 지운다. 배포를 되돌릴 수 있게 하려는 선택이다.

11. 운영에서는 자체 실행기로 적용한다

공식적인 다음 단계는 drizzle-kit migrate다. MyHome 은 SQL 생성에는 Drizzle Kit 을 쓰지만 운영 적용은 scripts/migrate.mjs가 맡는다.

이유는 운영 Docker 이미지에 개발 의존성인 drizzle-kit을 넣지 않았기 때문이다. 앱이 이미 쓰는 postgres 드라이버만으로 다음 일을 한다.

  1. drizzle/meta/_journal.json을 읽는다
  2. SQL 파일의 SHA-256을 계산한다
  3. drizzle.__drizzle_migrations에서 적용된 hash를 읽는다
  4. 새 파일을 statement-breakpoint로 나눈다
  5. 파일 하나를 트랜잭션 하나로 실행한다
  6. 성공하면 같은 형식으로 hash와 when을 기록한다

핵심은 Drizzle Kit 의 이력 형식을 그대로 쓴다는 점이다.

CREATE SCHEMA IF NOT EXISTS drizzle;

CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
  id serial PRIMARY KEY,
  hash text NOT NULL,
  created_at bigint
);

Docker Compose 에서는 DB, 마이그레이션, 앱 순서를 강제한다.

PostgreSQL healthy
        │
        ▼
migrate 컨테이너 1회 실행
        │ 성공
        ▼
Next.js 앱 시작

앱과 migrate 가 같은 이미지를 쓰므로 코드와 SQL 의 버전도 같이 움직인다. 마이그레이션이 실패하면 앱은 뜨지 않는다. 스키마가 덜 바뀐 채 새 코드만 뜨는 것보다 배포가 크게 멈추는 편이 안전했다.

12. 써 보며 정한 경계

MyHome 에서 Drizzle 을 쓰는 기준은 지금 이렇다.

상황선택
평범한 CRUD와 joinDrizzle query builder
반환 타입$inferSelect, 선택 컬럼 추론
ILIKE, coalesce, PostgreSQL 연산자sql 템플릿
개발 중 빠른 구조 시험필요할 때만 db:push
운영 구조 변경db:generate 후 SQL 검토
운영 적용Docker 안의 scripts/migrate.mjs
unique·foreign key 오류PostgreSQL 오류 코드를 cause까지 검사

처음에는 SQL 을 직접 쓰는 쪽이 파일도 적고 빠르게 느껴졌다. 표가 늘고 변경이 쌓이자 결과 타입, 스키마 이력, 개발과 운영의 차이를 사람이 계속 맞추는 비용이 더 컸다.

반대로 Drizzle 을 붙인 뒤에도 SQL 을 버리지는 않았다. 생성된 SQL 을 읽고, 필요한 SQL 을 보태고, 복잡한 조건은 sql 템플릿으로 적었다. 도구가 DB 를 가리는 방식보다 DB 를 드러낸 채 반복 작업만 줄이는 방식이 MyHome 에 맞았다.

정리

Next.js 에 Drizzle 을 붙인 순서는 이랬다.

  1. drizzle-orm, postgres, drizzle-kit의 실행 역할을 나눴다.
  2. drizzle.config.ts에서 PostgreSQL, 스키마 경로, SQL 출력 경로를 정했다.
  3. 개발 환경 파일을 먼저 읽어 운영 DB 오접속을 막았다.
  4. schema.ts에 테이블·제약·인덱스를 적고 $inferSelect로 타입을 얻었다.
  5. getDb()로 연결을 늦게 만들고 globalThis에 보관해 개발 서버의 연결 증가를 막았다.
  6. Server Component 에서는 바로 조회하고 Server Action 에서는 변경 뒤 화면을 갱신했다.
  7. PostgreSQL 기능이 더 분명한 검색과 정렬은 sql 템플릿을 섞었다.
  8. db:generate로 만든 SQL 을 읽고, Docker 에서는 작은 자체 실행기로 적용했다.
  9. 환경변수 순서, 빌드 시 DB 연결, 감싸진 오류처럼 프레임워크 밖의 문제도 따로 막았다.

그리고 배운 것 하나.

ORM을 도입한 값은 SQL을 안 쓰게 된 데 있지 않았다. DB 구조와 애플리케이션 타입이 어긋나면 사람이 발견하기 전에 코드가 먼저 멈추게 된 데 있었다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로