Notice
Recent Posts
Recent Comments
Link
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

개발자가 될래요

Next.js 소셜 로그인 구현 본문

프로젝트

Next.js 소셜 로그인 구현

Youcan 2024. 8. 31. 20:57

소셜 로그인을 구현 해보려고 한다.(supabase, nextauth 사용)

// src/app/(auth)/signin/page.tsx

"use client";

import InputTag from "@/components/InputTag";
import { signIn } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import Kakao from "@/asset/login/kakao_login_large_wide.png";
import Google from "@/asset/login/web_neutral_sq_ctn@3x.png";

export default function SignInPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await signIn("credentials", {
      email,
      password,
    });
  };

  return (
    <div>
      <h1>Sign In</h1>
      <div className="flex items-center justify-center min-h-screen bg-gray-100">
        <div className="w-full max-w-md p-8 space-y-8 bg-white rounded shadow-lg">
          <h2 className="text-2xl font-bold text-center">로그인</h2>
          <form onSubmit={handleSubmit} className="space-y-6">
            <InputTag
              label="이메일"
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
            <InputTag
              label="비밀번호"
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
            <div className="flex items-center justify-between">
              <Link
                href="#"
                className="text-sm text-indigo-600 hover:text-indigo-500"
              >
                아이디 찾기
              </Link>
              <Link
                href="#"
                className="text-sm text-indigo-600 hover:text-indigo-500"
              >
                비밀번호 찾기
              </Link>
            </div>
            <button
              type="submit"
              className="w-full py-2 text-white bg-indigo-600 rounded hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              로그인
            </button>
          </form>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-white text-gray-500">또는</span>
          </div>
          <div className="space-y-4">
            <Image
              src={Kakao}
              alt="카카오로그인"
              className="w-full hover:cursor-pointer"
            />
            <Image
              src={Google}
              alt="구글로그인"
              className="w-full h-14 hover:cursor-pointer"
            />
          </div>
          <div className="text-sm text-center text-gray-500">
            계정이 없으신가요?
            <Link
              href="/signup"
              className="text-indigo-600 hover:text-indigo-500"
            >
              회원가입
            </Link>
          </div>
        </div>
      </div>
    </div>
  );
}

기존의 코드에서 카카오 로그인 이미지와 구글 로그인 이미지를 넣어주었다.

 

 


그리고 https://console.cloud.google.com 에 접속한다.

그리고 API 및 서비스의 OAuth 동의 화면 탭에서 외부(External)을 체크하고 만들기를 진행한다.

앱 이름, 이메일, 애플리케이션 홈페이지를 작성하고, 승인된 도메인에는 supabase의 URL을 입력해준다.

