
Next.js + Supabase 프로젝트 - (9) : 로그인 회원가입 인증 구현 with Clerk
Next.js + Supabase 를 사용한다면 Supabase 를 사용하니까 편하게 Supabase 의 내제된 기능인 auth 를 사용을 할 수도 있습니다. 하지만 로그인 관련 UI 구현도 귀찮고, 모던 로그인 UX 등을 이해해보고 싶어, Clerk 라는 인증 관리 툴을 사용해 볼까 합니다. Supabase Auth vs Clerk 비교Supabase Auth✅ 장점완전한 통합: 이미 Supabase 사용 중이므로 추가 설정 최소화데이터베이스 직접 연동: RLS(Row Level Security)로 데이터 보안 자동 관리비용 효율성: 별도 인증 서비스 비용 없음간단한 설정: 기존 Supabase 클라이언트 그대로 활용기존 코드와 완벽 호환❌ 단점UI 커스터마이징 제한: 인증 UI를 직접 구현해야 함소..
Next.js + Supabase 를 사용한다면 Supabase 를 사용하니까 편하게 Supabase 의 내제된 기능인 auth 를 사용을 할 수도 있습니다.
하지만 로그인 관련 UI 구현도 귀찮고, 모던 로그인 UX 등을 이해해보고 싶어, Clerk 라는 인증 관리 툴을 사용해 볼까 합니다.

Supabase Auth vs Clerk 비교
Supabase Auth
✅ 장점
- 완전한 통합: 이미 Supabase 사용 중이므로 추가 설정 최소화
- 데이터베이스 직접 연동: RLS(Row Level Security)로 데이터 보안 자동 관리
- 비용 효율성: 별도 인증 서비스 비용 없음
- 간단한 설정: 기존 Supabase 클라이언트 그대로 활용
- 기존 코드와 완벽 호환
❌ 단점
- UI 커스터마이징 제한: 인증 UI를 직접 구현해야 함
- 소셜 로그인 설정 복잡: OAuth 앱 각각 설정 필요
- 고급 기능 부족: MFA, 세션 관리 등이 상대적으로 단순
Clerk
✅ 장점
- 완성도 높은 UI: 즉시 사용 가능한 아름다운 인증 컴포넌트
- 고급 기능: MFA, 세션 관리, 사용자 프로필 등 풍부한 기능
- 간편한 소셜 로그인: 클릭 몇 번으로 Google, GitHub 등 연동
- Next.js 최적화: App Router 완벽 지원
- 한 줄로 완성되는 로그인 UI
❌ 단점
- 추가 비용: 월 $25/1000 MAU (무료: 10,000 MAU) => MAU 란 : Monthly Active Users, 월간 활성 사용자
- 복잡한 연동: Supabase RLS와 별도 설정 필요
- 데이터 동기화: Clerk 사용자 ID를 Supabase에 매핑해야 함
현재 상황 고려사항
프로젝트 규모
현재 블로그 시스템
- 포스트 작성/수정 (인증 필요)
- 댓글 시스템 (예상)
- 좋아요/북마크 (예상)
기술 스택 일관성
현재: Next.js + Supabase + TypeScript
→ Supabase Auth 선택 시: 100% 일관성 유지
→ Clerk 선택 시: 인증만 외부 서비스
상황별 추천
Supabase Auth 추천 상황 ⭐⭐⭐
- ✅ 개인/소규모 프로젝트
- ✅ 빠른 개발 우선
- ✅ 비용 절약 중요
- ✅ Supabase 생태계 최대 활용
// 예시: 포스트 작성자만 수정 가능 (RLS 자동 적용)
CREATE POLICY "Users can only edit their own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);
Clerk 추천 상황 ⭐⭐
- ✅ 사용자 경험 중시
- ✅ 복잡한 사용자 관리 필요
- ✅ 예산 여유 있음 (한달간 사용자가 만명을 넘기고 있는 상황일때)
- ✅ 빠른 UI 구현 필요
가장 중요한 것은, ㅋㅋㅋ 솔직히 토이프로젝트로 아무리 용을 써도 MAU 10,000 을 넘는 프로젝트로 발전시킨다는건ㅋㅋㅋ 말도 안되는 거니까, 사용자의 경험을 중시하고 모던한 익숙한 UI 를 접할 수 있는 방식을 사용하겠습니다.
1. Clerk 로그인 및 회원가입
Clerk | Authentication and User ManagementThe easiest way to add authentication and user management to your application. Purpose-built for React, Next.js, Remix, and “The Modern Web”.clerk.com
2. 사용할 로그인 방식들 선택

