feat: 구글 소셜로그인 백엔드 연결

This commit is contained in:
2026-02-26 14:40:37 +09:00
parent a796a9cffb
commit d56303cec4
5 changed files with 85 additions and 63 deletions

View File

@@ -1,5 +1,5 @@
import { apiClient } from '@/shared/lib/apiClient'; import { apiClient } from "@/shared/lib/apiClient";
import { SocialLoginRequest, AuthResponse } from '../types'; import { AuthResponse, SocialLoginRequest } from "../types";
export const authApi = { export const authApi = {
/** /**
@@ -7,11 +7,11 @@ export const authApi = {
* @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token * @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token
*/ */
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => { loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
return apiClient<AuthResponse>('/auth/social', { return apiClient<AuthResponse>("api/v1/auth/social", {
method: 'POST', method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
}, },
// TODO: 이후 필요 시 logout, refreshAccessToken 등 인증 관련 API 추가 // TODO: 이후 필요 시 logout, refreshAccessToken 등 인증 관련 API 추가
}; };

View File

@@ -1,39 +1,59 @@
'use client'; "use client";
import React from 'react'; import { GoogleOAuthProvider } from "@react-oauth/google";
import { useSocialLogin } from '../hooks/useSocialLogin'; import { useSocialLogin } from "../hooks/useSocialLogin";
import { GoogleOAuthProvider } from '@react-oauth/google';
import FacebookLogin from 'react-facebook-login/dist/facebook-login-render-props';
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''; const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || ''; const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
/** /**
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다. * 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
* 이 컴포넌트는 반드시 GoogleOAuthProvider의 자식으로 존재해야 합니다. * 이 컴포넌트는 반드시 GoogleOAuthProvider의 자식으로 존재해야 합니다.
*/ */
const SocialLoginButtons = () => { const SocialLoginButtons = () => {
const { loginWithGoogle, loginWithApple, handleFacebookCallback, isLoading, error } = useSocialLogin(); const {
loginWithGoogle,
loginWithApple,
handleFacebookCallback,
isLoading,
error,
} = useSocialLogin();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 1. Google 로그인 (useGoogleLogin 훅 연동) */} {/* 1. Google 로그인 (useGoogleLogin 훅 연동) */}
<button <button
onClick={() => loginWithGoogle()} onClick={() => loginWithGoogle()}
disabled={isLoading} disabled={isLoading}
className="w-full flex items-center justify-center gap-3 bg-white border border-brand-dark/20 text-brand-dark px-6 py-3.5 rounded-xl font-bold hover:bg-slate-50 transition-colors shadow-sm disabled:opacity-50" className="w-full flex items-center justify-center gap-3 bg-white border border-brand-dark/20 text-brand-dark px-6 py-3.5 rounded-xl font-bold hover:bg-slate-50 transition-colors shadow-sm disabled:opacity-50"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/> className="w-5 h-5"
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> viewBox="0 0 24 24"
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> xmlns="http://www.w3.org/2000/svg"
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> >
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg> </svg>
{isLoading ? '연결 중...' : 'Google로 계속하기'} {isLoading ? "연결 중..." : "Google로 계속하기"}
</button> </button>
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */} {/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}
<button {/* <button
onClick={loginWithApple} onClick={loginWithApple}
disabled={isLoading} disabled={isLoading}
className="w-full flex items-center justify-center gap-3 bg-[#111111] text-white px-6 py-3.5 rounded-xl font-bold hover:bg-black transition-colors shadow-sm disabled:opacity-50" className="w-full flex items-center justify-center gap-3 bg-[#111111] text-white px-6 py-3.5 rounded-xl font-bold hover:bg-black transition-colors shadow-sm disabled:opacity-50"
@@ -42,10 +62,10 @@ const SocialLoginButtons = () => {
<path d="M16.365 21.438c-1.562 1.085-3.235 1.127-4.706.033-1.524-1.128-3.085-1.1-4.733.053-1.636 1.144-2.887 1.026-4.144-.393C1.516 19.882 0 16.516 0 12.872 0 8.783 2.502 6.075 5.753 5.92c1.46-.07 3.04.945 4.02.945 1.012 0 2.895-1.226 4.67-1.042 1.488.082 2.883.615 3.82 1.635-3.322 1.956-2.775 6.645.545 8.01-1.002 2.56-2.316 4.965-4.443 5.97zM11.95 5.567c-.204-2.618 1.914-5.004 4.544-5.26.335 2.723-2.074 5.213-4.544 5.26z"/> <path d="M16.365 21.438c-1.562 1.085-3.235 1.127-4.706.033-1.524-1.128-3.085-1.1-4.733.053-1.636 1.144-2.887 1.026-4.144-.393C1.516 19.882 0 16.516 0 12.872 0 8.783 2.502 6.075 5.753 5.92c1.46-.07 3.04.945 4.02.945 1.012 0 2.895-1.226 4.67-1.042 1.488.082 2.883.615 3.82 1.635-3.322 1.956-2.775 6.645.545 8.01-1.002 2.56-2.316 4.965-4.443 5.97zM11.95 5.567c-.204-2.618 1.914-5.004 4.544-5.26.335 2.723-2.074 5.213-4.544 5.26z"/>
</svg> </svg>
{isLoading ? '연결 중...' : 'Apple로 계속하기'} {isLoading ? '연결 중...' : 'Apple로 계속하기'}
</button> </button> */}
{/* 3. Facebook 로그인 (react-facebook-login Render Props 연동) */} {/* 3. Facebook 로그인 (react-facebook-login Render Props 연동) */}
<FacebookLogin {/* <FacebookLogin
appId={FACEBOOK_APP_ID} appId={FACEBOOK_APP_ID}
callback={handleFacebookCallback} callback={handleFacebookCallback}
render={(renderProps: any) => ( render={(renderProps: any) => (
@@ -60,7 +80,7 @@ const SocialLoginButtons = () => {
{isLoading ? '연결 중...' : 'Facebook으로 계속하기'} {isLoading ? '연결 중...' : 'Facebook으로 계속하기'}
</button> </button>
)} )}
/> /> */}
{/* 백엔드 연동 에러 메시지 표시 */} {/* 백엔드 연동 에러 메시지 표시 */}
{error && ( {error && (

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'; import { useAuthStore } from "@/store/useAuthStore";
import { useRouter } from 'next/navigation'; import { useGoogleLogin } from "@react-oauth/google";
import { authApi } from '../api/authApi'; import { useRouter } from "next/navigation";
import { useGoogleLogin } from '@react-oauth/google'; import { useState } from "react";
import appleAuthHelpers from 'react-apple-signin-auth'; import appleAuthHelpers from "react-apple-signin-auth";
import { useAuthStore } from '@/store/useAuthStore'; import { authApi } from "../api/authApi";
export const useSocialLogin = () => { export const useSocialLogin = () => {
const router = useRouter(); const router = useRouter();
@@ -14,24 +14,31 @@ export const useSocialLogin = () => {
* [비즈니스 로직 1] 공통 토큰 교환 로직 * [비즈니스 로직 1] 공통 토큰 교환 로직
* 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다. * 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다.
*/ */
const handleSocialLogin = async (provider: 'google' | 'apple' | 'facebook', socialToken: string) => { const handleSocialLogin = async (
provider: "google" | "apple" | "facebook",
socialToken: string,
) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
console.log("token:" + socialToken);
try { try {
// 1. 백엔드로 소셜 토큰 전송 (토큰 교환) // 1. 백엔드로 소셜 토큰 전송 (토큰 교환)
const response = await authApi.loginWithSocial({ provider, token: socialToken }); const response = await authApi.loginWithSocial({
provider,
token: socialToken,
});
console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken); console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken);
// 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장 // 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장
useAuthStore.getState().setAuth(response); useAuthStore.getState().setAuth(response);
// 3. 메인 대시보드 화면으로 이동 // 3. 메인 대시보드 화면으로 이동
router.push('/dashboard'); router.push("/dashboard");
} catch (err) { } catch (err) {
console.error(`[${provider}] 로그인 실패:`, err); console.error(`[${provider}] 로그인 실패:`, err);
setError('로그인에 실패했습니다. 다시 시도해 주세요.'); setError("로그인에 실패했습니다. 다시 시도해 주세요.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -43,10 +50,10 @@ export const useSocialLogin = () => {
*/ */
const loginWithGoogle = useGoogleLogin({ const loginWithGoogle = useGoogleLogin({
onSuccess: (tokenResponse) => { onSuccess: (tokenResponse) => {
handleSocialLogin('google', tokenResponse.access_token); handleSocialLogin("google", tokenResponse.access_token);
}, },
onError: () => { onError: () => {
setError('구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.'); setError("구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.");
}, },
}); });
@@ -58,24 +65,24 @@ export const useSocialLogin = () => {
try { try {
appleAuthHelpers.signIn({ appleAuthHelpers.signIn({
authOptions: { authOptions: {
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || '', clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || "",
scope: 'email name', scope: "email name",
redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요 redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요
usePopup: true, usePopup: true,
}, },
onSuccess: (response: any) => { onSuccess: (response: any) => {
if (response.authorization?.id_token) { if (response.authorization?.id_token) {
handleSocialLogin('apple', response.authorization.id_token); handleSocialLogin("apple", response.authorization.id_token);
} }
}, },
onError: (err: any) => { onError: (err: any) => {
console.error('Apple SignIn error:', err); console.error("Apple SignIn error:", err);
setError('애플 로그인 중 오류가 발생했습니다.'); setError("애플 로그인 중 오류가 발생했습니다.");
}, },
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError('애플 로그인 초기화 실패'); setError("애플 로그인 초기화 실패");
} }
}; };
@@ -85,9 +92,9 @@ export const useSocialLogin = () => {
*/ */
const handleFacebookCallback = (response: any) => { const handleFacebookCallback = (response: any) => {
if (response?.accessToken) { if (response?.accessToken) {
handleSocialLogin('facebook', response.accessToken); handleSocialLogin("facebook", response.accessToken);
} else { } else {
setError('페이스북 로그인에 실패했습니다.'); setError("페이스북 로그인에 실패했습니다.");
} }
}; };
@@ -98,4 +105,4 @@ export const useSocialLogin = () => {
isLoading, isLoading,
error, error,
}; };
}; };

View File

@@ -1,15 +1,9 @@
export interface SocialLoginRequest { export interface SocialLoginRequest {
provider: 'google' | 'apple' | 'facebook'; provider: "GOOGLE" | "apple" | "FACEBOOK";
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
} }
export interface AuthResponse { export interface AuthResponse {
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용) accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
refreshToken: string; // 토큰 갱신용 refreshToken: string; // 토큰 갱신용
user: { }
id: string;
email: string;
name: string;
profileImage?: string;
};
}

View File

@@ -1,22 +1,23 @@
/** /**
* VibeRoom 공통 API 클라이언트 * VibeRoom 공통 API 클라이언트
* 환경 변수(NEXT_PUBLIC_API_BASE_URL)를 기반으로 자동으로 Base URL이 설정됩니다. * 환경 변수(NEXT_PUBLIC_API_BASE_URL)를 기반으로 자동으로 Base URL이 설정됩니다.
* *
* - npm run dev 실행 시: https://api-dev.viberoom.io (from .env.development) * - npm run dev 실행 시: https://api-dev.viberoom.io (from .env.development)
* - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production) * - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production)
*/ */
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080";
export const apiClient = async <T>( export const apiClient = async <T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {},
): Promise<T> => { ): Promise<T> => {
// 엔드포인트 앞의 슬래시(/) 중복 방지 // 엔드포인트 앞의 슬래시(/) 중복 방지
const url = `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`; const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`;
const defaultHeaders = { const defaultHeaders = {
'Content-Type': 'application/json', "Content-Type": "application/json",
}; };
const response = await fetch(url, { const response = await fetch(url, {