Clerk로 Next.js 인증 붙이기: 로그인 컴포넌트, 보호 라우트, Webhook, 조직 관리

이 글의 핵심

인증을 직접 만들면 세션 관리와 OAuth, MFA까지 챙길 것이 많아 Clerk 같은 관리형 서비스를 고려하게 됩니다. 다만 사용자 데이터를 내 DB와 맞추려면 Webhook 동기화가 필요하고, 조직 단위 권한은 별도로 설계해야 합니다. Auth0와 비교한 선택 기준과 구현 체크리스트도 함께 담았습니다.

이 글의 핵심

Next.js 앱에 Clerk로 인증을 붙이는 과정을 정리한 글입니다. 이메일/비밀번호, OAuth, MFA, 사용자 관리, Next.js 통합까지 예제로 다룹니다. 본문 코드는 authMiddleware와 동기 auth()를 쓰는 @clerk/nextjs v4 API 기준입니다. v5부터는 clerkMiddleware와 createRouteMatcher로 바뀌었고, v6에서는 auth()가 await해야 하는 비동기 함수가 되었으므로 설치한 버전의 마이그레이션 문서를 함께 확인해야 합니다.

실무에서 마주치는 문제들

보안이 걱정돼요

인증을 직접 구현하면 비밀번호 해싱, 세션 토큰 회전, CSRF, 계정 열거(account enumeration) 방지, 브루트포스 차단까지 전부 스스로 챙겨야 합니다. 하나라도 빠지면 곧바로 사고로 이어지는 영역이라, 이 부분을 검증된 서비스에 맡기는 것이 Clerk를 쓰는 가장 큰 이유입니다.

OAuth 연동이 어려워요

Google, GitHub, Apple은 각각 콜백 URL 등록 방식, 스코프 이름, 이메일 제공 여부가 다릅니다. Apple은 최초 로그인 때만 이름을 돌려주는 식의 예외도 있습니다. Clerk는 대시보드에서 공급자를 켜고 자격 증명을 넣으면 같은 user 객체 형태로 정규화해 줍니다.

사용자 관리가 번거로워요

비밀번호 재설정, 계정 잠금 해제, 세션 강제 종료 같은 운영 작업을 하려면 결국 관리자 화면이 필요합니다. Clerk 대시보드가 이 역할을 대신하므로 초기에 Admin 패널을 따로 만들지 않아도 됩니다.

대가도 있습니다. 사용자 레코드의 원본이 Clerk 쪽에 있기 때문에 내 DB와 동기화하는 경로(6장 Webhook)를 반드시 설계해야 하고, MAU가 늘면 비용이 사용자 수에 비례해 늘어납니다. 나중에 다른 인증 방식으로 옮기려면 비밀번호 해시 내보내기 가능 여부 같은 이전 경로도 미리 확인해 두는 편이 안전합니다.


Clerk란?

핵심 특징

Clerk는 인증과 사용자 관리를 호스팅 형태로 제공하는 플랫폼입니다. Auth0나 Firebase Auth와 같은 범주지만, React용 UI 컴포넌트(<SignIn />, <UserButton />)를 기본으로 제공하고 Next.js 미들웨어와 서버 컴포넌트에 맞춘 SDK를 갖춘 점이 특징입니다. 세션은 짧은 수명의 JWT와 Clerk 도메인에 저장되는 장기 세션 쿠키 조합으로 관리되며, SDK가 만료 직전에 토큰을 자동으로 갱신합니다. 주요 기능:

  • 다양한 인증: 이메일, OAuth, Magic Link
  • MFA: 2단계 인증
  • 사용자 관리: Dashboard
  • 조직 관리: Multi-tenancy
  • 세션 관리: 자동

Next.js 설정

설치

npm install @clerk/nextjs

환경 변수

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

pk_로 시작하는 퍼블리셔블 키는 브라우저 번들에 들어가도 되는 값이고, sk_ 시크릿 키는 서버에서만 읽혀야 합니다. Next.js는 NEXT_PUBLIC_ 접두사가 붙은 변수만 클라이언트 번들에 인라인하므로, 시크릿 키에 실수로 이 접두사를 붙이지 않도록 주의합니다. pk_test_/sk_test_는 개발 인스턴스용이고 프로덕션 인스턴스는 pk_live_/sk_live_ 키와 별도 도메인 설정이 필요합니다. 배포 환경에 test 키를 그대로 두면 로그인 화면에 개발 모드 배너가 뜨고 개발 인스턴스의 사용 제한을 받습니다.

Middleware