저는 Email, Username, Google, Github 정도로 하겠습니다
저는 이 프로젝트에서는 당연히 Next 용 설치를 진행하겠습니다

1. 프로젝트 터미널에서 next 용 clerk 설치
해당 명령어 실행
npm install @clerk/nextjs
2. Clerk Key 값들을 .env 파일에 추가
1의 다음 줄의 명령어를 복사를 눌러 .env 혹은 .env.local 파일에 추가해줍니다
Set your Clerk API keys
부분 참고.
3. middleware.ts 파일 업데이트
사이트에서는 아직 middleware 파일이 없다는 전제 하에 진행을 하고 있습니다.
우리의 파일은 다음과 같이 되어있어야 합니다
middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";
// 보호할 라우트 정의
const isProtectedRoute = createRouteMatcher([
'/blog/write(.*)',
// 필요에 따라 추가 라우트 설정
]);
export default clerkMiddleware(async (auth, req) => {
// 보호된 라우트에 접근 시 인증 체크
if (isProtectedRoute(req)) {
const { userId } = await auth();
if (!userId) {
// 로그인하지 않은 사용자는 홈페이지로 리디렉션
return NextResponse.redirect(new URL('/', req.url));
}
}
// Supabase 미들웨어 적용
return await applyMiddlewareSupabaseClient(req);
});
export const applyMiddlewareSupabaseClient = async (request: NextRequest) => {
// Create an unmodified response
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
// If the cookie is updated, update the cookies for the request and response
request.cookies.set({
name,
value,
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value,
...options,
});
},
remove(name: string, options: CookieOptions) {
// If the cookie is removed, update the cookies for the request and response
request.cookies.set({
name,
value: "",
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value: "",
...options,
});
},
},
}
);
// refreshing the auth token
await supabase.auth.getUser();
return response;
};
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
4. Clerk 테마 설치
npm i @clerk/themes
테마들을 설치해서 우리가 나중에 UI 전체를 분위기를 바꿔도 여러개중에서 어울리는걸 선택하기 위해 먼저 받아놓는 것입니다
5. <ClerkProvider> 사용 선언
app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import ReactQueryClientProvider from "@/config/ReactQueryClientProvider";
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
import Header from "@/components/Header";
import UserSync from "@/components/UserSync";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
),
title: "Kyurasi Next Supabase",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider
appearance={{
baseTheme: dark,
}}
>
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* Fixed Header Navigation */}
<Header />
{/* 사용자 동기화 컴포넌트 */}
<UserSync />
{/* Main Content with top padding to account for fixed header */}
<main className="pt-16">
<ReactQueryClientProvider>
{children}
</ReactQueryClientProvider>
</main>
</body>
</html>
</ClerkProvider>
);
}
전체 Layout 을 <ClerkProvider> 로 감싸주어서, 전체 페이지에서 ClerkProvider 을 사용할 수 있도록 선언을 해줍니다.
4. Clerk 회원가입시 supabase 에 users 데이터에도 저장하는 코드 작성
components/UserSync.tsx 파일 생성
"use client";
import { useUser } from "@clerk/nextjs";
import { useEffect } from "react";
import { createBrowserSupabaseClient } from "@/utils/supabase/client";
export default function UserSync() {
const { user, isLoaded } = useUser();
useEffect(() => {
const syncUser = async () => {
if (!isLoaded || !user) return;
const supabase = createBrowserSupabaseClient();
try {
// 사용자가 이미 존재하는지 확인
const { data: existingUser } = await supabase
.from("users")
.select("*")
.eq("clerk_id", user.id)
.single();
if (existingUser) {
// 기존 사용자 정보 업데이트 (로그인 시간, 프로필 변경사항 등)
await supabase
.from("users")
.update({
email: user.primaryEmailAddress?.emailAddress || "",
username: user.username,
first_name: user.firstName,
last_name: user.lastName,
profile_image_url: user.imageUrl,
display_name:
user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username || user.firstName || "익명",
last_sign_in_at: user.lastSignInAt?.toISOString(),
email_verified:
user.primaryEmailAddress?.verification?.status === "verified",
})
.eq("clerk_id", user.id);
console.log("사용자 정보 업데이트 완료:", user.id);
return;
}
// 새 사용자 생성
const newUser = {
clerk_id: user.id,
email: user.primaryEmailAddress?.emailAddress || "",
username: user.username,
first_name: user.firstName,
last_name: user.lastName,
profile_image_url: user.imageUrl,
display_name:
user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username || user.firstName || "익명",
last_sign_in_at: user.lastSignInAt?.toISOString(),
email_verified:
user.primaryEmailAddress?.verification?.status === "verified",
}
const { error } = await supabase
.from("users")
.insert([newUser]);
if (error) {
console.error("사용자 생성 오류:", error);
} else {
console.log("새 사용자 생성 완료:", user.id);
}
} catch (error) {
console.error("사용자 동기화 오류:", error);
}
};
syncUser();
}, [user, isLoaded]);
// 이 컴포넌트는 UI를 렌더링하지 않음
return null;
}
일단, 헤더를 만들고 링크들을 연결하고, 회원가입이나 로그인 등은 어느 페이지를 가도 상단에 항상 떠있어야 하기 때문에 Header 를 새로 만들어주었습니다
components/Header.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
import {
SignedIn,
SignedOut,
SignInButton,
SignUpButton,
UserButton,
} from "@clerk/nextjs";
export default function Header() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">
큐라시 블로그
</h1>
</Link>
{/* Desktop Navigation Links */}
<nav className="hidden md:flex items-center space-x-8">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
홈
</Link>
<Link
href="/blog"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
블로그
</Link>
</nav>
{/* Desktop Auth Buttons */}
<div className="hidden md:flex items-center space-x-4">
<SignedOut>
<SignInButton>
<button className="text-gray-600 hover:text-gray-900 transition-colors cursor-pointer">
로그인
</button>
</SignInButton>
<SignUpButton>
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors cursor-pointer">
회원가입
</button>
</SignUpButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
{/* Mobile menu button */}
<div className="md:hidden flex items-center space-x-2">
<SignedIn>
<UserButton />
</SignedIn>
<button
onClick={toggleMobileMenu}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-expanded="false"
>
<span className="sr-only">메뉴 열기</span>
{/* Hamburger icon */}
<svg
className={`${
isMobileMenuOpen ? "hidden" : "block"
} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{/* Close icon */}
<svg
className={`${
isMobileMenuOpen ? "block" : "hidden"
} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div
className={`${isMobileMenuOpen ? "block" : "hidden"} md:hidden`}
>
<div className="px-2 pt-2 pb-3 space-y-1 bg-white border-t border-gray-200">
{/* Mobile Navigation Links */}
<Link
href="/"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 cursor-pointer"
onClick={() => setIsMobileMenuOpen(false)}
>
홈
</Link>
<Link
href="/blog"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 cursor-pointer"
onClick={() => setIsMobileMenuOpen(false)}
>
블로그
</Link>
{/* Mobile Auth Buttons */}
<div className="pt-4 pb-3 border-t border-gray-200">
<SignedOut>
<div className="space-y-2">
<SignInButton>
<button
className="w-full text-left px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 cursor-pointer"
onClick={() =>
setIsMobileMenuOpen(false)
}
>
로그인
</button>
</SignInButton>
<SignUpButton>
<button
className="w-full text-left px-3 py-2 rounded-md text-base font-medium bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
onClick={() =>
setIsMobileMenuOpen(false)
}
>
회원가입
</button>
</SignUpButton>
</div>
</SignedOut>
</div>
</div>
</div>
</header>
);
}
이 헤더 컴포넌트는 Mobile 로 점근 할 경우를 생각해서 모바일일 경우에는 햄버거 메뉴로 둬서, 반응형으로 작동하게 해두었습니다

