이번 프로젝트에서 Supabase를 활용한 소셜로그인(google, kakao, github)이랑 회원 탈퇴 기능 구현을 맡게 되었다.
우선 공통적으로 기능 구현 전에 supabase와 각 플랫폼을 연동 시켜줘야 한다.
Supabase 대시보드에서 다음과 같은 경로로 이동하고,
Supabase -> Authentication -> Providers
내가 사용하고자 하는 플랫폼을 Enable로 변경해준다.
해당 링크로 접속 후 내가 만들고자 하는 앱의 기본적인 정보들을 입력해주고, 키와 secret key를 발급받는다.(플랫폼마다 명칭 조금씩 다름)
발급받은 key들을 supabase에 입력해주고 supabase에서 제공해주는 callbackURL은 각 플랫폼 셋팅할 때 입력해주면 된다.
해당 과정에 대한 자세한 설명은 여러 블로그에 잘 적혀 있으므로, 참고해서 진행하면 된다.
그 다음에는 환경 변수를
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# 회원 탈퇴에 필요
SERVICE_ROLE_KEY=
작성해주고,
// utils/supabase/client.ts
import { Database } from "@/lib/types/supabase";
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// utils/supabase/server.ts
import { cookies } from "next/headers";
import { Database } from "@/lib/types/supabase";
import { createServerClient } from "@supabase/ssr";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
공식 문서에 적혀있는대로 서버 측, 클라이언트 측에서 사용할 Supabase 클라이언트를 설정해준다.
그 다음엔 callback 라우트를 설정해준다.
// src/app/auth/callback/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/utils/supabase/server";
// The client you created from the Server-Side Auth instructions
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
// if "next" is in param, use it as the redirect URL
const next = searchParams.get("next") ?? "/";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
console.log(error);
if (!error) {
const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {
// we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
return NextResponse.redirect(`${origin}${next}`);
}
}
}
// return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}
이또한 공식문서에 적혀있는대로 작성해주었다.
마지막으로 auth.users
테이블과 public.users
테이블이 데이터를 동기화할 수 있도록 트리거까지 설정해주면 끝이다!
CREATE OR REPLACE FUNCTION public.handle_new_social_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.users (id, username, created_at)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', 'Unnamed User'),
NOW()
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_social_user();
이렇게 기본 설정이 완료되었으며, 나는 구글 로그인부터 구현했다.
"use client";
import Image from "next/image";
import { createClient } from "@/lib/utils/supabase/client";
import googleLogo from "@/assets/images/google-logo.svg";
const GoogleLoginBtn = () => {
const handleGoogleLogin = async () => {
const supabase = await createClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/signup`,
queryParams: {
prompt: "consent select_account", // 계정 선택 + 동의 창 표시
}
}
});
if (error) {
console.error("Google 로그인 오류:", error.message);
}
} catch (err) {
console.error("오류 발생:", err);
}
};
return (
<button
onClick={handleGoogleLogin}
className="w-4/6 h-10 md:h-12 border text-sm md:text-base bg-[#ffffff] font-medium text-[#666666] border-[#dddddd] rounded-md flex items-center gap-8 md:gap-20"
>
<Image src={googleLogo} width={24} height={24} alt="googleLogo" className="ml-8" />
<span>Google 계정으로 시작</span>
</button>
);
};
export default GoogleLoginBtn;
소셜 로그인 별 버튼을 나누는 방식으로 작성했다.
참고로 queryParams: {}
부분은 매 로그인마다 계정 선택 + 동의창이 뜨도록 강제하는 옵션이다. 이는 선택사항! 나는 사용자 경험 개선에 도움이 될 거라 생각해 추가했다.
"use client";
import Image from "next/image";
import { createClient } from "@/lib/utils/supabase/client";
import kakaoLogo from "@/assets/images/kakao-logo.svg"
const KakaoLoginBtn = () => {
const handleKakaoLogin = async () => {
const supabase = await createClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: "kakao",
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/signup`,
}
});
if (error) {
console.error("Kakao 로그인 오류:", error.message);
}
} catch (err) {
console.error("오류 발생:", err);
}
};
return (
<button
onClick={handleKakaoLogin}
className="w-4/6 h-10 md:h-12 border text-sm md:text-base bg-[#FFEB00] font-medium text-[#333333] border-[#dddddd] rounded-md flex items-center gap-8 md:gap-20"
>
<Image src={kakaoLogo} width={24} height={24} alt="kakaoLogo" className="ml-8" />
<span>Kakao 계정으로 시작</span>
</button>
);
};
export default KakaoLoginBtn;
사실 provider 부분이랑 css 말고는 바뀐 부분이 없다 (ㅋㅋㅋ)
"use client";
import Image from "next/image";
import { createClient } from "@/lib/utils/supabase/client";
import githubLogo from "@/assets/images/github-logo.svg"
const GithubLoginBtn = () => {
const handleGithubLogin = async () => {
const supabase = await createClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: "github",
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/signup`,
}
});
if (error) {
console.error("Google 로그인 오류:", error.message);
}
} catch (err) {
console.error("오류 발생:", err);
}
};
return (
<button
onClick={handleGithubLogin}
className="w-4/6 h-10 md:h-12 border text-sm md:text-base bg-[#181717] font-medium text-[#ffffff] border-[#dddddd] rounded-md flex items-center gap-8 md:gap-20"
>
<Image src={githubLogo} width={24} height={24} alt="githubLogo" className="ml-8 invert" />
<span>Github 계정으로 시작</span>
</button>
);
};
export default GithubLoginBtn;
얘도 마찬가지...
그래서 그냥 공통 컴포넌트로 빼버렸다!
// auth/_componenets/SocialLoginBtn.tsx
"use client";
import Image from "next/image";
import { createClient } from "@/lib/utils/supabase/client";
import { Provider, PROVIDER_CONFIG } from "@/constants/oauth";
type SocialLoginBtnProps = {
provider: Provider;
}
const SocialLoginBtn = ({provider}: SocialLoginBtnProps) => {
const handleLogin = async () => {
const supabase = await createClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/signup`,
queryParams: PROVIDER_CONFIG[provider].queryParams
}
});
if (error) {
console.error(`${provider} 로그인 오류:`, error.message);
}
} catch (err) {
console.error("오류 발생:", err);
}
};
return (
<button
onClick={handleLogin}
className={`w-4/6 h-10 md:h-12 border text-sm md:text-base ${PROVIDER_CONFIG[provider].bgColor} ${PROVIDER_CONFIG[provider].textColor} font-medium border-[#dddddd] rounded-md flex items-center gap-8 md:gap-20`}
>
<Image src={PROVIDER_CONFIG[provider].logo} width={24} height={24} alt={`${provider}Logo`} className={`ml-8 ${PROVIDER_CONFIG[provider].logoClass}`} />
<span>{PROVIDER_CONFIG[provider].label}</span>
</button>
);
};
export default SocialLoginBtn;
// constants/oauth.ts
import googleLogo from "@/assets/images/google-logo.svg"
import kakaoLogo from "@/assets/images/kakao-logo.svg"
import githubLogo from "@/assets/images/github-logo.svg"
export type Provider = "google" | "kakao" | "github"
export type ProviderConfig = {
label: string;
bgColor: string;
textColor: string;
logo: string;
logoClass?: string;
queryParams?: Record<string, string>;
}
export const PROVIDER_CONFIG: Record<Provider, ProviderConfig> = {
google: {
label: "Google 계정으로 시작",
bgColor: "bg-white",
textColor: "text-[#666666]",
logo: googleLogo,
queryParams: {
prompt: "consent select_account"
}
},
kakao: {
label: "Kakao 계정으로 시작",
bgColor: "bg-[#ffeb00]",
textColor: "text-[#333333]",
logo: kakaoLogo,
},
github: {
label: "Github 계정으로 시작",
bgColor: "bg-[#181717]",
textColor: "text-white",
logo: githubLogo,
logoClass: "invert",
},
}
이렇게 로그인 기능 세 개 구현은 끝!
// stores/userStore.ts
"use client";
import { create } from "zustand";
import { createClient } from "@/lib/utils/supabase/client";
import { UserState } from "@/lib/types/auth";
export const useUserStore = create<UserState>((set) => ({
user: null,
isLogin: false,
fetchUser: async () => {
const supabase = createClient();
try {
const { data, error } = await supabase.auth.getSession();
if (error) throw error;
console.log("세션 정보 확인:", data.session);
if (data.session) {
set({
user: data.session.user,
isLogin: true
});
} else {
set({
user: null,
isLogin: false
});
}
} catch (err) {
console.error("세션 확인 오류", err);
set({ user: null, isLogin: false });
}
},
signOut: async () => {
const supabase = createClient();
try {
await supabase.auth.signOut();
set({ user: null, isLogin: false });
console.log("로그아웃 성공!");
} catch (err) {
console.error("logout 오류:", err);
}
},
}));
zustand
사용해 유저, 로그인 상태를 관리하게 했고, useEffect
를 통해 최상위 provider
에서 fetchUser
를 호출해 전역 상태를 유지하게 했다.
또한 로그아웃 기능은 signOut
함수를 통해 사용할 수 있게끔 했다.
회원 탈퇴는 기존의 Supabase 클라이언트 대신 Admin 클라이언트가 필요하다.
Admin 클라이언트는 높은 권한을 요구하기에 서버 환경에서만 사용해야 하며, 클라이언트 환경에서는 절대 노출되어선 안된다.
https://supabase.com/docs/reference/javascript/admin-api
따라서 해당 문서를 참고하여, 환경 변수에 SERVICE_ROLE_KEY를 추가하고
// supabase/admin.ts
import { Database } from "@/lib/types/supabase";
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
const serviceRoleKey = process.env.SERVICE_ROLE_KEY as string;
const supabase = createClient<Database>(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
// Access auth admin api
const adminAuthClient = supabase.auth.admin;
export { adminAuthClient };
Admin 클라이언트를 생성한 뒤, 보안상의 이유로 zustand
스토어에서 직접 사용자 삭제 기능을 정의하지 않고, 별도의 서버 라우트 핸들러를 통해 처리하도록 구성했다.
(service_role_key는 민감한 정보이기에 서버 환경에서만 사용해야함)
// api/auth/delete/route.ts
import { NextResponse } from "next/server";
import { adminAuthClient } from "@/lib/utils/supabase/admin";
import { createClient } from "@/lib/utils/supabase/server";
export async function DELETE(request: Request) {
try {
const supabase = await createClient();
const {
data: { session }
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "유저가 확인되지 않습니다." });
}
const { error } = await adminAuthClient.deleteUser(session.user.id);
if (error) throw error;
return NextResponse.json({ message: "회원 탈퇴 성공" });
} catch (error: any) {
console.error("회원 탈퇴 오류", error.message);
return NextResponse.json({ error: error.message });
}
}
다음과 같이 getSession
을 통해 현재 로그인된 사용자의 세션 정보를 확인한 뒤, 해당 세션의 user.id
를 사용하여 adminAuthClient
를 사용해 Admin 권한으로 사용자 삭제 요청을 보내는 로직을 작성했다.
그 다음에 기존의 userStore
에 deleteUser
함수를 추가해 스토어에서 직접 Supabase Admin API를 호출하는 대신, 라우트 핸들러로 HTTP 요청을 보내게 했다.
deleteUser: async () => {
try {
const response = await fetch("/api/auth/delete", {
method: "DELETE"
});
if (!response.ok) {
throw new Error("회원 탈퇴 실패");
}
set({ user: null, isLogin: false });
console.log("회원 탈퇴 설공!");
} catch (error: any) {
console.error("회원 탈퇴 오류", error.message);
}
}
이로써 회원 탈퇴까지 마무리!!!!!