From a796a9cffb8561da21ac2192199fa33d1fd01f66 Mon Sep 17 00:00:00 2001 From: corpi Date: Thu, 26 Feb 2026 12:41:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 131 ++++++++++++++++-- package.json | 13 +- .../auth/components/SocialLoginGroup.tsx | 63 ++++++--- src/features/auth/hooks/useSocialLogin.ts | 89 ++++++++---- src/store/useAuthStore.ts | 62 +++++++++ 5 files changed, 294 insertions(+), 64 deletions(-) create mode 100644 src/store/useAuthStore.ts diff --git a/package-lock.json b/package-lock.json index 35ccd15..9287e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,23 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@react-oauth/google": "^0.13.4", + "js-cookie": "^3.0.5", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-apple-signin-auth": "^1.1.2", + "react-dom": "19.2.3", + "react-facebook-login": "^4.1.1", + "zustand": "^5.0.11" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-facebook-login": "^4.1.11", + "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", @@ -67,7 +75,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -310,6 +317,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1227,6 +1241,16 @@ "node": ">=12.4.0" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", + "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1532,6 +1556,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1562,7 +1593,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1577,6 +1607,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-facebook-login": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@types/react-facebook-login/-/react-facebook-login-4.1.11.tgz", + "integrity": "sha512-7S/qzQMrS/zyJupX1RTiP9YV8qA0RyjS+up19G28XM6C9IN3NYfkrbKJEHOAfc79HdFYgpt9qQd1IHNZxuH+eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -1622,7 +1662,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2148,7 +2187,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2492,7 +2530,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2644,6 +2681,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3060,7 +3115,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3246,7 +3300,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4441,6 +4494,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5444,17 +5506,25 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-apple-signin-auth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/react-apple-signin-auth/-/react-apple-signin-auth-1.1.2.tgz", + "integrity": "sha512-E5bPu4LtNR3IDsd08A/f1Y0HyuHfjqQpRNRCtQQ3JSVby2JK50FoixyK8EwUh6cbu8N4qrJStL77dEb51Ny5uA==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5462,6 +5532,15 @@ "react": "^19.2.3" } }, + "node_modules/react-facebook-login": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/react-facebook-login/-/react-facebook-login-4.1.1.tgz", + "integrity": "sha512-COnHEHlYGTKipz4963safFAK9PaNTcCiXfPXMS/yxo8El+/AJL5ye8kMJf23lKSSGGPgqFQuInskIHVqGqTvSw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6143,7 +6222,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6306,7 +6384,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6582,7 +6659,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -6599,6 +6675,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index bd60b9d..a4889b8 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,27 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "local": "cross-env NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 next dev" }, "dependencies": { + "@react-oauth/google": "^0.13.4", + "js-cookie": "^3.0.5", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-apple-signin-auth": "^1.1.2", + "react-dom": "19.2.3", + "react-facebook-login": "^4.1.1", + "zustand": "^5.0.11" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-facebook-login": "^4.1.11", + "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", diff --git a/src/features/auth/components/SocialLoginGroup.tsx b/src/features/auth/components/SocialLoginGroup.tsx index eb0fad4..af3a303 100644 --- a/src/features/auth/components/SocialLoginGroup.tsx +++ b/src/features/auth/components/SocialLoginGroup.tsx @@ -1,22 +1,25 @@ -'use client'; // 클라이언트 사이드 이벤트(onClick) 및 훅(useRouter)을 사용하므로 필수 +'use client'; import React from 'react'; 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 FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || ''; /** - * 로그인 화면 UI (View) - * 비즈니스 로직(토큰 교환 등)을 전혀 모른 채, 오직 버튼만 그리고 이벤트를 훅(Hook)으로 위임합니다. + * 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다. + * 이 컴포넌트는 반드시 GoogleOAuthProvider의 자식으로 존재해야 합니다. */ -export const SocialLoginGroup = () => { - // 로직과 뷰의 완벽한 분리: useSocialLogin 커스텀 훅에서 필요한 함수만 꺼내옵니다. - const { loginWithGoogle, loginWithApple, loginWithFacebook, isLoading, error } = useSocialLogin(); +const SocialLoginButtons = () => { + const { loginWithGoogle, loginWithApple, handleFacebookCallback, isLoading, error } = useSocialLogin(); return (
- - {/* 1. Google 로그인 */} + {/* 1. Google 로그인 (useGoogleLogin 훅 연동) */} - {/* 2. Apple 로그인 */} + {/* 2. Apple 로그인 (react-apple-signin-auth 연동) */} - {/* 3. Facebook 로그인 */} - + {/* 3. Facebook 로그인 (react-facebook-login Render Props 연동) */} + ( + + )} + /> {/* 백엔드 연동 에러 메시지 표시 */} {error && ( @@ -62,3 +71,15 @@ export const SocialLoginGroup = () => {
); }; + +/** + * 로그인 화면 UI (View) + * GoogleOAuthProvider로 감싸서 내부에서 useGoogleLogin 훅을 사용할 수 있게 합니다. + */ +export const SocialLoginGroup = () => { + return ( + + + + ); +}; diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts index 95522b6..c957033 100644 --- a/src/features/auth/hooks/useSocialLogin.ts +++ b/src/features/auth/hooks/useSocialLogin.ts @@ -1,6 +1,9 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { authApi } from '../api/authApi'; +import { useGoogleLogin } from '@react-oauth/google'; +import appleAuthHelpers from 'react-apple-signin-auth'; +import { useAuthStore } from '@/store/useAuthStore'; export const useSocialLogin = () => { const router = useRouter(); @@ -8,7 +11,8 @@ export const useSocialLogin = () => { const [error, setError] = useState(null); /** - * [비즈니스 로직 1] 플랫폼별 소셜 SDK에서 받은 토큰을 백엔드로 보내어 VibeRoom JWT를 얻어오는 공통 함수 + * [비즈니스 로직 1] 공통 토큰 교환 로직 + * 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다. */ const handleSocialLogin = async (provider: 'google' | 'apple' | 'facebook', socialToken: string) => { setIsLoading(true); @@ -16,16 +20,14 @@ export const useSocialLogin = () => { try { // 1. 백엔드로 소셜 토큰 전송 (토큰 교환) - const response = await authApi.loginWithSocial({ - provider, - token: socialToken, - }); - - // 2. 응답받은 VibeRoom 전용 토큰을 로컬에 저장 (TODO: 실제로는 Zustand/Cookie에 저장) + const response = await authApi.loginWithSocial({ provider, token: socialToken }); + console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken); - // ex) useAuthStore.getState().setToken(response.accessToken); - - // 3. 성공 후 메인 대시보드 화면으로 이동 + + // 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장 + useAuthStore.getState().setAuth(response); + + // 3. 메인 대시보드 화면으로 이동 router.push('/dashboard'); } catch (err) { console.error(`[${provider}] 로그인 실패:`, err); @@ -36,32 +38,63 @@ export const useSocialLogin = () => { }; /** - * [비즈니스 로직 2] UI 컴포넌트(View)에서 호출할 개별 플랫폼 로그인 함수들 - * 향후 여기에 구글/애플/페이스북의 실제 프론트엔드 SDK 코드가 들어갑니다. + * [비즈니스 로직 2] Google Login + * @react-oauth/google 라이브러리의 훅을 사용하여 구글 로그인 팝업을 호출합니다. */ - const loginWithGoogle = async () => { - // TODO: Google Identity Services(GSI) SDK 호출 코드 작성 - const mockGoogleToken = 'mock_google_token_123'; - await handleSocialLogin('google', mockGoogleToken); + const loginWithGoogle = useGoogleLogin({ + onSuccess: (tokenResponse) => { + handleSocialLogin('google', tokenResponse.access_token); + }, + onError: () => { + setError('구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.'); + }, + }); + + /** + * [비즈니스 로직 3] Apple Login + * react-apple-signin-auth 라이브러리를 사용하여 애플 로그인을 팝업으로 호출합니다. + */ + const loginWithApple = () => { + try { + appleAuthHelpers.signIn({ + authOptions: { + clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || '', + scope: 'email name', + redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요 + usePopup: true, + }, + onSuccess: (response: any) => { + if (response.authorization?.id_token) { + handleSocialLogin('apple', response.authorization.id_token); + } + }, + onError: (err: any) => { + console.error('Apple SignIn error:', err); + setError('애플 로그인 중 오류가 발생했습니다.'); + }, + }); + } catch (err) { + console.error(err); + setError('애플 로그인 초기화 실패'); + } }; - const loginWithApple = async () => { - // TODO: Sign in with Apple JS 호출 코드 작성 - const mockAppleToken = 'mock_apple_token_456'; - await handleSocialLogin('apple', mockAppleToken); + /** + * [비즈니스 로직 4] Facebook Callback + * react-facebook-login 컴포넌트에서 콜백으로 받은 토큰을 처리합니다. + */ + const handleFacebookCallback = (response: any) => { + if (response?.accessToken) { + handleSocialLogin('facebook', response.accessToken); + } else { + setError('페이스북 로그인에 실패했습니다.'); + } }; - const loginWithFacebook = async () => { - // TODO: Facebook Login SDK 호출 코드 작성 - const mockFacebookToken = 'mock_facebook_token_789'; - await handleSocialLogin('facebook', mockFacebookToken); - }; - - // UI 컴포넌트에 노출할 상태와 함수들만 캡슐화하여 반환 return { loginWithGoogle, loginWithApple, - loginWithFacebook, + handleFacebookCallback, isLoading, error, }; diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts new file mode 100644 index 0000000..8d1393a --- /dev/null +++ b/src/store/useAuthStore.ts @@ -0,0 +1,62 @@ +import { create } from 'zustand'; +import Cookies from 'js-cookie'; +import { AuthResponse } from '@/features/auth/types'; + +interface AuthState { + accessToken: string | null; + user: AuthResponse['user'] | null; + isAuthenticated: boolean; + + // 액션 + setAuth: (data: AuthResponse) => void; + logout: () => void; +} + +// 쿠키 키 상수 정의 (Access Token 보관용) +const TOKEN_COOKIE_KEY = 'vr_access_token'; + +/** + * VibeRoom 전역 인증(Auth) 상태 저장소 + * + * Zustand를 활용하여 메모리에 상태를 쥐고 있으면서, + * 쿠키(js-cookie)를 통해 브라우저 종료 후에도 세션이 유지되도록 동기화합니다. + */ +export const useAuthStore = create((set) => { + // 브라우저 환경에서만 쿠키 접근 + const isClient = typeof window !== 'undefined'; + const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null; + + return { + accessToken: savedToken || null, + user: null, // 새로고침 시 이 토큰을 기반으로 유저 정보(me API)를 다시 가져와야 합니다(Hydrate). + isAuthenticated: !!savedToken, + + setAuth: (data: AuthResponse) => { + // 1. 브라우저 쿠키에 저장 (보안 옵션 설정) + Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, { + expires: 7, // 7일 후 만료 + secure: process.env.NODE_ENV === 'production', // HTTPS 환경에서만 전송 + sameSite: 'strict' // CSRF 공격 방어 + }); + + // 2. Zustand 메모리 상태 업데이트 (연결된 UI 전체 리렌더링) + set({ + accessToken: data.accessToken, + user: data.user, + isAuthenticated: true + }); + }, + + logout: () => { + // 1. 쿠키에서 토큰 삭제 + Cookies.remove(TOKEN_COOKIE_KEY); + + // 2. Zustand 상태 초기화 + set({ + accessToken: null, + user: null, + isAuthenticated: false + }); + }, + }; +});