미들웨어는 모든 요청 앞에서 세션 쿠키를 확인해 로그인하지 않은 사용자를 로그인 페이지로 보내는 관문입니다. publicRoutes에 없는 경로는 기본적으로 보호 대상이 되므로, 새 페이지를 추가할 때 “허용 목록에 넣지 않으면 막힌다”는 방향으로 동작한다는 점을 기억해야 합니다. 특히 Webhook 경로(/api/webhook)를 공개 경로에 넣지 않으면 Clerk 서버가 보낸 요청이 로그인 페이지로 리다이렉트되어 웹훅이 계속 실패합니다.

// middleware.ts
import { authMiddleware } from '@clerk/nextjs';
export default authMiddleware({
  publicRoutes: ['/', '/api/webhook'],
});
export const config = {
  matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
};

matcher의 첫 번째 패턴은 확장자가 있는 정적 파일(.png, .css 등)과 _next 내부 경로를 제외하고, 나머지 두 패턴은 루트와 API·tRPC 경로를 명시적으로 포함합니다. 정적 파일까지 미들웨어를 거치게 하면 이미지 하나마다 세션 확인이 일어나 응답이 느려집니다. v5의 clerkMiddleware는 반대로 기본값이 “모두 공개”이고 createRouteMatcher로 고른 경로에서만 auth().protect()를 호출하는 방식이라, 버전을 올릴 때 보호 범위가 뒤집히지 않았는지 반드시 점검해야 합니다.

Provider

ClerkProvider는 세션 상태와 UI 컴포넌트가 쓸 컨텍스트를 제공합니다. 루트 레이아웃에 한 번만 두면 되고, 여기서 appearance prop으로 전체 컴포넌트 테마를 지정하거나 localization으로 한국어 문구를 적용할 수 있습니다.

// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="ko">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

인증 컴포넌트

Sign In / Sign Up

// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
  return (
    <div className="flex justify-center items-center min-h-screen">
      <SignIn />
    </div>
  );
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
  return (
    <div className="flex justify-center items-center min-h-screen">
      <SignUp />
    </div>
  );
}

폴더 이름의 [[...sign-in]]은 선택적 catch-all 세그먼트입니다. <SignIn />은 이메일 인증 코드 입력, MFA, 비밀번호 재설정 같은 하위 단계를 /sign-in/factor-one처럼 하위 경로로 이동하며 처리하므로, 일반 page.tsx로 만들면 두 번째 단계에서 404가 납니다. 로그인 페이지 경로를 바꿨다면 NEXT_PUBLIC_CLERK_SIGN_IN_URL, NEXT_PUBLIC_CLERK_SIGN_UP_URL 환경 변수도 같이 맞춰야 미들웨어가 올바른 곳으로 리다이렉트합니다.

User Button

<SignedIn>과 <SignedOut>은 세션 상태에 따라 자식을 렌더링하는 조건부 래퍼입니다. 이것은 화면 표시만 바꿀 뿐 접근 제어가 아니므로, 데이터 보호는 반드시 서버 쪽(미들웨어, 서버 컴포넌트, API Route)에서 해야 합니다. mode="modal"을 쓰면 별도 페이지로 이동하지 않고 현재 화면 위에 로그인 창을 띄웁니다.

// components/Header.tsx
import { UserButton, SignedIn, SignedOut, SignInButton } from '@clerk/nextjs';
export default function Header() {
  return (
    <header>
      <SignedIn>
        <UserButton afterSignOutUrl="/" />
      </SignedIn>
      <SignedOut>
        <SignInButton mode="modal">
          <button>Sign In</button>
        </SignInButton>
      </SignedOut>
    </header>
  );
}

보호된 페이지

클라이언트 컴포넌트

'use client';
import { useUser } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
export default function DashboardPage() {
  const { isLoaded, isSignedIn, user } = useUser();
  if (!isLoaded) return <div>Loading...</div>;
  if (!isSignedIn) return redirect('/sign-in');
  return (
    <div>
      <h1>Welcome, {user.firstName}!</h1>
    </div>
  );
}

클라이언트 컴포넌트에서는 isLoaded를 먼저 확인해야 합니다. Clerk 스크립트가 세션을 불러오기 전에는 isSignedIn이 undefined라서, 이 검사를 빼면 로그인한 사용자도 새로고침할 때마다 잠깐 로그인 페이지로 튕겨 나가는 현상이 생깁니다. 또한 redirect()는 원래 서버 쪽에서 쓰도록 설계된 함수라, 클라이언트에서는 useRouter().push()를 쓰거나 애초에 미들웨어에서 막는 편이 흐름이 깔끔합니다.

서버 컴포넌트

서버 컴포넌트에서는 HTML을 보내기 전에 사용자를 확인하므로 로딩 깜빡임이 없고, 보호된 데이터가 클라이언트로 새어 나갈 여지도 줄어듭니다.

