읽는 데 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 | 앱에서 타입이 붙은 조회와 변경을 만든다 | 운영 의존성 |
postgres | PostgreSQL 연결과 실제 SQL 실행을 맡는다 | 운영 의존성 |
drizzle-kit | TypeScript 스키마 차이를 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 흐름이다.
생성됐다고 바로 적용하지 않는다. 다음을 먼저 본다.
- 컬럼 삭제나 이름 변경을 새 컬럼 추가로 오해하지 않았는가
NOT NULL을 넣기 전에 기존 행을 채워야 하지 않는가- index와 check 이름이 의도한 것인가
COMMENT ON이나 extension처럼 손으로 보탤 SQL 이 있는가- 옛 앱으로 되돌렸을 때도 읽을 수 있는 변화인가
특히 데이터 이동과 컬럼 삭제는 한 판에 하지 않는다. 새 코드가 양쪽 구조를 읽게 내보내고, 데이터를 옮긴 다음, 다음 판에서 옛 컬럼을 지운다. 배포를 되돌릴 수 있게 하려는 선택이다.
11. 운영에서는 자체 실행기로 적용한다
공식적인 다음 단계는 drizzle-kit migrate다. MyHome 은 SQL 생성에는 Drizzle Kit 을 쓰지만 운영 적용은 scripts/migrate.mjs가 맡는다.
이유는 운영 Docker 이미지에 개발 의존성인 drizzle-kit을 넣지 않았기 때문이다. 앱이 이미 쓰는 postgres 드라이버만으로 다음 일을 한다.
drizzle/meta/_journal.json을 읽는다- SQL 파일의 SHA-256을 계산한다
drizzle.__drizzle_migrations에서 적용된 hash를 읽는다- 새 파일을
statement-breakpoint로 나눈다 - 파일 하나를 트랜잭션 하나로 실행한다
- 성공하면 같은 형식으로 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와 join | Drizzle 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 을 붙인 순서는 이랬다.
drizzle-orm,postgres,drizzle-kit의 실행 역할을 나눴다.drizzle.config.ts에서 PostgreSQL, 스키마 경로, SQL 출력 경로를 정했다.- 개발 환경 파일을 먼저 읽어 운영 DB 오접속을 막았다.
schema.ts에 테이블·제약·인덱스를 적고$inferSelect로 타입을 얻었다.getDb()로 연결을 늦게 만들고globalThis에 보관해 개발 서버의 연결 증가를 막았다.- Server Component 에서는 바로 조회하고 Server Action 에서는 변경 뒤 화면을 갱신했다.
- PostgreSQL 기능이 더 분명한 검색과 정렬은
sql템플릿을 섞었다. db:generate로 만든 SQL 을 읽고, Docker 에서는 작은 자체 실행기로 적용했다.- 환경변수 순서, 빌드 시 DB 연결, 감싸진 오류처럼 프레임워크 밖의 문제도 따로 막았다.
그리고 배운 것 하나.
ORM을 도입한 값은 SQL을 안 쓰게 된 데 있지 않았다. DB 구조와 애플리케이션 타입이 어긋나면 사람이 발견하기 전에 코드가 먼저 멈추게 된 데 있었다.
댓글
첫 댓글을 남겨보세요.