(supabaseURL은 supabase -> Project Settings -> API에서 확인 가능하다.

애플리케이션 홈페이지는 http://localhost:3000 을 입력했고, 승인된 도메인에는 http://나 https://를 넣으면 안된다.)

 

 

이후 범위 추가 또는 삭제를 누르고, 로그인한 유저로부터 받아올 정보를 체크한다.

 

 

완료가 되었다면 사용자 인증 정보 탭에서 '사용자 인증 정보 만들기' -> 'OAuth 클라이언트 ID'를 눌러준다

 

애플리케이션 유형은 만드는 것에 해당하는 설정을 누르면 된다. (난 웹 상의 로그인을 구현하므로 웹 애플리케이션으로 설정했다.)

이후에 승인된 리디렉션 URI에는 http://localhost:3000/api/auth/callback/google을 넣어 주었다.

그리고 우측의 클라이언트 ID와 클라이언트 비밀번호는 GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET으로 .env.local파일에 추가함과 동시에 supabase에 추가한다.

 

 

그리고 나서 api를 만들어준다.

// src/app/api/auth/[...nextauth]/route.ts

import NextAuth, { Session, NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import KakaoProvider from "next-auth/providers/kakao";
import { createClient } from "@supabase/supabase-js";
import { JWT } from "next-auth/jwt";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey);

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials, req) {
        if (!credentials) {
          return null;
        }
        const { email, password } = credentials;

      
        const { data, error: signInError } =
          await supabaseAdmin.auth.signInWithPassword({
            email,
            password,
          });

        if (signInError) {
          const { data: signUpData, error: signUpError } =
            await supabaseAdmin.auth.signUp({
              email,
              password,
            });

          if (signUpError) {
            throw new Error(signUpError.message);
          }

          return signUpData.user;
        }

        return data.user;
      },
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    KakaoProvider({
      clientId: process.env.KAKAO_CLIENT_ID!,
      clientSecret: process.env.KAKAO_CLIENT_SECRET!,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  session: {
    strategy: "jwt",
  },
  callbacks: {
    async session({ session, token }: { session: Session; token: JWT }) {
      if (session.user) {
        session.user.id = token.sub ?? null; 
      }
      return session;
    },
    async signIn({ user, account, profile }) {
      const { data, error } = await supabaseAdmin.auth.signUp({
        email: user.email!,
        password: Math.random().toString(36).substring(2),
      });

      if (error) {
        console.error("Error creating user in Supabase:", error.message);
        return false;
      }

      return true;
    },
  },
  pages: {
    signIn: "/auth/signin",
  },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

 

근데 이렇게 하니, 소셜로그인을 완료하면 supabase 내의 auth 스키마의 users 테이블에 유저 정보가 저장이 된다.

이 유저 정보를 가져와서 이용할 수도 있지만, 보안을 위해 권장하지 않기 때문에, public 스키마에 users 테이블을 만들어서 auth.users와 연동하여 사용하려고 한다.

 

public.users 테이블을 만들고, id는 auth.users의 id과 외래키 설정을 했다.

-- 무작위 닉네임 생성 함수
create or replace function public.generate_random_nickname()
returns text as $$
declare
  new_nickname text;
begin
  -- 무작위 닉네임 생성 (여기서는 단순히 랜덤 문자열을 사용)
  new_nickname := 'user_' || substr(md5(random()::text), 1, 8);

  -- 닉네임이 중복되지 않는지 확인
  while exists (select 1 from public.users where nickname = new_nickname) loop
    new_nickname := 'user_' || substr(md5(random()::text), 1, 8);
  end loop;

  return new_nickname;
end;
$$ language plpgsql;

create or replace function public.handle_new_user() 
returns trigger as $$
declare
  final_nickname text;
  birth_value text;
  gender_value text;
  profile_url_value text;
  updated_raw_user_meta_data jsonb;
begin
  -- 1️⃣ `public` 스키마의 `users` 테이블에 데이터 추가
  -- 🏷️ A. generate_random_nickname 함수를 이용하여 무작위 닉네임 생성
  if new.raw_user_meta_data->>'nickname' is null then
    final_nickname := public.generate_random_nickname();
  else
    final_nickname := new.raw_user_meta_data->>'nickname';
  end if;

  -- 생년월일, 성별, 프로필 URL 값 설정
  birth_value := new.raw_user_meta_data->>'birth';
  gender_value := new.raw_user_meta_data->>'gender'; 
  profile_url_value := new.raw_user_meta_data->>'profileUrl';

  -- public.users 테이블에 데이터 삽입
  insert into public.users (
    id, email, nickname, birth, gender, profile_url
  )
  values (
    new.id,
    new.email,
    final_nickname,
    birth_value::date,
    gender_value,
    profile_url_value
  );

  -- 2️⃣ 소셜 로그인을 진행할 경우 상기 코드에서 무작위로 생성한 닉네임을 metadata에 업데이트 하기 위함
  -- 🏷️ C. jsonb_build_object
  updated_raw_user_meta_data := jsonb_build_object(
  'nickname', final_nickname,
  'birth', birth_value,
  'gender', gender_value,
  'profileUrl', profile_url_value
  );

  update auth.users
  set 
    raw_user_meta_data = updated_raw_user_meta_data
  where id = new.id;

  return new;
end;
$$ language plpgsql security definer;

-- 트리거 정의
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

 

이런 SQL문을 supabase 내의 SQL Editor에 넣고 Run을 해준다.

그리고 나서 로그인을 할 때, auth 스키마가 아닌, public.users 테이블로 유저 정보를 확인 하도록 [...nextauth]/route.ts를 수정해준다.

callbacks: {
    async session({ session, token }: { session: Session; token: JWT }) {
      if (session.user) {
        session.user.id = token.sub ?? null;
      }
      return session;
    },
    async signIn({ user, account, profile }) {
      try {
        if (user.email) {
          // Supabase SQL 쿼리를 사용하여 이메일로 사용자 검색
          const { data: existingUser, error: getUserError } =
            await supabaseAdmin
              .from("users")
              .select("*")
              .eq("email", user.email)
              .single();

          if (getUserError && getUserError.code !== "PGRST116") {
            // PGRST116은 사용자가 없는 경우 발생
            console.error(
              "이용자 fetching 오류 :",
              getUserError.message
            );
            return false;
          }

          // 사용자가 이미 존재하면 새로운 사용자 생성 불필요
          if (existingUser) {
            return true;
          }

          // 새로운 사용자 생성
          const { data, error: signUpError } = await supabaseAdmin.auth.signUp({
            email: user.email!,
            password: Math.random().toString(36).substring(2),
          });

          if (signUpError) {
            console.error(
              "유저 생성 오류 :",
              signUpError.message
            );
            return false;
          }

          return true;
        }
        return false;
      } catch (error) {
        console.error("로그인중 에러발생 : ", error);
        return false;
      }
    },
  },

 

그리고 로그인을 시도해 본다.

 

로그인이 된 것을 보여주는 Home 화면은 이처럼 구성했다.

// src/app/page.tsx


"use client";

import { useEffect } from "react";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import Image from "next/image";

export default function HomePage() {
  const { data: session, status } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (status === "unauthenticated") {
      router.push("/signin");
    }
  }, [status, router]);

  console.log(session);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-blue-500 to-purple-600">
      <div className="bg-white shadow-lg rounded-lg p-10 max-w-md w-full text-center">
        {session?.user?.image && (
          <Image
            width={100}
            height={100}
            src={session.user.image}
            alt={`${session.user.name}'s profile picture`}
            className="w-24 h-24 rounded-full mx-auto mb-4"
          />
        )}
        <h1 className="text-3xl font-bold text-gray-800 mb-2">
          Welcome,{" "}
          <span className="text-indigo-600">{session?.user?.name}</span>
        </h1>
        <p className="text-xl text-gray-600 mb-6">{session?.user?.email}</p>
        <p className="text-gray-600 mb-6">환영합니다!</p>
        <button
          onClick={() => signOut()}
          className="px-6 py-3 bg-indigo-600 text-white font-semibold rounded-md shadow-md hover:bg-indigo-700 transition-all duration-300 ease-in-out"
        >
          Sign Out
        </button>
      </div>
    </div>
  );
}

 

 

근데 프로필이미지를 불러오면서

라는 에러가 발생했다.

 

이는 Next.js가 외부 이미지를 URL로 가져와 사용할 때, 발생하는 에러다.

이를 해결하기 위해서는 next.config.js에 이미지의 domains를 설정하면 해결된다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
  // 이곳에 에러에서 hostname 다음 따옴표에 오는 링크를 적으면 된다.
    domains: ["lh3.googleusercontent.com", "img1.kakaocdn.net"], 
  },
};

export default nextConfig;

 

난 구글 로그인 뿐만 아니라 카카오 로그인을 사용하므로, 카카오 이미지 URL도 포함하였다.

 

이제 로그인을 시도하면..

유저의 이름, 이메일, 프로필사진이 나오는걸 확인할 수 있다.
session은 email, id, image, name과 token의 만료시간이 넘어왔다.


카카오 로그인도 설정하기 | Kakao Developers 문서의 과정을 통해 구현했다.