개발자가 될래요
Next.js 소셜 로그인 구현 본문
소셜 로그인을 구현 해보려고 한다.(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와 연동하여 사용하려고 한다.

-- 무작위 닉네임 생성 함수
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도 포함하였다.
이제 로그인을 시도하면..


카카오 로그인도 설정하기 | Kakao Developers 문서의 과정을 통해 구현했다.
'프로젝트' 카테고리의 다른 글
| Riot API 사용해보기(1) (0) | 2024.09.01 |
|---|---|
| 자체 로그인에 프로필 이미지 추가 (0) | 2024.09.01 |
| Next + Socket.io 로 채팅 구현(6) (0) | 2024.08.30 |
| Next + Socket.io 로 채팅 구현(5) (0) | 2024.08.24 |
| Next + Socket.io 로 채팅 구현(4) (0) | 2024.08.21 |