Clerk에서 Better Auth로 마이그레이션

이 가이드에서는 Clerk에서 Better Auth로 프로젝트를 마이그레이션하는 단계를 안내합니다. 적절한 해싱을 사용하는 이메일/비밀번호, 소셜/외부 계정, 전화번호, 2단계 인증 데이터 등이 포함됩니다.

이 마이그레이션은 모든 활성 세션을 무효화합니다. 이 가이드는 현재 조직(Organization)을 마이그레이션하는 방법을 보여주지 않지만, 조직 플러그인을 사용하여 추가 단계로 가능합니다.

시작하기 전에

마이그레이션 프로세스를 시작하기 전에 프로젝트에서 Better Auth를 설정하세요. 설치 가이드를 따라 시작하세요.

데이터베이스 연결

사용자와 계정을 마이그레이션하려면 데이터베이스에 연결해야 합니다. 원하는 데이터베이스를 사용할 수 있지만, 이 예제에서는 PostgreSQL을 사용합니다.

npm install pg

다음 코드를 사용하여 데이터베이스에 연결할 수 있습니다.

auth.ts
import { Pool } from "pg";

export const auth = betterAuth({
    database: new Pool({
        connectionString: process.env.DATABASE_URL
    }),
})

이메일 및 비밀번호 활성화 (선택 사항)

인증 설정에서 이메일 및 비밀번호를 활성화하고 인증 이메일 전송, 비밀번호 재설정 이메일 등을 위한 자체 로직을 구현하세요.

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
    database: new Pool({
        connectionString: process.env.DATABASE_URL
    }),
    emailAndPassword: { 
        enabled: true, 
    }, 
    emailVerification: {
      sendVerificationEmail: async({ user, url })=>{
        // 이메일 인증을 보내는 로직을 여기에 구현하세요
      }
	},
})

더 많은 설정 옵션은 이메일 및 비밀번호를 참조하세요.

소셜 프로바이더 설정 (선택 사항)

Clerk 프로젝트에서 활성화한 소셜 프로바이더를 인증 설정에 추가하세요.

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
    database: new Pool({
        connectionString: process.env.DATABASE_URL
    }),
    emailAndPassword: {
        enabled: true,
    },
    socialProviders: { 
        github: { 
            clientId: process.env.GITHUB_CLIENT_ID, 
            clientSecret: process.env.GITHUB_CLIENT_SECRET, 
        } 
    } 
})

플러그인 추가 (선택 사항)

필요에 따라 인증 설정에 다음 플러그인을 추가할 수 있습니다.

Admin 플러그인을 사용하면 사용자 관리, 사용자 가장(impersonation), 앱 수준 역할 및 권한을 관리할 수 있습니다.

Two Factor 플러그인을 사용하면 애플리케이션에 2단계 인증을 추가할 수 있습니다.

Phone Number 플러그인을 사용하면 애플리케이션에 전화번호 인증을 추가할 수 있습니다.

Username 플러그인을 사용하면 애플리케이션에 사용자 이름 인증을 추가할 수 있습니다.

auth.ts
import { Pool } from "pg";
import { betterAuth } from "better-auth";
import { admin, twoFactor, phoneNumber, username } from "better-auth/plugins";

export const auth = betterAuth({
    database: new Pool({
        connectionString: process.env.DATABASE_URL
    }),
    emailAndPassword: {
        enabled: true,
    },
    socialProviders: {
        github: {
            clientId: process.env.GITHUB_CLIENT_ID!,
            clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }
    },
    plugins: [admin(), twoFactor(), phoneNumber(), username()], 
})

스키마 생성

사용자 정의 데이터베이스 어댑터를 사용하는 경우 스키마를 생성하세요:

npx @better-auth/cli generate

기본 어댑터를 사용하는 경우 다음 명령을 사용할 수 있습니다:

npx @better-auth/cli migrate

Clerk 사용자 내보내기

Clerk 대시보드로 이동하여 사용자를 내보내세요. 여기에서 방법을 확인하세요. CSV 파일이 다운로드됩니다. exported_users.csv로 저장하고 프로젝트의 루트에 넣으세요.

마이그레이션 스크립트 생성

scripts 폴더에 migrate-clerk.ts라는 새 파일을 만들고 다음 코드를 추가하세요:

scripts/migrate-clerk.ts
import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";

import { auth } from "@/lib/auth"; // auth 인스턴스를 가져오세요