만일, 회원가입할때에 회원가입할 때에 약관 조항 같은 것들 동의 버튼을 추가하고싶으시면

요기서 Require 을 켜주시되, 그 페이지를 만들어서 URL 같이 띄워주겨야 합니다. 저는 토이프로젝트이니 패스하겠습니다ㅎㅎ
이제 저희 Supabase 프로젝트에 Users 라는 테이블을 만들어서, 유저가 생성될때 DB 에 저장을 하겠습니다. 그래야 앞으로 어떤 유저가 어떤 글을 쓰고 어떤 행위를 하는지 전부 연결해 트래킹을 할 수 있기 때문입니다
아래의 명령어를 Supabase - SQL Editor 로 가 아래의 명령어를 붙어 넣습니다

실행할 명령어는, Users 테이블을 생성하고, Users 테이블과 posts 테이블의 관게를 연결해주는 부분입니다.
-- Users 테이블 생성 (확장된 설계)
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
clerk_id TEXT UNIQUE NOT NULL, -- Clerk의 사용자 ID
-- 기본 정보
email TEXT,
username TEXT,
first_name TEXT,
last_name TEXT,
-- 프로필 정보
profile_image_url TEXT,
bio TEXT, -- 자기소개
website_url TEXT, -- 개인 웹사이트
location TEXT, -- 위치
-- 블로그 관련 설정
display_name TEXT, -- 블로그에 표시될 이름 (기본값: first_name + last_name)
is_admin BOOLEAN DEFAULT FALSE, -- 관리자 여부
is_author BOOLEAN DEFAULT TRUE, -- 작성자 권한
-- 메타데이터
last_sign_in_at TIMESTAMP WITH TIME ZONE,
email_verified BOOLEAN DEFAULT FALSE,
-- 타임스탬프
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- updated_at 자동 업데이트 함수
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- updated_at 트리거
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- posts 테이블에 user_id 컬럼 추가 (users.id를 참조)
ALTER TABLE posts ADD COLUMN IF NOT EXISTS user_id INTEGER;
-- 외래 키 제약 조건 추가
ALTER TABLE posts
ADD CONSTRAINT fk_posts_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE SET NULL;
항상!! 테이블을 변경하거나 추가했으면 해줘야 할 행위가 있죠.
프로젝트의 터미널로 들어가, 지금 supabase 의 DB 와 현재 이 프로젝트와 DB 를 일치시켜주는 작업을 해야 합니다
혹시나 supabase 로그아웃 되었을 수도 있으니까
npx supabase login
이후에
npm run generate-types
이렇게 다시 DB 상황을 일치시켜준 후, 프로젝트 터미널에서 npm run dev 를 진행중이었다면 control + c 후에 다시 npm run dev 명령어를 입력하여 새로 서버를 돌려보고, 회원가입을 진행해 봅니다.
자, 이 전에 몇 가지 제가 이전에 .env 파일 설정을 좀 잘못한게 있었는데ㅎㅎ 그 부분만 좀 추가 수정을 하고 deploy 다시 가볼게요
먼저 .env 파일 부터 수정을 해볼게요
# 클라이언트에서 사용 (브라우저에 노출되어도 안전)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 서버에서만 사용 (브라우저에 노출되지 않음)
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_SUPABASE_DB_PASSWORD=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
여기에 해당하는 값들은 Supabase, Clerk 대시보드에서 확인할 수 있습니다.
utils/supabase/client.ts 파일 수정
"use client";
import { createBrowserClient } from "@supabase/ssr";
// 내 supabase 프로젝트랑 연관된 이 api를 통해서 anon key를 통해서 브라우저 클라이언트가 하나 자동으로 생성이 된다.
export const createBrowserSupabaseClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
utils/supabase/server.ts 파일 수정
"use server";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Database } from "../../types_db";
// 서버 컴포넌트들에서만 사용을 할 거임
export const createServerSupabaseClient = async (
cookieStore?: Awaited<ReturnType<typeof cookies>>,
admin: boolean = false
) => {
const resolvedCookieStore = cookieStore || await cookies();
return createServerClient<Database>(
process.env.SUPABASE_URL!,
admin
? process.env.SUPABASE_SERVICE_ROLE_KEY!
: process.env.SUPABASE_ANON_KEY!,
{
// 쿠기 get, set, remove 하는 부분 설정을 해줘야
// 이제 유저 관련된 오퍼레이션들이 전부 동작을 함. 인증 부분 구축을 할 때에 많이 사용을 하게 될 거임.
// supabase 에 다 나와있긴 한데, 좀 복잡하니 이거 사용
cookies: {
get(name: string) {
return resolvedCookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
resolvedCookieStore.set({ name, value, ...options });
} catch (error) {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
remove(name: string, options: CookieOptions) {
try {
resolvedCookieStore.set({
name,
value: "",
...options,
});
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
};
// 이 함수는 admin 을 true 로 줘서 하는거 정리하려고.
export const createServerSupabaseAdminClient = async (
cookieStore?: Awaited<ReturnType<typeof cookies>>
) => {
return createServerSupabaseClient(cookieStore, true);
};
// 인증 여부 확인 함수
export const requireAuth = async () => {
const supabase = await createServerSupabaseClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
// 인증되지 않은 경우 /auth로 리디렉션
redirect("/auth");
}
return session; // 인증된 경우 세션 반환
};
이 값들을 이제 vercel 에 들어가서 프로젝트를 선택하고, Setting 에 들어가서 하나하나 입력을 해주고 Redeploy 를 하고, 여기까지의 코드를
git add .
git commit -m "오늘의변경내용들..."
git push origin main 혹은 git push origin master
로 push 해둡니다!!