import { currentUser } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
  const user = await currentUser();
  if (!user) {
    redirect('/sign-in');
  }
  return (
    <div>
      <h1>Welcome, {user.firstName}!</h1>
    </div>
  );
}

currentUser()와 auth()의 차이를 알아두면 좋습니다. auth()는 요청에 담긴 세션 토큰만 해석해 userId, orgId 같은 클레임을 돌려주므로 네트워크 호출이 없습니다. 반면 currentUser()는 Clerk Backend API를 호출해 이름, 이메일, 프로필 이미지까지 담긴 전체 사용자 객체를 가져옵니다. 권한 확인만 필요한 곳에서 습관적으로 currentUser()를 부르면 페이지마다 외부 API 왕복이 추가되고, 트래픽이 많을 때는 Backend API 요청 한도(rate limit)에 걸려 429 응답을 받을 수 있습니다.


API 보호

API Route

// app/api/protected/route.ts
import { auth } from '@clerk/nextjs';
import { NextResponse } from 'next/server';
export async function GET() {
  const { userId } = auth();
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const data = await getProtectedData(userId);
  return NextResponse.json(data);
}

미들웨어가 이미 경로를 보호하더라도 Route Handler 안에서 userId를 다시 확인하는 것이 좋습니다. 미들웨어의 matcher나 공개 경로 설정이 바뀌면 보호가 조용히 풀릴 수 있고, 무엇보다 “누가 요청했는지”를 알아야 해당 사용자의 데이터만 조회할 수 있기 때문입니다. getProtectedData(userId)처럼 조회 조건에 반드시 userId를 넣어야, 다른 사용자의 ID를 파라미터로 넘겨 남의 데이터를 읽는 IDOR 취약점을 막을 수 있습니다.

모바일 앱이나 별도 백엔드에서 이 API를 호출할 때는 쿠키가 없으므로 Authorization: Bearer <세션 토큰> 헤더로 토큰을 보내야 합니다. 클라이언트에서는 useAuth().getToken()으로 토큰을 얻을 수 있습니다. 이 토큰은 수명이 약 60초로 짧기 때문에 한 번 받아서 저장해 두고 재사용하면 금방 401이 납니다. 요청할 때마다 getToken()을 호출하는 것이 맞는 사용법입니다.


Webhook

설정

사용자 정보는 Clerk에 있지만, 게시글 작성자나 주문 내역처럼 내 DB의 테이블이 사용자를 참조해야 하는 경우가 대부분입니다. 이때 Clerk가 사용자 생성·수정·삭제 이벤트를 HTTP POST로 알려주는 Webhook을 받아 내 DB의 users 테이블을 맞춥니다. Clerk는 Svix를 통해 웹훅을 전송하므로, 요청 헤더의 서명을 svix 라이브러리로 검증해 위조된 요청을 걸러냅니다.

// app/api/webhook/route.ts
import { Webhook } from 'svix';
import { headers } from 'next/headers';
export async function POST(req: Request) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET!;
  const headerPayload = headers();
  const svixId = headerPayload.get('svix-id');
  const svixTimestamp = headerPayload.get('svix-timestamp');
  const svixSignature = headerPayload.get('svix-signature');
  const body = await req.text();
  const wh = new Webhook(WEBHOOK_SECRET);
  let evt;
  try {
    evt = wh.verify(body, {
      'svix-id': svixId!,
      'svix-timestamp': svixTimestamp!,
      'svix-signature': svixSignature!,
    });
  } catch (err) {
    return new Response('Webhook verification failed', { status: 400 });
  }
  const { type, data } = evt;
  switch (type) {
    case 'user.created':
      await createUserInDatabase(data);
      break;
    case 'user.updated':
      await updateUserInDatabase(data);
      break;
    case 'user.deleted':
      await deleteUserFromDatabase(data);
      break;
  }
  return new Response('Webhook received', { status: 200 });
}

핵심은 req.text()로 원문 문자열 그대로 서명을 검증하는 부분입니다. req.json()으로 파싱한 뒤 JSON.stringify로 다시 만들면 공백이나 키 순서가 달라져 서명이 맞지 않고, WebhookVerificationError: No matching signature found 오류가 납니다. svix-timestamp는 재전송 공격을 막기 위한 값이라 서버 시계가 크게 어긋나 있으면 Message timestamp too old 오류로 검증이 실패합니다. Next.js 15 이상에서는 headers()가 Promise를 반환하므로 await headers()로 바꿔야 합니다.