function getCSVData(csv: string) {
  const lines = csv.split('\n').filter(line => line.trim());
  const headers = lines[0]?.split(',').map(header => header.trim()) || [];
  const jsonData = lines.slice(1).map(line => {
      const values = line.split(',').map(value => value.trim());
      return headers.reduce((obj, header, index) => {
          obj[header] = values[index] || '';
          return obj;
      }, {} as Record<string, string>);
  });

  return jsonData as Array<{
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      primary_email_address: string;
      primary_phone_number: string;
      verified_email_addresses: string;
      unverified_email_addresses: string;
      verified_phone_numbers: string;
      unverified_phone_numbers: string;
      totp_secret: string;
      password_digest: string;
      password_hasher: string;
  }>;
}

const exportedUserCSV = await Bun.file("exported_users.csv").text(); // Clerk에서 다운로드한 파일입니다

async function getClerkUsers(totalUsers: number) {
  const clerkUsers: {
      id: string;
      first_name: string;
      last_name: string;
      username: string;
      image_url: string;
      password_enabled: boolean;
      two_factor_enabled: boolean;
      totp_enabled: boolean;
      backup_code_enabled: boolean;
      banned: boolean;
      locked: boolean;
      lockout_expires_in_seconds: number;
      created_at: number;
      updated_at: number;
      external_accounts: {
          id: string;
          provider: string;
          identification_id: string;
          provider_user_id: string;
          approved_scopes: string;
          email_address: string;
          first_name: string;
          last_name: string;
          image_url: string;
          created_at: number;
          updated_at: number;
      }[]
  }[] = [];
  for (let i = 0; i < totalUsers; i += 500) {
      const response = await fetch(`https://api.clerk.com/v1/users?offset=${i}&limit=${500}`, {
          headers: {
              'Authorization': `Bearer ${process.env.CLERK_SECRET_KEY}`
          }
      });
      if (!response.ok) {
          throw new Error(`Failed to fetch users: ${response.statusText}`);
      }
      const clerkUsersData = await response.json();
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      clerkUsers.push(...clerkUsersData as any);
  }
  return clerkUsers;
}


export async function generateBackupCodes(
  secret: string,
) {
  const key = secret;
  const backupCodes = Array.from({ length: 10 })
      .fill(null)
      .map(() => generateRandomString(10, "a-z", "0-9", "A-Z"))
      .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
  const encCodes = await symmetricEncrypt({
      data: JSON.stringify(backupCodes),
      key: key,
  });
  return encCodes
}

