THE DEVLOG

scribbly.

Next JS 치트 시트

2025.09.19 20:03:49

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;

  1. adapter: OAuth와 연동하기 위해서는 accounts,
    sessions,
    users,
    verificationTokens 네 개의 테이블이 필수로 필요하다.
    https://authjs.dev/concepts/database-models#verificationtoken
  2. session.strategy: 세션을 관리하기 위한 전략.
    https://authjs.dev/guides/refresh-token-rotation#database-strategy
  3. providers[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