Series · Building My Own Newsletter · 04

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.urlDATABASE_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"),
});

컬럼별 의도를 짧게 메모해 둘게요.

컬럼타입메모
idserial PK내부 식별자. URL에는 노출하지 않음
emailvarchar(254), UNIQUERFC 5321 최대 길이. 같은 이메일 중복 가입 방지
statusvarchar(20), default 'pending'가입 직후엔 pending, 이메일 확인을 누르면 confirmed
confirmation_tokentext, nullable이중 옵트인 토큰 (다음 글에서 검증 로직 추가)
created_attimestamp, default now()가입 시각
confirmed_attimestamp, 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:migrate

migrations 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 라우트에서 이메일을 받아 토큰 발급과 저장까지 처리하는 흐름이에요.

여기까지 와야 사용자가 "구독합니다" 버튼을 눌렀을 때 시스템이 처음으로 반응하기 시작합니다. 그 작업은 다음 글에서 이어가볼게요.