[NextJS] 소셜 로그인, 회원 탈퇴 (feat. Supabase)

우지끈·2024년 12월 27일
1
post-thumbnail

이번 프로젝트에서 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 라우트를 설정해준다.

https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=platform&platform=web&queryGroups=environment&environment=client&queryGroups=framework&framework=nextjs

// 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();

이렇게 기본 설정이 완료되었으며, 나는 구글 로그인부터 구현했다.

google 로그인

"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: {} 부분은 매 로그인마다 계정 선택 + 동의창이 뜨도록 강제하는 옵션이다. 이는 선택사항! 나는 사용자 경험 개선에 도움이 될 거라 생각해 추가했다.

kakao 로그인

"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 말고는 바뀐 부분이 없다 (ㅋㅋㅋ)

Github 로그인

"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 권한으로 사용자 삭제 요청을 보내는 로직을 작성했다.

그 다음에 기존의 userStoredeleteUser 함수를 추가해 스토어에서 직접 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);
        }
    }

이로써 회원 탈퇴까지 마무리!!!!!

0개의 댓글

관련 채용 정보