Drizzle ORM을 프로젝트에 적용하였는데,
drizzle orm와 next-auth에 공식문서들이 뿔뿔히 흩어져서 모아서 정리해두는 글.
Drizzle ORM
Drizzle은 SQL-like 문법을 지원하는 ORM이다.
한편 아키텍처 구조도 직관적으로 입력된 SQL-like를 각 DB에 맞는 SQL 문법으로 파싱하여 전송한다. 단순한 아키텍처 구조 덕분에 Prisma보다 상대적으로 안정적인 성능을 보여준다.
Drizzle Next.js 연동
https://vercel.com/templates/next.js/postgres-drizzle
npm i drizzle-orm postgres
npm i -D drizzle-kit
-
설정 : drizzle.config.ts
import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: ["./src/lib/db/schema.ts", "./src/features/**/db/schema.ts"], // 경로 추가 out: "./drizzle/migrations", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, }, });-
이때 DATABASE_URL은 PostgreSQL DB 주소를 의미한다 Supabase의 SQL 서버 주소를 직업 입력하여도 된다.(
프로젝트 주소?showConnect=true) -
glob patterns을 이용해 원하는 주소를 지정해줄 수 있다. feature 별로 주소를 지정하기 위해 위와 같이 경로를 추가하였다.
-
-
연결 : src/lib/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL environment variable is required"); } const client = postgres(process.env.DATABASE_URL!, { ssl: "require" }); export const db = drizzle(client);- drizzle객체 안에 DB를 넣으면 연결이 진행된다.
-
스키마 생성 : src/features/profile/db/schema.ts
// schema.ts import { createInsertSchema } from 'drizzle-zod'; export const ProfilesTable = pgTable( "profiles", { id: uuid("id") .default(sql`gen_random_uuid()`) .primaryKey(), firstName: text("firstName").notNull(), lastName: text("lastName").notNull(), email: text("email").notNull(), }, (profiles) => [uniqueIndex("unique_profile_email_idx").on(profiles.email)], ); export type DbProfile = InferSelectModel<typeof ProfilesTable>; export type NewProfile = InferInsertModel<typeof ProfilesTable>; export const userInsertSchema = createInsertSchema(users);-
Infer{DML}Model을 통해 타입을 추론할 수 있다.
-
drizzle-zod 라이브러리를 통해 zod 스키마로 반환할 수 있다. https://orm.drizzle.team/docs/zod
-
-
쿼리
"use server" import { unstable_cache } from "next/cache"; export const selectById = unstable_cache(async ( id: string, ): Promise<DbProfile | null> => { const [profile] = await db .select() .from(ProfilesTable) .where(eq(ProfilesTable.id, id)) .limit(1); return profile ?? null; };, ["unique_key"], {revalidate : 3600, tags: ["user_profile"]});-
SQL-like 문법을 이용해서 쿼리를 작성할 수 있다.
-
서버에서 실행할 때에는 unstable_cache 등으로 캐싱할 수 있다.
-
Drizzle - next-auth 연동
https://authjs.dev/getting-started/adapters/drizzle#configuration
npm install @auth/drizzle-adapter next-auth@beta @auth/core
npx auth secret
설정
// src/features/auth/lib/auth.config.ts
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { type AuthConfig } from "@auth/core";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/features/auth/db/schema";
import { db } from "@/lib/db";
export const authConfig = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
}),
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
const email = credentials?.email as string | undefined;
const password = credentials?.password as string | undefined;
if (!email || !password) return null;
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || !user.hashedPassword) return null;
const ok = await bcrypt.compare(password, user.hashedPassword);
if (!ok) return null;
return {
id: user.id,
name: user.name ?? null,
email: user.email,
image: user.image ?? null,
};
} catch (err) {
console.error("Credentials authorize error", err);
return null;
}
},
}),
],
callbacks: {
async session({ session, token, user }) {
const resolvedId = user?.id ?? (token?.id as string | undefined);
if (session.user && resolvedId) {
session.user.id = resolvedId;
}
return session;
},
},
session: {
strategy: "database",
},
debug: process.env.NODE_ENV === "development",
} satisfies AuthConfig;
adapter: OAuth와 연동하기 위해서는 accounts,
sessions,
users,
verificationTokens 네 개의 테이블이 필수로 필요하다.
https://authjs.dev/concepts/database-models#verificationtokensession.strategy: 세션을 관리하기 위한 전략.
https://authjs.dev/guides/refresh-token-rotation#database-strategyproviders[Credentials]: 아이디와 비밀번호를 이용한 로그인이다. 이때 비밀번호는 bycript 등으로 해시화를 해야한다. (next-auth는 별도로 비밀번호를 암호화하지 않는다.)
실행
// auth.ts
import NextAuth from "next-auth";
import { authConfig } from "@/features/auth/lib/auth.config";
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
import Link from "next/link";
import { auth } from "@/features/auth/lib/auth";
export async function AuthControls() {
const session = await auth(); //서버사이드 렌더링
if (session?.user) {
return <LogoutButton />
}
return (
<div className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href="/auth/signin">로그인</Link>
</Button>
<Button asChild>
<Link href="/auth/signup">회원가입</Link>
</Button>
</div>
);
}
auth.ts를 이용하여 서버 측에서 렌더링이 가능하다.
만일 클라이언트에서 세션이 필요한 경우에는next-auth/react를 활용한다. hhttps://authjs.dev/getting-started/session-management/get-session?framework=Next.js%2520%28Client%29
한편 auth를 이용해 상태를 가져올 때에는 아래와 같이 suspense로 감싸 별도의 렌더링 단위로 분리시킬 수 있다.
import { Suspense } from "react";
import { HeaderContainer, HeaderNav, Logo } from "@/components/ui/site-header";
import { Skeleton } from "@/components/ui/skeleton";
import { AuthControls } from "@/features/auth/components/auth-controls";
export function MainHeader() {
return (
<HeaderContainer>
<div className="flex items-center gap-6">
<Logo />
<HeaderNav />
</div>
<Suspense fallback={<Skeleton className="h-9 w-28" />}>
<AuthControls />
</Suspense>
</HeaderContainer>
);
}
스키마
export const users = pgTable("user", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").unique().notNull(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
hashedPassword: text("hashedPassword"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccountType>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
{
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
},
],
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(verificationToken) => [
{
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
},
],
);
export const authenticators = pgTable(
"authenticator",
{
credentialID: text("credentialID").notNull().unique(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerAccountId: text("providerAccountId").notNull(),
credentialPublicKey: text("credentialPublicKey").notNull(),
counter: integer("counter").notNull(),
credentialDeviceType: text("credentialDeviceType").notNull(),
credentialBackedUp: boolean("credentialBackedUp").notNull(),
transports: text("transports"),
},
(authenticator) => [
{
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
},
],
);
next-auth는 스네이크 케이스와 카멜 케이스가 혼용된다.
authenticators는 pass-key를 사용하기 위함인데, next-auth v5부터 지원한다. https://authjs.dev/getting-started/authentication/webauthn