// 타임스탬프를 Date로 안전하게 변환하는 헬퍼 함수
function safeDateConversion(timestamp?: number): Date {
  if (!timestamp) return new Date();

  // 초를 밀리초로 변환
  const date = new Date(timestamp * 1000);

  // 날짜가 유효한지 확인
  if (isNaN(date.getTime())) {
      console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`);
      return new Date();
  }

  // 비합리적인 날짜 확인 (2000년 이전 또는 2100년 이후)
  const year = date.getFullYear();
  if (year < 2000 || year > 2100) {
      console.warn(`Suspicious date year: ${year}, falling back to current date`);
      return new Date();
  }

  return date;
}

async function migrateFromClerk() {
  const jsonData = getCSVData(exportedUserCSV);
  const clerkUsers = await getClerkUsers(jsonData.length);
  const ctx = await auth.$context
  const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin");
  const isTwoFactorEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "two-factor");
  const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username");
  const isPhoneNumberEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "phone-number");
  for (const user of jsonData) {
      const { id, first_name, last_name, username, primary_email_address, primary_phone_number, verified_email_addresses, unverified_email_addresses, verified_phone_numbers, unverified_phone_numbers, totp_secret, password_digest, password_hasher } = user;
      const clerkUser = clerkUsers.find(clerkUser => clerkUser?.id === id);

      // 사용자 생성
      const createdUser = await ctx.adapter.create<{
          id: string;
      }>({
          model: "user",
          data: {
              id,
              email: primary_email_address,
              emailVerified: verified_email_addresses.length > 0,
              name: `${first_name} ${last_name}`,
              image: clerkUser?.image_url,
              createdAt: safeDateConversion(clerkUser?.created_at),
              updatedAt: safeDateConversion(clerkUser?.updated_at),
              // # 2단계 인증 (2단계 인증 플러그인을 활성화한 경우)
              ...(isTwoFactorEnabled ? {
                  twoFactorEnabled: clerkUser?.two_factor_enabled
              } : {}),
              // # 관리자 (관리자 플러그인을 활성화한 경우)
              ...(isAdminEnabled ? {
                  banned: clerkUser?.banned,
                  banExpiresAt: clerkUser?.lockout_expires_in_seconds,
                  role: "user"
              } : {}),
              // # 사용자 이름 (사용자 이름 플러그인을 활성화한 경우)
              ...(isUsernameEnabled ? {
                  username: username,
              } : {}),
              // # 전화번호 (전화번호 플러그인을 활성화한 경우)
              ...(isPhoneNumberEnabled ? {
                  phoneNumber: primary_phone_number,
                  phoneNumberVerified: verified_phone_numbers.length > 0,
              } : {}),
          },
          forceAllowId: true
      }).catch(async e => {
          return await ctx.adapter.findOne<{
              id: string;
          }>({
              model: "user",
              where: [{
                  field: "id",
                  value: id
              }]
          })
      })
      // 외부 계정 생성
      const externalAccounts = clerkUser?.external_accounts;
      if (externalAccounts) {
          for (const externalAccount of externalAccounts) {
              const { id, provider, identification_id, provider_user_id, approved_scopes, email_address, first_name, last_name, image_url, created_at, updated_at } = externalAccount;
              if (externalAccount.provider === "credential") {
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId: provider,
                          accountId: externalAccount.provider_user_id,
                          scope: approved_scopes,
                          userId: createdUser?.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                          password: password_digest,
                      }
                  })
              } else {
                  await ctx.adapter.create({
                      model: "account",
                      data: {
                          id,
                          providerId: provider.replace("oauth_", ""),
                          accountId: externalAccount.provider_user_id,
                          scope: approved_scopes,
                          userId: createdUser?.id,
                          createdAt: safeDateConversion(created_at),
                          updatedAt: safeDateConversion(updated_at),
                      },
                      forceAllowId: true
                  })
              }
          }
      }

      // 2단계 인증
      if (isTwoFactorEnabled) {
          await ctx.adapter.create({
              model: "twoFactor",
              data: {
                  userId: createdUser?.id,
                  secret: totp_secret,
                  backupCodes: await generateBackupCodes(totp_secret)
              }
          })
      }
  }
}

migrateFromClerk()
  .then(() => {
      console.log('Migration completed');
      process.exit(0);
  })
  .catch((error) => {
      console.error('Migration failed:', error);
      process.exit(1);
  });

process.env.CLERK_SECRET_KEY를 자신의 Clerk 비밀 키로 교체해야 합니다. 필요에 따라 스크립트를 자유롭게 사용자 정의하세요.

마이그레이션 실행

마이그레이션을 실행하세요:

bun run script/migrate-clerk.ts # 스크립트를 실행하기 위해 원하는 것을 사용할 수 있습니다

다음을 확인하세요:

  1. 먼저 개발 환경에서 마이그레이션을 테스트하세요
  2. 오류가 있는지 마이그레이션 프로세스를 모니터링하세요
  3. 계속 진행하기 전에 Better Auth에서 마이그레이션된 데이터를 확인하세요
  4. 마이그레이션이 완료될 때까지 Clerk를 설치하고 구성된 상태로 유지하세요

마이그레이션 확인

마이그레이션 실행 후 데이터베이스를 확인하여 모든 사용자가 올바르게 마이그레이션되었는지 확인하세요.

컴포넌트 업데이트

이제 데이터가 마이그레이션되었으므로 Better Auth를 사용하도록 컴포넌트 업데이트를 시작할 수 있습니다. 다음은 로그인 컴포넌트의 예입니다:

components/auth/sign-in.tsx
import { authClient } from "better-auth/client";

export const SignIn = () => {
  const handleSignIn = async () => {
    const { data, error } = await authClient.signIn.email({
      email: "user@example.com",
      password: "password",
    });

    if (error) {
      console.error(error);
      return;
    }
    // 성공적인 로그인 처리
  };

  return (
    <form onSubmit={handleSignIn}>
      <button type="submit">로그인</button>
    </form>
  );
};

미들웨어 업데이트

Clerk 미들웨어를 Better Auth의 미들웨어로 교체하세요:

middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;
  if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard", "/login", "/signup"],
};

Clerk 종속성 제거

Better Auth에서 모든 것이 올바르게 작동하는지 확인한 후 Clerk를 제거할 수 있습니다:

Clerk 제거
pnpm remove @clerk/nextjs @clerk/themes @clerk/types

추가 리소스

Goodbye Clerk, Hello Better Auth – Full Migration Guide!

마무리

축하합니다! Clerk에서 Better Auth로 성공적으로 마이그레이션했습니다.

Better Auth는 더 큰 유연성과 더 많은 기능을 제공합니다. 문서를 탐색하여 전체 잠재력을 활용하세요.