Drizzle ORM 셋업하고 첫 테이블 만들기
Drizzle을 설치하고 subscribers 스키마를 정의해 Neon에 첫 테이블을 마이그레이션하는 과정
지난 글에서 Vercel에 Neon DB를 붙이고, 로컬에 환경 변수도 가져와 두었어요. Postgres가 깔린 자리는 만들어졌지만 아직 그 위엔 테이블이 한 개도 없는 상태입니다. 이번 글에선 ORM을 얹어 그 위에 첫 테이블을 만들어 봅니다. Drizzle을 설치하고, subscribers 테이블 스키마를 정의하고, Neon에 마이그레이션을 적용해서 실제로 테이블이 들어가는지까지 확인하는 과정이에요.
Step 1. Drizzle 설치
이제 Drizzle 관련 패키지 4개를 설치합니다. 두 개는 런타임용, 두 개는 개발용이에요.
npm i drizzle-orm @neondatabase/serverless
npm i -D drizzle-kit dotenv각 패키지가 무엇을 하는 건지 짧게 살펴볼게요.
drizzle-orm— 런타임에 쓰는 ORM 본체. 스키마 정의, 쿼리 빌더, 타입 추론이 다 여기 들어 있습니다.@neondatabase/serverless— Neon 공식 드라이버. HTTP 기반이라 edge runtime / serverless 함수에서 cold start가 가볍고, TCP 연결 풀링 이슈가 없어요. 일반 Postgres에 붙고 싶다면pg를 쓰면 됩니다.drizzle-kit— 마이그레이션 SQL을 자동으로 만들고 적용하는 CLI. 개발할 때만 쓰니 devDependencies에 둡니다.dotenv— Next.js는 알아서 환경변수를 로드해주지만, drizzle-kit은 외부 CLI라서.env.local을 직접 읽어줘야 해요. 그래서 필요합니다.
Step 2. drizzle.config.ts 작성
drizzle-kit이 어디에 있는 스키마를 봐야하고, 어디에 마이그레이션 SQL을 출력하고, 어느 DB에 연결할지를 알려주는 설정 파일이에요. 프로젝트 루트에 만듭니다.
// drizzle.config.ts
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
config({ path: ".env.local" });
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL_UNPOOLED!,
},
});여기서 한 가지 짚고 가야 할 부분이 있어요. dbCredentials.url에 DATABASE_URL_UNPOOLED을 쓴 부분이에요. 지난 글에서도 잠깐 언급했지만, 마이그레이션은 pooled 연결로 돌리면 안 됩니다. 풀러가 끼면 세션 단위 쿼리가 깨지고, DDL 일부는 트랜잭션 내에서 보장이 흔들려요. 직접 연결인 _UNPOOLED을 쓰는 게 안전합니다. 런타임 쿼리는 풀링이 더 효율적이라 거기서는 DATABASE_URL을 그대로 쓰고요.
Step 3. subscribers 스키마 작성
lib/db/schema.ts 파일에 테이블 구조를 정의합니다.
// lib/db/schema.ts
import { pgTable, serial, varchar, text, timestamp } from "drizzle-orm/pg-core";
export const subscribers = pgTable("subscribers", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 254 }).notNull().unique(),
status: varchar("status", { length: 20 }).notNull().default("pending"),
confirmationToken: text("confirmation_token"),
createdAt: timestamp("created_at").notNull().defaultNow(),
confirmedAt: timestamp("confirmed_at"),
});컬럼별 의도를 짧게 메모해 둘게요.
| 컬럼 | 타입 | 메모 |
|---|---|---|
id | serial PK | 내부 식별자. URL에는 노출하지 않음 |
email | varchar(254), UNIQUE | RFC 5321 최대 길이. 같은 이메일 중복 가입 방지 |
status | varchar(20), default 'pending' | 가입 직후엔 pending, 이메일 확인을 누르면 confirmed |
confirmation_token | text, nullable | 이중 옵트인 토큰 (다음 글에서 검증 로직 추가) |
created_at | timestamp, default now() | 가입 시각 |
confirmed_at | timestamp, nullable | 확인 완료 시각 |
지금 단계에서는 컬럼을 위와 같이 많지 않게 시작했어요. 1-click unsubscribe(정보통신망법 의무), bounce 처리, complaint 처리 같은 항목은 이후에 다룰 예정이라, 그때 컬럼을 추가할 예정입니다. Drizzle은 마이그레이션을 자동 diff로 생성해줘서, 컬럼 추가가 부담스럽지 않거든요.
Step 4. DB 클라이언트 (lib/db/index.ts)
런타임에서 import해서 쓸 db 객체입니다.
// lib/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });drizzle-orm/neon-http는 Drizzle이 Neon HTTP 드라이버에 맞춰 제공하는 어댑터예요. HTTP 기반이라 Node.js 런타임에서도, Edge 런타임에서도 똑같이 동작합니다. /api/subscribe 같은 라우트를 나중에 Edge로 옮기더라도 코드를 손볼 일이 없어요.
schema를 객체로 import해 넘겨두면 나중에 db.query.subscribers.findFirst({...}) 같은 메서드 호출도 쓸 수 있어요. 지금은 select / insert부터 쓸 거지만, 이후에 점점 활용도가 늘어날 거예요.
Step 5. 마이그레이션 생성 + 적용
먼저 package.json에 편의용 스크립트 세 개를 추가합니다.
"scripts": {
...
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}순서대로 실행해 봅니다.
npm run db:generate이 명령은 DB에 아직 아무것도 반영하지 않아요. 스키마를 읽어서 SQL 마이그레이션 파일만 lib/db/migrations/0000_xxx.sql 경로에 자동으로 생성해주는 단계예요. 파일을 한번 열어보면, schema.ts에 적었던 내용이 그대로 SQL로 옮겨져 있는 걸 확인할 수 있습니다.
CREATE TABLE "subscribers" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar(254) NOT NULL,
"status" varchar(20) DEFAULT 'pending' NOT NULL,
"confirmation_token" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"confirmed_at" timestamp,
CONSTRAINT "subscribers_email_unique" UNIQUE("email")
);이제 이 SQL을 Neon에 실제로 반영할 차례예요.
npm run db:migratemigrations applied successfully! 메시지가 보이면 성공입니다. Neon에 subscribers 테이블이 만들어졌다는 뜻이에요.
Step 6. 테이블이 잘 만들어졌는지 확인
처음 한 번은 직접 눈으로 확인해보는 게 마음이 편하더라고요. 간단한 확인용 스크립트를 만들어 실행해 봅니다.
// lib/db/test.ts (실행 후 삭제할 일회용 파일)
import { config } from "dotenv";
import { neon } from "@neondatabase/serverless";
config({ path: ".env.local" });
async function main() {
const sql = neon(process.env.DATABASE_URL!);
const tables = await sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name
`;
console.log("tables:", tables);
const rows = await sql`SELECT * FROM subscribers`;
console.log("subscribers:", rows);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});npx tsx lib/db/test.ts이렇게 출력되면 정상입니다.
tables: [ { table_name: 'subscribers' } ]
subscribers: []subscribers 테이블이 만들어졌고, 아직 row는 비어 있다는 뜻이에요. 딱 원하던 상태예요. 확인이 끝났으니 lib/db/test.ts는 지워도 됩니다.
마치며
여기까지 작업하니 구독자 정보를 저장할 테이블이 진짜로 만들어졌어요. 다만 테이블만 있을 뿐, 폼도 API 라우트도 아직 비어 있는 상태입니다. 다음 글에서는 그 테이블에 실제로 데이터가 들어가도록 만들어볼 거예요. 비워뒀던 구독 폼을 다시 살리고, /api/subscribe 라우트에서 이메일을 받아 토큰 발급과 저장까지 처리하는 흐름이에요.
여기까지 와야 사용자가 "구독합니다" 버튼을 눌렀을 때 시스템이 처음으로 반응하기 시작합니다. 그 작업은 다음 글에서 이어가볼게요.