제가 웹훅 동기화에서 가장 자주 본 문제는 중복 처리와 순서입니다. Svix는 응답이 2xx가 아니거나 시간 초과되면 같은 이벤트를 일정 간격으로 재전송하므로, user.created가 두 번 들어와 unique 제약 위반이 나거나 사용자가 두 번 생성되는 일이 생깁니다. createUserInDatabase는 Clerk 사용자 ID를 기준으로 upsert하도록 만들고, svix-id를 저장해 이미 처리한 이벤트는 건너뛰는 편이 안전합니다. 또 가입 직후 리다이렉트된 페이지가 웹훅보다 먼저 DB를 조회하면 “사용자 없음”이 나오므로, 첫 화면에서는 레코드가 없을 때 그 자리에서 생성하거나 잠시 후 재조회하는 처리가 필요합니다.

로컬 개발 중에는 Clerk가 localhost로 요청을 보낼 수 없으므로 ngrok 같은 터널로 공개 URL을 만들어 대시보드에 등록해야 합니다. 개발용과 프로덕션용 엔드포인트는 서명 시크릿(whsec_...)이 각각 다르다는 점도 배포할 때 자주 놓칩니다.


조직 관리

조직 생성

import { OrganizationSwitcher, OrganizationProfile } from '@clerk/nextjs';
export default function OrganizationPage() {
  return (
    <div>
      <OrganizationSwitcher />
      <OrganizationProfile />
    </div>
  );
}

조직(Organization)은 B2B SaaS처럼 한 사용자가 여러 회사 계정에 속하고, 회사마다 역할이 다른 구조를 표현하는 기능입니다. <OrganizationSwitcher />로 현재 활성 조직을 바꾸면 세션 토큰의 orgId와 orgRole이 바뀌므로, 서버에서는 이 값으로 “지금 어느 회사의 데이터를 보고 있는지”를 판단합니다. 조직 기능은 대시보드에서 먼저 활성화해야 컴포넌트가 동작합니다.

권한 확인

아래 예제는 역할 문자열을 직접 비교합니다. 실제 Clerk의 기본 역할 키는 org:admin, org:member처럼 org: 접두사가 붙은 형태이므로, 대시보드에 정의된 키 이름과 정확히 일치하는지 확인해야 합니다.

import { auth } from '@clerk/nextjs';
export default async function AdminPage() {
  const { userId, orgRole } = auth();
  if (orgRole !== 'admin') {
    return <div>Access Denied</div>;
  }
  return <div>Admin Panel</div>;
}

역할 문자열 비교는 역할이 늘어날수록 조건문이 흩어져 관리하기 어렵습니다. auth().has({ permission: 'org:invoice:create' })처럼 권한(permission) 단위로 확인하면 역할 구성이 바뀌어도 코드를 고칠 필요가 없습니다. 그리고 활성 조직이 없는 개인 계정 상태에서는 orgRole이 undefined이므로, 조직 전용 페이지는 orgId 존재 여부부터 확인해야 합니다.


정리 및 체크리스트

핵심 요약

  • Clerk: 인증 및 사용자 관리
  • 다양한 인증: 이메일, OAuth, Magic Link
  • MFA: 2단계 인증
  • 사용자 관리: Dashboard
  • 조직 관리: Multi-tenancy
  • Next.js: 미들웨어·서버 컴포넌트용 SDK 제공

구현 체크리스트

  • Clerk 계정 생성
  • SDK 설치
  • Middleware 설정
  • 인증 컴포넌트 추가
  • 보호된 페이지 구현
  • API 보호
  • Webhook 설정

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Auth0와 비교하면 어떤가요?

A. Clerk는 React/Next.js용 UI 컴포넌트와 SDK가 잘 갖춰져 있어 초기 구현이 빠릅니다. Auth0는 SAML·엔터프라이즈 SSO, Actions 같은 확장 지점, 다양한 언어 SDK 등 범용성이 더 넓습니다. 프론트엔드가 Next.js 중심이면 Clerk, 여러 플랫폼과 엔터프라이즈 요구사항이 많으면 Auth0 쪽이 맞는 경우가 많습니다.

Q. 무료로 사용할 수 있나요?

A. 무료 플랜이 있지만 사용자 수 기준과 한도는 요금 정책 개편에 따라 바뀌어 왔으므로 공식 가격 페이지에서 현재 조건을 확인하는 것이 정확합니다.

Q. 커스터마이징이 가능한가요?

A. appearance prop으로 색상·폰트·레이아웃을 바꿀 수 있고, 완전히 다른 UI가 필요하면 useSignIn, useSignUp 훅으로 직접 폼을 만들 수 있습니다. 다만 직접 만든 폼은 MFA·이메일 인증 같은 단계 전환도 직접 처리해야 합니다.

Q. Clerk 앱을 프로덕션에 배포하기 전에 무엇을 바꿔야 하나요?

A. 프로덕션 인스턴스용 도메인(DNS) 설정, pk_live_ 키 교체, 웹훅 엔드포인트와 시크릿 재등록을 배포 전에 모두 마쳐야 합니다.