중요: 이 문서의 모든 API 키와 시크릿 값들은 예시용 플레이스홀더입니다.
.env 파일)로 관리하세요graph TB
subgraph "프론트엔드 계층"
WEB[웹 브라우저]
MOBILE[모바일 앱]
ADMIN[관리자 페이지]
end
subgraph "소셜 플랫폼"
GOOGLE[Google OAuth 2.0]
KAKAO[카카오 로그인]
NAVER[네이버 로그인]
end
subgraph "AuthService (포트: 8082)"
CONTROLLER[SocialAuthController]
SERVICE[SocialAuthService]
GOOGLE_SVC[GoogleAuthService]
KAKAO_SVC[KakaoAuthService]
NAVER_SVC[NaverAuthService]
USER_SVC[SocialUserService]
end
subgraph "데이터 저장소"
MYSQL[(MySQL Database)]
REDIS[(Redis Cache)]
end
WEB --> CONTROLLER
MOBILE --> CONTROLLER
ADMIN --> CONTROLLER
CONTROLLER --> SERVICE
SERVICE --> GOOGLE_SVC
SERVICE --> KAKAO_SVC
SERVICE --> NAVER_SVC
SERVICE --> USER_SVC
GOOGLE_SVC --> GOOGLE
KAKAO_SVC --> KAKAO
NAVER_SVC --> NAVER
USER_SVC --> MYSQL
SERVICE --> REDIS
/api/auth/social/{provider} (GET/POST)파일: com.akc.b2c.authservice.controller.SocialAuthController
주요 엔드포인트:
@RestController
@RequestMapping("/api/auth/social")
public class SocialAuthController {
// 1. 통합 API 엔드포인트들
@PostMapping("/google") // Google ID Token 처리
@PostMapping("/kakao") // Kakao Access Token 처리
@PostMapping("/naver") // Naver Access Token 처리
@PostMapping("/signup") // 소셜 회원가입 통합 처리
// 2. 네이버 OAuth 플로우 (팝업 전용)
@GetMapping("/naver/login") // 네이버 인증 시작
@GetMapping("/naver/callback") // 네이버 콜백 처리
// 3. 헬스체크 & 메타데이터
@GetMapping("/providers") // 지원 플랫폼 목록
@GetMapping("/health") // 서비스 상태
}
의존성 주입:
private final SocialAuthService socialAuthService; // 통합 로직 처리
private final NaverAuthService naverAuthService; // 네이버 OAuth 전용
private final SocialUserService socialUserService; // DB 사용자 관리
private final JwtTokenProvider jwtTokenProvider; // JWT 토큰 생성
파일: com.akc.b2c.authservice.service.social.SocialAuthService
핵심 메서드:
public class SocialAuthService {
// 각 플랫폼별 로그인 처리 (동일한 구조)
public SocialLoginResponse googleLogin(GoogleLoginRequest request)
public SocialLoginResponse kakaoLogin(KakaoLoginRequest request)
public SocialLoginResponse naverLogin(NaverLoginRequest request)
// 소셜 회원가입 통합 처리
public SocialSignUpResponse socialSignUp(SocialSignUpRequest request)
}
공통 처리 로직:
SocialUserService.findOrCreateUserBySocial() 호출파일: com.akc.b2c.authservice.service.social.GoogleAuthService
특징:
핵심 메서드:
public class GoogleAuthService {
public SocialUserInfo getUserInfo(String idToken) {
// 1. 개발모드 또는 더미토큰 체크
if (devMode || isDummyToken(idToken)) {
return createDummyGoogleUserInfo(idToken);
}
// 2. 실제 Google API 토큰 검증
Map<String, Object> tokenInfo = verifyGoogleIdToken(idToken);
// 3. 사용자 정보 추출 및 변환
return extractUserInfoFromGoogle(tokenInfo);
}
}
파일: com.akc.b2c.authservice.service.social.KakaoAuthService
특징:
Authorization: Bearer {access_token}핵심 메서드:
public class KakaoAuthService {
public SocialUserInfo getUserInfo(String accessToken) {
// 1. 개발모드 체크
if (devMode) {
return createDevModeUserInfo();
}
// 2. 카카오 User Info API 호출
Map<String, Object> userInfo = callKakaoUserInfoApi(accessToken);
// 3. 카카오 특유의 중첩 구조 파싱
return parseKakaoUserInfo(userInfo);
}
private Map<String, Object> callKakaoUserInfoApi(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
// 카카오 API: https://kapi.kakao.com/v2/user/me
ResponseEntity<Map> response = restTemplate.exchange(kakaoUserInfoUrl, HttpMethod.GET, entity, Map.class);
}
}
파일: com.akc.b2c.authservice.service.social.NaverAuthService
특징:
핵심 메서드:
public class NaverAuthService {
// 1. OAuth 인증 URL 생성
public String buildAuthorizationUrl(String redirectUri) {
String state = UUID.randomUUID().toString();
return UriComponentsBuilder.fromHttpUrl(naverAuthUrl)
.queryParam("response_type", "code")
.queryParam("client_id", naverClientId)
.queryParam("redirect_uri", naverRedirectUri)
.queryParam("scope", "name email profile_image")
.queryParam("state", state)
.build().toUriString();
}
// 2. Authorization Code → Access Token 교환
public String getAccessToken(String authorizationCode, String state) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", naverClientId);
params.add("client_secret", naverClientSecret);
params.add("redirect_uri", naverRedirectUri);
params.add("code", authorizationCode);
params.add("state", state);
// 네이버 Token API: https://nid.naver.com/oauth2.0/token
ResponseEntity<Map> response = restTemplate.postForEntity(naverTokenUrl, params, Map.class);
}
// 3. Access Token으로 사용자 정보 조회
public SocialUserInfo getUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
// 네이버 User Info API: https://openapi.naver.com/v1/nid/me
ResponseEntity<Map> response = restTemplate.exchange(naverUserInfoUrl, HttpMethod.GET, entity, Map.class);
}
}
파일: com.akc.b2c.authservice.service.SocialUserService
3단계 자동 판단 로직:
@Transactional
public UserSocialResult findOrCreateUserBySocial(SocialUserInfo socialUserInfo) {
// 1단계: 소셜 ID로 기존 계정 찾기 (Provider + Social ID)
Optional<MemberSocialAccount> existingSocialAccount =
memberSocialAccountRepository.findByProviderAndSocialId(provider, socialUserInfo.getSocialId());
if (existingSocialAccount.isPresent()) {
// 🟢 기존 소셜 계정 → 즉시 로그인
return UserSocialResult.builder()
.member(socialAccount.getMember())
.isNewUser(false)
.isNewSocialAccount(false)
.build();
}
// 2단계: 이메일로 기존 사용자 찾기
Optional<Member> existingMember = memberRepository.findActiveUserByEmail(socialUserInfo.getEmail());
if (existingMember.isPresent()) {
// 🔗 기존 사용자에게 소셜 계정 연결
Member member = existingMember.get();
MemberSocialAccount newSocialAccount = createSocialAccount(member, socialUserInfo, provider);
member.addSocialAccount(newSocialAccount);
return UserSocialResult.builder()
.member(member)
.isNewUser(false)
.isNewSocialAccount(true)
.build();
}
// 3단계: 완전히 새로운 사용자 생성
Member newMember = createMember(socialUserInfo);
Member savedMember = memberRepository.save(newMember);
MemberSocialAccount newSocialAccount = createSocialAccount(savedMember, socialUserInfo, provider);
return UserSocialResult.builder()
.member(newMember)
.isNewUser(true)
.isNewSocialAccount(true)
.build();
}
# TypeScript를 사용하는 경우
npm install --save-dev @types/google.accounts
# 상태 관리 (선택사항)
npm install zustand # 또는 redux-toolkit
# .env.local (Next.js) 또는 .env (React)
NEXT_PUBLIC_GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com
NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY=YOUR_KAKAO_JAVASCRIPT_KEY
NEXT_PUBLIC_AUTH_SERVICE_URL=http://localhost:8082
NEXT_PUBLIC_ENVIRONMENT=development # development | production
// types/social-login.ts
export interface SocialLoginRequest {
idToken?: string;
accessToken?: string;
deviceInfo: {
platform: 'WEB_BROWSER' | 'MOBILE_APP' | 'ADMIN_WEB';
deviceId: string;
appVersion: string;
};
}
export interface SocialLoginResponse {
success: boolean;
accessToken?: string;
refreshToken?: string;
tokenType?: string;
expiresIn?: number;
userInfo?: UserInfo;
needsAdditionalInfo?: boolean;
needsUsernameInput?: boolean;
needsAccountLink?: boolean;
message: string;
error?: string;
}
export interface UserInfo {
id: string;
email: string;
name: string;
profileImageUrl?: string;
role: string;
isNewUser: boolean;
}
// Global type definitions for Google GSI
declare global {
interface Window {
google: {
accounts: {
id: {
initialize: (config: GoogleConfig) => void;
prompt: (callback?: (notification: any) => void) => void;
renderButton: (element: HTMLElement, config: GoogleButtonConfig) => void;
disableAutoSelect: () => void;
cancel: () => void;
};
};
};
Kakao: {
init: (key: string) => void;
isInitialized: () => boolean;
Auth: {
login: (config: KakaoLoginConfig) => void;
logout: (callback?: () => void) => void;
getAccessToken: () => string;
};
};
}
}
// components/SocialLoginProvider.tsx
import Script from 'next/script';
import { useEffect, useState } from 'react';
export const SocialLoginProvider = ({ children }: { children: React.ReactNode }) => {
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
const [isKakaoLoaded, setIsKakaoLoaded] = useState(false);
return (
<>
{/* Google GSI SDK */}
<Script
src="https://accounts.google.com/gsi/client"
strategy="afterInteractive"
onLoad={() => {
console.log('Google GSI SDK 로드 완료');
setIsGoogleLoaded(true);
}}
onError={() => {
console.error('Google GSI SDK 로드 실패');
}}
/>
{/* Kakao JavaScript SDK */}
<Script
src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.5/kakao.min.js"
strategy="afterInteractive"
onLoad={() => {
console.log('Kakao SDK 로드 완료');
setIsKakaoLoaded(true);
// Kakao SDK 초기화
if (window.Kakao && !window.Kakao.isInitialized()) {
window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY);
}
}}
onError={() => {
console.error('Kakao SDK 로드 실패');
}}
/>
{children}
</>
);
};
// hooks/useSocialSDK.ts
import { useState, useEffect } from 'react';
export const useSocialSDK = () => {
const [sdkStatus, setSdkStatus] = useState({
google: { loaded: false, initialized: false },
kakao: { loaded: false, initialized: false }
});
useEffect(() => {
// Google SDK 상태 체크
const checkGoogleSDK = () => {
if (typeof window !== 'undefined' && window.google?.accounts?.id) {
setSdkStatus(prev => ({
...prev,
google: { loaded: true, initialized: true }
}));
}
};
// Kakao SDK 상태 체크
const checkKakaoSDK = () => {
if (typeof window !== 'undefined' && window.Kakao?.isInitialized()) {
setSdkStatus(prev => ({
...prev,
kakao: { loaded: true, initialized: true }
}));
}
};
const interval = setInterval(() => {
checkGoogleSDK();
checkKakaoSDK();
}, 100);
// 5초 후 체크 중단
setTimeout(() => clearInterval(interval), 5000);
return () => clearInterval(interval);
}, []);
return sdkStatus;
};
// hooks/useGoogleLogin.ts
import { useCallback, useEffect, useState } from 'react';
import { SocialLoginResponse } from '../types/social-login';
export const useGoogleLogin = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Google GSI 초기화
const initializeGoogleLogin = useCallback(() => {
if (!window.google?.accounts?.id) {
setError('Google SDK가 로드되지 않았습니다.');
return false;
}
try {
window.google.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
use_fedcm_for_prompt: false // FedCM 비활성화로 팝업 보장
});
return true;
} catch (error) {
setError('Google 초기화 실패');
console.error('Google 초기화 오류:', error);
return false;
}
}, []);
// Google 콜백 처리
const handleCredentialResponse = useCallback(async (response: any) => {
setIsLoading(true);
setError(null);
try {
const deviceId = localStorage.getItem('deviceId') || generateDeviceId();
const result = await fetch(`${process.env.NEXT_PUBLIC_AUTH_SERVICE_URL}/api/auth/social/google`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include', // CORS 쿠키 허용
body: JSON.stringify({
idToken: response.credential,
deviceInfo: {
platform: 'WEB_BROWSER',
deviceId: deviceId,
appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0'
}
})
});
if (!result.ok) {
throw new Error(`HTTP ${result.status}: ${result.statusText}`);
}
const data: SocialLoginResponse = await result.json();
if (data.success) {
handleSocialLoginSuccess(data, 'Google');
} else {
throw new Error(data.error || 'Google 로그인 실패');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
setError(errorMessage);
console.error('Google 로그인 오류:', error);
} finally {
setIsLoading(false);
}
}, []);
// 로그인 버튼 렌더링
const renderGoogleButton = useCallback((elementId: string, config?: Partial<GoogleButtonConfig>) => {
const element = document.getElementById(elementId);
if (!element || !window.google?.accounts?.id) return;
const defaultConfig = {
theme: 'outline' as const,
size: 'large' as const,
type: 'standard' as const,
text: 'signin_with' as const,
locale: 'ko',
width: 300
};
window.google.accounts.id.renderButton(element, { ...defaultConfig, ...config });
}, []);
return {
isLoading,
error,
initializeGoogleLogin,
renderGoogleButton
};
};
// 유틸리티 함수
const generateDeviceId = (): string => {
const id = `web-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('deviceId', id);
return id;
};
// hooks/useKakaoLogin.ts
import { useCallback, useState } from 'react';
export const useKakaoLogin = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 카카오 로그인 실행
const loginWithKakao = useCallback(async () => {
if (!window.Kakao?.Auth) {
setError('카카오 SDK가 로드되지 않았습니다.');
return;
}
setIsLoading(true);
setError(null);
try {
// 카카오 로그인 팝업
window.Kakao.Auth.login({
success: async (kakaoResponse: any) => {
try {
const deviceId = localStorage.getItem('deviceId') || generateDeviceId();
const result = await fetch(`${process.env.NEXT_PUBLIC_AUTH_SERVICE_URL}/api/auth/social/kakao`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
accessToken: kakaoResponse.access_token,
deviceInfo: {
platform: 'WEB_BROWSER',
deviceId: deviceId,
appVersion: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0'
}
})
});
if (!result.ok) {
throw new Error(`HTTP ${result.status}: ${result.statusText}`);
}
const data: SocialLoginResponse = await result.json();
if (data.success) {
handleSocialLoginSuccess(data, 'Kakao');
} else {
throw new Error(data.error || '카카오 로그인 실패');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
setError(errorMessage);
console.error('카카오 백엔드 통신 오류:', error);
} finally {
setIsLoading(false);
}
},
fail: (kakaoError: any) => {
setIsLoading(false);
setError('카카오 인증 실패');
console.error('카카오 로그인 실패:', kakaoError);
}
});
} catch (error) {
setIsLoading(false);
setError('카카오 로그인 초기화 실패');
console.error('카카오 로그인 오류:', error);
}
}, []);
return {
isLoading,
error,
loginWithKakao
};
};
// hooks/useNaverLogin.ts
import { useCallback, useEffect, useState } from 'react';
export const useNaverLogin = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 네이버 로그인 팝업 실행
const loginWithNaver = useCallback(() => {
setIsLoading(true);
setError(null);
try {
const popup = window.open(
`${process.env.NEXT_PUBLIC_AUTH_SERVICE_URL}/api/auth/social/naver/login`,
'naverLogin',
'width=500,height=600,scrollbars=yes,resizable=yes,top=50,left=50'
);
if (!popup) {
throw new Error('팝업이 차단되었습니다. 브라우저의 팝업 설정을 확인해주세요.');
}
// 팝업 창 닫힘 감지
const popupTimer = setInterval(() => {
if (popup.closed) {
clearInterval(popupTimer);
setIsLoading(false);
}
}, 1000);
// 30초 후 자동 정리
setTimeout(() => {
if (!popup.closed) {
popup.close();
clearInterval(popupTimer);
setIsLoading(false);
setError('로그인 시간이 초과되었습니다.');
}
}, 30000);
} catch (error) {
setIsLoading(false);
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
setError(errorMessage);
console.error('네이버 로그인 오류:', error);
}
}, []);
// 팝업 메시지 리스너 설정
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
// 보안: AuthService 도메인만 허용
if (event.origin !== process.env.NEXT_PUBLIC_AUTH_SERVICE_URL) {
console.warn('허용되지 않은 도메인에서 메시지:', event.origin);
return;
}
const { type, result, error } = event.data;
switch (type) {
case 'NAVER_LOGIN_SUCCESS':
console.log('네이버 로그인 성공:', result);
handleSocialLoginSuccess(result, 'Naver');
setIsLoading(false);
break;
case 'NAVER_SIGNUP_NEEDED':
console.log('네이버 회원가입 필요:', result);
handleNaverSignupNeeded(result);
setIsLoading(false);
break;
case 'NAVER_LOGIN_ERROR':
console.error('네이버 로그인 오류:', error);
setError(error?.message || '네이버 로그인 중 오류가 발생했습니다.');
setIsLoading(false);
break;
default:
console.log('알 수 없는 메시지 타입:', type);
}
};
window.addEventListener('message', handlePopupMessage);
return () => window.removeEventListener('message', handlePopupMessage);
}, []);
return {
isLoading,
error,
loginWithNaver
};
};
// 네이버 회원가입 필요 처리
const handleNaverSignupNeeded = (result: any) => {
const { needsUsernameInput, needsAdditionalInfo, tempUserInfo } = result;
// 회원가입 모달 표시 또는 페이지 이동
// 상태 관리 라이브러리를 통해 전역 상태 업데이트
console.log('추가 정보 입력 필요:', {
needsUsernameInput,
needsAdditionalInfo,
tempUserInfo
});
};
// components/SocialLoginButton.tsx
import React, { useState } from 'react';
import { Loader, CheckCircle, AlertCircle } from 'lucide-react';
interface SocialLoginButtonProps {
provider: 'google' | 'kakao' | 'naver';
onLogin: () => Promise<void>;
disabled?: boolean;
}
export const SocialLoginButton: React.FC<SocialLoginButtonProps> = ({
provider,
onLogin,
disabled = false
}) => {
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const handleClick = async () => {
if (isLoading || disabled) return;
setIsLoading(true);
setStatus('loading');
try {
await onLogin();
setStatus('success');
// 성공 상태를 잠시 보여준 후 초기화
setTimeout(() => {
setStatus('idle');
setIsLoading(false);
}, 2000);
} catch (error) {
setStatus('error');
setTimeout(() => {
setStatus('idle');
setIsLoading(false);
}, 3000);
}
};
const getButtonContent = () => {
switch (status) {
case 'loading':
return (
<>
<Loader className="w-4 h-4 animate-spin" />
<span>{provider.toUpperCase()} 로그인 중...</span>
</>
);
case 'success':
return (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span>로그인 성공!</span>
</>
);
case 'error':
return (
<>
<AlertCircle className="w-4 h-4 text-red-500" />
<span>다시 시도해주세요</span>
</>
);
default:
return (
<>
<img src={`/icons/${provider}.svg`} alt={provider} className="w-4 h-4" />
<span>{provider.toUpperCase()}로 시작하기</span>
</>
);
}
};
return (
<button
onClick={handleClick}
disabled={isLoading || disabled}
className={`
flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg border
transition-all duration-200 font-medium
${status === 'error' ? 'border-red-300 bg-red-50 text-red-700' :
status === 'success' ? 'border-green-300 bg-green-50 text-green-700' :
'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'}
${(isLoading || disabled) && 'cursor-not-allowed opacity-60'}
`}
>
{getButtonContent()}
</button>
);
};
// hooks/useErrorHandler.ts
import { useState } from 'react';
interface ErrorInfo {
code?: string;
message: string;
provider?: string;
retry?: () => void;
}
export const useErrorHandler = () => {
const [error, setError] = useState<ErrorInfo | null>(null);
const handleSocialLoginError = (error: any, provider: string, retryFn?: () => void) => {
let errorInfo: ErrorInfo;
if (error.message?.includes('팝업이 차단')) {
errorInfo = {
code: 'POPUP_BLOCKED',
message: '팝업이 차단되었습니다. 브라우저 설정을 확인하고 다시 시도해주세요.',
provider,
retry: retryFn
};
} else if (error.message?.includes('HTTP 403')) {
errorInfo = {
code: 'FORBIDDEN',
message: '로그인 권한이 없습니다. 관리자에게 문의해주세요.',
provider
};
} else if (error.message?.includes('HTTP 500')) {
errorInfo = {
code: 'SERVER_ERROR',
message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
provider,
retry: retryFn
};
} else if (error.message?.includes('Network')) {
errorInfo = {
code: 'NETWORK_ERROR',
message: '네트워크 연결을 확인해주세요.',
provider,
retry: retryFn
};
} else {
errorInfo = {
code: 'UNKNOWN',
message: error.message || '알 수 없는 오류가 발생했습니다.',
provider,
retry: retryFn
};
}
setError(errorInfo);
// 에러 로깅 (선택사항)
console.error(`${provider} 소셜 로그인 오류:`, error);
// 에러 분석을 위한 로그 전송 (선택사항)
if (process.env.NODE_ENV === 'production') {
// sendErrorLog(errorInfo);
}
};
const clearError = () => setError(null);
return {
error,
handleSocialLoginError,
clearError
};
};
// utils/socialLoginHandler.ts
import { SocialLoginResponse } from '../types/social-login';
// 토큰 저장 및 관리
export const handleSocialLoginSuccess = async (
response: SocialLoginResponse,
provider: string
) => {
try {
// JWT 토큰 저장 (httpOnly 쿠키 권장, 여기서는 localStorage 예시)
if (response.accessToken) {
localStorage.setItem('accessToken', response.accessToken);
}
if (response.refreshToken) {
localStorage.setItem('refreshToken', response.refreshToken);
}
// 사용자 정보 저장
if (response.userInfo) {
localStorage.setItem('userInfo', JSON.stringify(response.userInfo));
}
// 추가 정보 입력이 필요한 경우
if (response.needsAdditionalInfo || response.needsUsernameInput) {
// 회원가입 폼으로 이동하거나 모달 표시
showSignUpModal(provider, response);
return;
}
// 로그인 성공 알림
showSuccessNotification(`${provider} 로그인이 완료되었습니다!`);
// 홈 페이지로 이동 또는 이전 페이지로 복귀
const returnUrl = localStorage.getItem('returnUrl') || '/dashboard';
window.location.href = returnUrl;
} catch (error) {
console.error('로그인 성공 처리 중 오류:', error);
throw new Error('로그인 처리 중 오류가 발생했습니다.');
}
};
// 회원가입 모달 표시
const showSignUpModal = (provider: string, response: SocialLoginResponse) => {
// 상태 관리 라이브러리를 사용하여 모달 상태 업데이트
// 예: Zustand, Redux Toolkit 등
const signUpData = {
provider,
socialToken: response.accessToken, // 또는 임시 토큰
userInfo: response.userInfo,
requirements: {
needsUsernameInput: response.needsUsernameInput,
needsAdditionalInfo: response.needsAdditionalInfo,
needsAccountLink: response.needsAccountLink
}
};
// 전역 상태 업데이트
// useSignUpStore.getState().setSignUpData(signUpData);
// useUIStore.getState().setModalOpen('signup');
};
// 성공 알림 표시
const showSuccessNotification = (message: string) => {
// 토스트 알림 라이브러리 사용 (예: react-hot-toast, sonner 등)
// toast.success(message);
// 또는 브라우저 기본 알림
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('로그인 성공', { body: message });
}
};
// components/SocialSignUpForm.tsx
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signUpSchema = z.object({
userId: z.string()
.min(4, '아이디는 4자 이상이어야 합니다.')
.max(20, '아이디는 20자 이하여야 합니다.')
.regex(/^[a-zA-Z0-9_]+$/, '아이디는 영문, 숫자, 밑줄만 사용 가능합니다.'),
phone: z.string()
.regex(/^010-\d{4}-\d{4}$/, '휴대폰 번호 형식이 올바르지 않습니다.'),
termsAgreed: z.boolean().refine(val => val, '이용약관에 동의해주세요.'),
privacyAgreed: z.boolean().refine(val => val, '개인정보 처리방침에 동의해주세요.'),
marketingAgreed: z.boolean().optional()
});
type SignUpFormData = z.infer<typeof signUpSchema>;
interface SocialSignUpFormProps {
provider: string;
socialToken: string;
userInfo: any;
requirements: {
needsUsernameInput: boolean;
needsAdditionalInfo: boolean;
};
onSubmit: (data: SignUpFormData) => Promise<void>;
onCancel: () => void;
}
export const SocialSignUpForm: React.FC<SocialSignUpFormProps> = ({
provider,
userInfo,
requirements,
onSubmit,
onCancel
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [userIdAvailable, setUserIdAvailable] = useState<boolean | null>(null);
const { register, handleSubmit, watch, formState: { errors } } = useForm<SignUpFormData>({
resolver: zodResolver(signUpSchema),
defaultValues: {
marketingAgreed: false
}
});
const userId = watch('userId');
// 아이디 중복 확인
const checkUserIdAvailability = async (userId: string) => {
if (!userId || userId.length < 4) return;
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_AUTH_SERVICE_URL}/api/auth/check-userid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
const data = await response.json();
setUserIdAvailable(data.available);
} catch (error) {
console.error('아이디 중복 확인 오류:', error);
}
};
const handleFormSubmit = async (data: SignUpFormData) => {
setIsSubmitting(true);
try {
await onSubmit(data);
} catch (error) {
console.error('회원가입 오류:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4">
{provider.toUpperCase()} 회원가입
</h2>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
{/* 기본 정보 표시 */}
<div className="p-3 bg-gray-50 rounded">
<p className="text-sm text-gray-600">
<strong>이메일:</strong> {userInfo.email}
</p>
<p className="text-sm text-gray-600">
<strong>이름:</strong> {userInfo.name}
</p>
</div>
{/* 아이디 입력 (필요한 경우) */}
{requirements.needsUsernameInput && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
아이디
</label>
<div className="relative">
<input
{...register('userId')}
type="text"
className={`w-full px-3 py-2 border rounded-md ${
errors.userId ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="아이디를 입력하세요"
onBlur={() => checkUserIdAvailability(userId)}
/>
{userIdAvailable === false && (
<p className="text-sm text-red-600 mt-1">이미 사용 중인 아이디입니다.</p>
)}
{userIdAvailable === true && (
<p className="text-sm text-green-600 mt-1">사용 가능한 아이디입니다.</p>
)}
</div>
{errors.userId && (
<p className="text-sm text-red-600 mt-1">{errors.userId.message}</p>
)}
</div>
)}
{/* 휴대폰 번호 (필요한 경우) */}
{requirements.needsAdditionalInfo && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
휴대폰 번호
</label>
<input
{...register('phone')}
type="tel"
className={`w-full px-3 py-2 border rounded-md ${
errors.phone ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="010-0000-0000"
/>
{errors.phone && (
<p className="text-sm text-red-600 mt-1">{errors.phone.message}</p>
)}
</div>
)}
{/* 약관 동의 */}
<div className="space-y-2">
<label className="flex items-center">
<input
{...register('termsAgreed')}
type="checkbox"
className="mr-2"
/>
<span className="text-sm">이용약관에 동의합니다 (필수)</span>
</label>
{errors.termsAgreed && (
<p className="text-sm text-red-600">{errors.termsAgreed.message}</p>
)}
<label className="flex items-center">
<input
{...register('privacyAgreed')}
type="checkbox"
className="mr-2"
/>
<span className="text-sm">개인정보 처리방침에 동의합니다 (필수)</span>
</label>
{errors.privacyAgreed && (
<p className="text-sm text-red-600">{errors.privacyAgreed.message}</p>
)}
<label className="flex items-center">
<input
{...register('marketingAgreed')}
type="checkbox"
className="mr-2"
/>
<span className="text-sm">마케팅 정보 수신에 동의합니다 (선택)</span>
</label>
</div>
{/* 버튼 */}
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
>
취소
</button>
<button
type="submit"
disabled={isSubmitting || userIdAvailable === false}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '처리 중...' : '회원가입'}
</button>
</div>
</form>
</div>
</div>
);
};
sequenceDiagram
participant User as 👤 사용자
participant Frontend as 🌐 프론트엔드
participant GSI as 🔍 Google GSI
participant AuthService as 🔐 AuthService
participant GoogleAPI as 📡 Google API
participant DB as 🗄️ Database
User->>Frontend: "Google로 시작하기" 클릭
Frontend->>GSI: google.accounts.id.initialize()
GSI->>User: Google 로그인 팝업 표시
User->>GSI: 계정 선택 및 로그인
GSI->>Frontend: handleCredentialResponse(idToken)
Frontend->>AuthService: POST /api/auth/social/google<br/>{idToken, deviceInfo}
AuthService->>GoogleAPI: GET /oauth2/v3/tokeninfo?id_token=...
GoogleAPI->>AuthService: 사용자 정보 응답
AuthService->>DB: 3단계 자동 판단 로직 실행
DB->>AuthService: 사용자 정보 반환
AuthService->>Frontend: JWT + 사용자 정보 응답
Frontend->>User: 로그인 완료 또는 회원가입 폼
sequenceDiagram
participant User as 👤 사용자
participant Frontend as 🌐 프론트엔드
participant KakaoSDK as 🍊 Kakao SDK
participant AuthService as 🔐 AuthService
participant KakaoAPI as 📡 Kakao API
participant DB as 🗄️ Database
User->>Frontend: "카카오로 시작하기" 클릭
Frontend->>KakaoSDK: Kakao.Auth.createLoginButton()
KakaoSDK->>User: 카카오 로그인 페이지
User->>KakaoSDK: 카카오 계정 로그인
KakaoSDK->>Frontend: success(accessToken)
Frontend->>AuthService: POST /api/auth/social/kakao<br/>{accessToken, deviceInfo}
AuthService->>KakaoAPI: GET /v2/user/me<br/>Authorization: Bearer {accessToken}
KakaoAPI->>AuthService: 사용자 정보 응답<br/>{id, kakao_account: {email, profile}}
AuthService->>DB: 3단계 자동 판단 로직 실행
DB->>AuthService: 사용자 정보 반환
AuthService->>Frontend: JWT + 사용자 정보 응답
Frontend->>User: 로그인 완료 또는 회원가입 폼
sequenceDiagram
participant User as 👤 사용자
participant Frontend as 🌐 프론트엔드
participant Popup as 🔗 팝업창
participant AuthService as 🔐 AuthService
participant NaverOAuth as 🟢 네이버 OAuth
participant NaverAPI as 📡 네이버 API
participant DB as 🗄️ Database
User->>Frontend: "네이버로 시작하기" 클릭
Frontend->>Popup: window.open('/api/auth/social/naver/login')
Popup->>AuthService: GET /api/auth/social/naver/login
AuthService->>NaverOAuth: redirect to 네이버 인증 페이지
NaverOAuth->>User: 네이버 로그인 페이지 표시
User->>NaverOAuth: 네이버 계정 로그인
NaverOAuth->>AuthService: GET /api/auth/social/naver/callback?code=...&state=...
AuthService->>NaverOAuth: POST /oauth2.0/token<br/>{code, client_id, client_secret}
NaverOAuth->>AuthService: {access_token}
AuthService->>NaverAPI: GET /v1/nid/me<br/>Authorization: Bearer {access_token}
NaverAPI->>AuthService: 사용자 정보 응답<br/>{response: {id, email, name}}
AuthService->>DB: 3단계 자동 판단 로직 실행
DB->>AuthService: 사용자 정보 반환
alt 로그인 성공
AuthService->>Popup: HTML + postMessage('NAVER_LOGIN_SUCCESS')
Popup->>Frontend: window.opener.postMessage({type: 'NAVER_LOGIN_SUCCESS'})
Frontend->>User: 로그인 완료
else 회원가입 필요
AuthService->>Popup: HTML + postMessage('NAVER_SIGNUP_NEEDED')
Popup->>Frontend: window.opener.postMessage({type: 'NAVER_SIGNUP_NEEDED'})
Frontend->>User: 회원가입 폼 표시
end
sequenceDiagram
participant U as 사용자
participant C as 클라이언트(앱/웹)
participant A as AuthService
participant G as Google API
participant DB as Database
participant R as Redis
U->>C: 1. Google 로그인 버튼 클릭
C->>G: 2. Google OAuth 인증 요청
G->>U: 3. Google 로그인 페이지 표시
U->>G: 4. 계정 정보 입력 & 동의
G->>C: 5. ID Token 발급
C->>A: 6. POST /api/auth/social/google
Note over C,A: { "idToken": "google_id_token" }
A->>G: 7. ID Token 검증
G->>A: 8. 사용자 정보 반환
Note over G,A: { sub, email, name, picture, email_verified }
A->>DB: 9. 소셜 계정 조회/생성
DB->>A: 10. 사용자 정보 반환
A->>A: 11. JWT 토큰 생성
A->>R: 12. 토큰 캐싱
A->>C: 13. 로그인 응답
Note over A,C: { accessToken, refreshToken, userInfo }
C->>U: 14. 로그인 완료 화면
sequenceDiagram
participant U as 사용자
participant C as 클라이언트
participant A as AuthService
participant K as Kakao API
participant DB as Database
participant R as Redis
U->>C: 1. 카카오 로그인 버튼 클릭
C->>K: 2. 카카오 OAuth 인증 요청
K->>U: 3. 카카오 로그인 페이지
U->>K: 4. 로그인 & 동의
K->>C: 5. Access Token 발급
C->>A: 6. POST /api/auth/social/kakao
Note over C,A: { "accessToken": "kakao_access_token" }
A->>K: 7. 사용자 정보 조회
K->>A: 8. 사용자 정보 반환
Note over K,A: { id, kakao_account: { email, profile: { nickname, profile_image_url } } }
A->>DB: 9. 소셜 계정 처리
A->>A: 10. JWT 토큰 생성
A->>R: 11. 토큰 캐싱
A->>C: 12. 로그인 응답
C->>U: 13. 로그인 완료
sequenceDiagram
participant U as 사용자
participant C as 클라이언트
participant A as AuthService
participant N as Naver API
participant DB as Database
participant R as Redis
U->>C: 1. 네이버 로그인 버튼 클릭
C->>N: 2. 네이버 OAuth 인증 요청
N->>U: 3. 네이버 로그인 페이지
U->>K: 4. 로그인 & 동의
N->>C: 5. Access Token 발급
C->>A: 6. POST /api/auth/social/naver
Note over C,A: { "accessToken": "naver_access_token" }
A->>N: 7. 사용자 정보 조회
N->>A: 8. 사용자 정보 반환
Note over N,A: { resultcode: "00", response: { id, email, name, nickname, profile_image } }
A->>DB: 9. 소셜 계정 처리
A->>A: 10. JWT 토큰 생성
A->>R: 11. 토큰 캐싱
A->>C: 12. 로그인 응답
C->>U: 13. 로그인 완료
sequenceDiagram
participant U as 사용자
participant C as 클라이언트
participant A as AuthService
participant S as 소셜 플랫폼
participant DB as Database
participant R as Redis
U->>C: 1. 소셜 회원가입 선택
C->>S: 2. 소셜 인증 요청
S->>U: 3. 로그인 페이지 표시
U->>S: 4. 인증 & 동의
S->>C: 5. 소셜 토큰 발급
C->>U: 6. 추가 정보 입력 폼 표시
Note over C,U: userId, 전화번호, 약관 동의
U->>C: 7. 추가 정보 입력
C->>A: 8. POST /api/auth/social/signup
Note over C,A: { provider, socialToken, userId, phone, termsAgreed, privacyAgreed }
A->>S: 9. 소셜 토큰으로 사용자 정보 조회
S->>A: 10. 소셜 사용자 정보 반환
A->>DB: 11. userId 중복 확인
DB->>A: 12. 중복 여부 응답
alt userId 중복인 경우
A->>C: 13. 중복 오류 응답
C->>U: 14. 다른 아이디 입력 요청
else userId 사용 가능
A->>DB: 15. 새 사용자 생성
A->>DB: 16. 소셜 계정 연결
A->>A: 17. JWT 토큰 생성
A->>R: 18. 토큰 캐싱
A->>C: 19. 회원가입 성공 응답
C->>U: 20. 회원가입 완료 화면
end
flowchart TD
A[소셜 토큰 수신] --> B[소셜 사용자 정보 추출]
B --> C{1️⃣ 소셜 ID로 기존 계정 찾기}
C -->|찾음| D[🟢 즉시 로그인]
C -->|못찾음| E{2️⃣ 이메일로 기존 사용자 찾기}
E -->|찾음| F[🔗 소셜 계정 연결]
F --> G[🟢 연결 후 로그인]
E -->|못찾음| H[🆕 신규 사용자 생성]
H --> I{3️⃣ 추가 정보 충분한가?}
I -->|충분함| J[🟢 자동 생성 후 로그인]
I -->|부족함| K[⚠️ 소셜 회원가입 필요]
K --> L[사용자가 추가 정보 입력]
L --> M[🟢 회원가입 완료 후 로그인]
{
"idToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"deviceInfo": {
"platform": "WEB_BROWSER", // WEB_BROWSER, MOBILE_APP, ADMIN_WEB
"deviceId": "browser-fingerprint-uuid",
"appVersion": "1.0.0"
}
}
{
"accessToken": "kEWG7tLkJc3eDpNpOo8TCCoTsCYadJ5F_access_token",
"deviceInfo": {
"platform": "MOBILE_APP",
"deviceId": "device-unique-id",
"appVersion": "2.1.0"
}
}
{
"accessToken": "AAAANVl6Y8PgC8HsssEQutadsd_access_token"
}
{
"provider": "GOOGLE", // GOOGLE, KAKAO, NAVER
"socialToken": "social_platform_token",
"userId": "user_unique_id",
"phone": "010-1234-5678",
"termsAgreed": true,
"privacyAgreed": true,
"marketingAgreed": false
}
{
"success": true,
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJ1c2VySWQiOiI...",
"refreshToken": "4f8b1c2e-3d4a-5b6c-7d8e-9f0a1b2c3d4e",
"tokenType": "Bearer",
"expiresIn": 3600,
"userInfo": {
"id": "member-uuid-1234",
"email": "user@gmail.com",
"name": "홍길동",
"profileImageUrl": "https://profile-image-url",
"role": "USER",
"isNewUser": false
},
"needsAdditionalInfo": false,
"needsUsernameInput": false,
"needsAccountLink": false,
"message": "Google 로그인이 완료되었습니다!"
}
{
"success": true,
"message": "회원가입이 완료되었습니다!",
"accessToken": "eyJhbGciOiJIUzUxMiJ9...",
"refreshToken": "refresh-token-uuid",
"tokenType": "Bearer",
"expiresIn": 3600,
"userInfo": {
"id": "new-member-uuid",
"email": "newuser@gmail.com",
"name": "김신규",
"profileImageUrl": "https://profile-image-url",
"provider": "GOOGLE",
"role": "USER",
"needsAdditionalInfo": false
}
}
플랫폼별 사용자 정보 통합 클래스:
{
"provider": "google", // google, kakao, naver
"socialId": "108234567890123456789",
"email": "user@gmail.com",
"name": "홍길동",
"profileImageUrl": "https://profile-image-url",
"emailVerified": true,
"phoneNumber": "010-1234-5678",
"locale": "ko_KR",
"collectionChannel": "mobile_app",
"providedInfoMask": 37 // 비트마스크로 제공된 정보 표시
}
CREATE TABLE members (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_uuid VARCHAR(36) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE,
name VARCHAR(50),
user_id VARCHAR(30) UNIQUE,
phone VARCHAR(20),
profile_image_url TEXT,
role VARCHAR(20) DEFAULT 'USER',
status VARCHAR(20) DEFAULT 'ACTIVE',
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE member_social_accounts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL,
provider VARCHAR(20) NOT NULL, -- GOOGLE, KAKAO, NAVER
social_id VARCHAR(100) NOT NULL,
email VARCHAR(100),
name VARCHAR(50),
profile_image_url TEXT,
is_primary BOOLEAN DEFAULT FALSE,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (member_id) REFERENCES members(id),
UNIQUE KEY unique_social_account (provider, social_id)
);
# AuthService의 application-dev.yml
spring:
profiles:
active: dev
social:
google:
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com}
dev-mode: true # 더미 데이터 사용
token-info-url: https://oauth2.googleapis.com/tokeninfo
kakao:
client-id: ${KAKAO_REST_API_KEY:your-kakao-rest-api-key}
user-info-url: https://kapi.kakao.com/v2/user/me
dev-mode: true # 더미 데이터 사용
naver:
client-id: ${NAVER_CLIENT_ID:YOUR_NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET:your-naver-client-secret}
redirect-uri: http://localhost:8082/api/auth/social/naver/callback
auth-url: https://nid.naver.com/oauth2.0/authorize
token-url: https://nid.naver.com/oauth2.0/token
user-info-url: https://openapi.naver.com/v1/nid/me
dev-mode: true # 더미 데이터 사용
jwt:
secret: ${JWT_SECRET:YOUR_JWT_SECRET_KEY_HERE}
access-token-expiry: 3600 # 1시간
refresh-token-expiry: 86400 # 24시간
logging:
level:
com.akc.b2c.authservice.service.social: DEBUG
org.springframework.web: DEBUG
org.springframework.security: DEBUG
// GoogleAuthService.java의 개발 모드 더미 데이터
private SocialUserInfo createDummyGoogleUserInfo(String idToken) {
return SocialUserInfo.builder()
.provider("google")
.socialId("dummy_google_" + System.currentTimeMillis())
.email("dev.google@test.com")
.name("구글 테스트 사용자")
.profileImageUrl("https://lh3.googleusercontent.com/a/default-user=s96-c")
.emailVerified(true)
.locale("ko_KR")
.build();
}
// KakaoAuthService.java의 개발 모드 더미 데이터
private SocialUserInfo createDevModeUserInfo() {
return SocialUserInfo.builder()
.provider("kakao")
.socialId("dummy_kakao_" + System.currentTimeMillis())
.email("dev.kakao@test.com")
.name("카카오 테스트 사용자")
.profileImageUrl("https://k.kakaocdn.net/dn/default_profile.png")
.emailVerified(true)
.build();
}
// NaverAuthService.java의 개발 모드 더미 데이터
private SocialUserInfo createDummyNaverUserInfo() {
return SocialUserInfo.builder()
.provider("naver")
.socialId("dummy_naver_" + System.currentTimeMillis())
.email("dev.naver@test.com")
.name("네이버 테스트 사용자")
.profileImageUrl("https://ssl.pstatic.net/static/pwe/address/img_profile.png")
.emailVerified(true)
.build();
}
// 개발 모드 테스트 (더미 토큰)
const testGoogleLogin = () => {
fetch('http://localhost:8082/api/auth/social/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idToken: 'dummy_google_id_token_for_dev',
deviceInfo: {
platform: 'WEB_BROWSER',
deviceId: 'test-device-' + Date.now()
}
})
});
};
// 개발 모드 테스트 (더미 토큰)
const testKakaoLogin = () => {
fetch('http://localhost:8082/api/auth/social/kakao', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: 'dummy_kakao_access_token_for_dev',
deviceInfo: {
platform: 'WEB_BROWSER',
deviceId: 'test-device-' + Date.now()
}
})
});
};
// 팝업 방식 테스트
const testNaverLogin = () => {
const popup = window.open(
'http://localhost:8082/api/auth/social/naver/login',
'naverTest',
'width=500,height=600'
);
window.addEventListener('message', (event) => {
if (event.origin !== 'http://localhost:8082') return;
console.log('네이버 로그인 결과:', event.data);
});
};
# AuthService 로그 실시간 모니터링
tail -f /path/to/authservice.log | grep -E "(Google|Kakao|Naver|소셜)"
# 특정 패턴 필터링
tail -f /path/to/authservice.log | grep -E "(로그인 처리 시작|사용자 정보|토큰 생성)"
# 토큰 검증 실패 로그
grep "토큰 검증 실패" /path/to/authservice.log
# DB 연결 문제 로그
grep "DB 처리 실패" /path/to/authservice.log
# JWT 토큰 생성 오류
grep "JWT.*실패" /path/to/authservice.log
# Google 로그인 테스트
curl -X POST http://localhost:8082/api/auth/social/google \
-H "Content-Type: application/json" \
-d '{
"idToken": "dummy_google_id_token",
"deviceInfo": {
"platform": "WEB_BROWSER",
"deviceId": "test-device"
}
}'
# 카카오 로그인 테스트
curl -X POST http://localhost:8082/api/auth/social/kakao \
-H "Content-Type: application/json" \
-d '{
"accessToken": "dummy_kakao_access_token",
"deviceInfo": {
"platform": "WEB_BROWSER",
"deviceId": "test-device"
}
}'
# 네이버 로그인 테스트 (Access Token 방식)
curl -X POST http://localhost:8082/api/auth/social/naver \
-H "Content-Type: application/json" \
-d '{
"accessToken": "dummy_naver_access_token"
}'
# 지원 플랫폼 확인
curl -X GET http://localhost:8082/api/auth/social/providers
# 헬스체크
curl -X GET http://localhost:8082/api/auth/social/health
Access to fetch at 'http://localhost:8082/api/auth/social/google'
from origin 'null' has been blocked by CORS policy
백엔드 해결방법: SecurityConfig에서 CORS 설정 확인
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:3011",
"http://localhost:3010",
"http://localhost:3000", // Next.js 개발 서버
"file://*" // 로컬 HTML 파일 허용
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
프론트엔드 해결방법: 개발 환경에서 프록시 설정
// next.config.js (Next.js)
module.exports = {
async rewrites() {
return [
{
source: '/api/auth/:path*',
destination: 'http://localhost:8082/api/auth/:path*'
}
];
}
};
해결방법: 브라우저 팝업 허용 설정 또는 사용자 액션에서 팝업 호출
백엔드 해결방법: RefreshToken을 이용한 토큰 갱신 로직 구현
// JwtTokenProvider.java
@Component
public class JwtTokenProvider {
@Value("${jwt.access-token-expiry:3600}")
private long accessTokenExpiry;
@Value("${jwt.refresh-token-expiry:86400}")
private long refreshTokenExpiry;
public TokenResponse refreshAccessToken(String refreshToken) {
if (validateToken(refreshToken)) {
String userId = getUserIdFromToken(refreshToken);
String newAccessToken = generateAccessToken(userId);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken) // RefreshToken은 재사용
.tokenType("Bearer")
.expiresIn(accessTokenExpiry)
.build();
}
throw new InvalidTokenException("Invalid refresh token");
}
}
# AuthService 실행
cd /Users/sangyonghan/development/Amano/akc-b2c/AuthService
./gradlew bootRun
# 프론트엔드 개발 서버 실행 (Next.js 예시)
cd /path/to/frontend
npm run dev
// config/social-config.ts
export const getSocialConfig = () => {
const environment = process.env.NODE_ENV;
const configs = {
development: {
authServiceUrl: 'http://localhost:8082',
googleClientId: 'YOUR_GOOGLE_CLIENT_ID',
kakaoJsKey: 'YOUR_KAKAO_JAVASCRIPT_KEY',
enableDevMode: true,
enableConsoleLogging: true
},
staging: {
authServiceUrl: 'https://staging-auth.example.com',
googleClientId: 'YOUR_STAGING_GOOGLE_CLIENT_ID',
kakaoJsKey: 'YOUR_STAGING_KAKAO_JS_KEY',
enableDevMode: false,
enableConsoleLogging: true
},
production: {
authServiceUrl: 'https://auth.example.com',
googleClientId: 'YOUR_PROD_GOOGLE_CLIENT_ID',
kakaoJsKey: 'YOUR_PROD_KAKAO_JS_KEY',
enableDevMode: false,
enableConsoleLogging: false
}
};
return configs[environment as keyof typeof configs] || configs.development;
};
// utils/googleDebugger.ts
export const debugGoogleLogin = {
// Google SDK 상태 확인
checkSDKStatus: () => {
console.log('Google SDK 상태:', {
sdkLoaded: typeof window.google !== 'undefined',
accountsAvailable: typeof window.google?.accounts !== 'undefined',
idAvailable: typeof window.google?.accounts?.id !== 'undefined'
});
},
// ID Token 디코딩 (개발 환경에서만)
decodeIdToken: (idToken: string) => {
if (process.env.NODE_ENV !== 'development') return;
try {
const payload = JSON.parse(atob(idToken.split('.')[1]));
console.log('Google ID Token 페이로드:', {
iss: payload.iss,
aud: payload.aud,
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified,
exp: new Date(payload.exp * 1000),
iat: new Date(payload.iat * 1000)
});
} catch (error) {
console.error('ID Token 디코딩 실패:', error);
}
},
// 네트워크 요청 로깅
logNetworkRequest: async (idToken: string) => {
const startTime = Date.now();
console.log('Google API 요청 시작:', new Date().toISOString());
try {
const response = await fetch('/api/auth/social/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken })
});
const endTime = Date.now();
console.log('Google API 응답:', {
status: response.status,
statusText: response.statusText,
duration: `${endTime - startTime}ms`,
headers: Object.fromEntries(response.headers.entries())
});
return response;
} catch (error) {
console.error('Google API 요청 실패:', error);
throw error;
}
}
};
// utils/kakaoDebugger.ts
export const debugKakaoLogin = {
// Kakao SDK 상태 확인
checkSDKStatus: () => {
console.log('Kakao SDK 상태:', {
sdkLoaded: typeof window.Kakao !== 'undefined',
initialized: window.Kakao?.isInitialized(),
version: window.Kakao?.VERSION
});
},
// 액세스 토큰 정보 확인
checkAccessToken: () => {
if (!window.Kakao?.Auth) return;
const accessToken = window.Kakao.Auth.getAccessToken();
console.log('Kakao Access Token:', {
hasToken: !!accessToken,
tokenLength: accessToken?.length,
tokenPreview: accessToken ? `${accessToken.substring(0, 10)}...` : null
});
},
// 로그인 상태 확인
checkLoginStatus: () => {
if (!window.Kakao?.Auth) return;
window.Kakao.Auth.getStatusInfo((statusInfo: any) => {
console.log('Kakao 로그인 상태:', statusInfo);
});
}
};
// utils/naverDebugger.ts
export const debugNaverLogin = {
// 팝업 상태 모니터링
monitorPopup: (popup: Window) => {
const checkInterval = setInterval(() => {
if (popup.closed) {
console.log('네이버 팝업이 닫혔습니다.');
clearInterval(checkInterval);
return;
}
try {
console.log('팝업 URL:', popup.location.href);
} catch (error) {
// Cross-origin 오류는 정상 (다른 도메인으로 이동 중)
console.log('팝업이 네이버 도메인으로 이동 중...');
}
}, 1000);
// 30초 후 모니터링 중단
setTimeout(() => clearInterval(checkInterval), 30000);
},
// 메시지 이벤트 로깅
logPopupMessages: () => {
const originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message') {
const wrappedListener = (event: MessageEvent) => {
console.log('팝업 메시지 수신:', {
origin: event.origin,
data: event.data,
timestamp: new Date().toISOString()
});
return listener.call(this, event);
};
return originalAddEventListener.call(this, type, wrappedListener, options);
}
return originalAddEventListener.call(this, type, listener, options);
};
}
};
문제:
Access to fetch at 'http://localhost:8082/api/auth/social/google'
from origin 'http://localhost:3000' has been blocked by CORS policy
해결방법:
// 개발 환경에서 프록시 설정 (Next.js)
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/auth/:path*',
destination: 'http://localhost:8082/api/auth/:path*'
}
];
}
};
// 또는 백엔드 CORS 설정 확인
// SecurityConfig.java에서 allowedOrigins 확인
문제: Google GSI 또는 Kakao SDK가 로드되지 않음
해결방법:
// SDK 로딩 재시도 로직
const loadSDKWithRetry = async (url: string, checkFn: () => boolean, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
await loadScript(url);
// SDK 로드 확인을 위해 잠시 대기
await new Promise(resolve => setTimeout(resolve, 1000));
if (checkFn()) {
console.log(`SDK 로드 성공 (${i + 1}번째 시도)`);
return true;
}
} catch (error) {
console.warn(`SDK 로드 실패 (${i + 1}번째 시도):`, error);
}
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 2초 대기
}
}
throw new Error('SDK 로드 최대 재시도 횟수 초과');
};
// 사용 예시
try {
await loadSDKWithRetry(
'https://accounts.google.com/gsi/client',
() => typeof window.google?.accounts?.id !== 'undefined'
);
} catch (error) {
// Fallback UI 표시 또는 대체 로그인 방법 제공
showFallbackLoginOptions();
}
문제: 브라우저에서 네이버 로그인 팝업이 차단됨
해결방법:
// 사용자 인터랙션 기반 팝업 열기
const openPopupWithFallback = (url: string, name: string, features: string) => {
const popup = window.open(url, name, features);
if (!popup || popup.closed) {
// 팝업이 차단된 경우 사용자에게 알림
const userConfirmed = confirm(
'팝업이 차단되었습니다. 브라우저 설정에서 팝업을 허용하고 다시 시도하시겠습니까?'
);
if (userConfirmed) {
// 새 창으로 직접 이동
window.location.href = url;
}
return null;
}
return popup;
};
문제: API 요청이 너무 오래 걸리거나 응답하지 않음
해결방법:
// 타임아웃이 있는 fetch 래퍼
const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 10000) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`요청 시간 초과 (${timeout}ms)`);
}
throw error;
}
};
// utils/logger.ts
interface LogEvent {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: Record<string, any>;
timestamp?: string;
provider?: string;
userId?: string;
}
class SocialLoginLogger {
private isDevelopment = process.env.NODE_ENV === 'development';
private log(event: LogEvent) {
const logEntry = {
...event,
timestamp: event.timestamp || new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href
};
// 개발 환경에서는 콘솔에 출력
if (this.isDevelopment) {
console[event.level](event.message, logEntry);
}
// 프로덕션에서는 로그 수집 서비스로 전송
if (!this.isDevelopment && event.level !== 'debug') {
this.sendToLogService(logEntry);
}
}
private async sendToLogService(logEntry: LogEvent) {
try {
await fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logEntry)
});
} catch (error) {
// 로그 전송 실패는 조용히 처리
console.warn('로그 전송 실패:', error);
}
}
debug(message: string, context?: Record<string, any>) {
this.log({ level: 'debug', message, context });
}
info(message: string, context?: Record<string, any>) {
this.log({ level: 'info', message, context });
}
warn(message: string, context?: Record<string, any>) {
this.log({ level: 'warn', message, context });
}
error(message: string, context?: Record<string, any>) {
this.log({ level: 'error', message, context });
}
socialLoginStart(provider: string) {
this.info(`${provider} 로그인 시작`, { provider });
}
socialLoginSuccess(provider: string, isNewUser: boolean) {
this.info(`${provider} 로그인 성공`, { provider, isNewUser });
}
socialLoginError(provider: string, error: Error) {
this.error(`${provider} 로그인 실패`, {
provider,
error: error.message,
stack: error.stack
});
}
}
export const logger = new SocialLoginLogger();
// utils/performanceMonitor.ts
export const performanceMonitor = {
measureSocialLogin: (provider: string, operation: () => Promise<any>) => {
const startTime = performance.now();
const startTimestamp = Date.now();
logger.debug(`${provider} 성능 측정 시작`);
return operation().finally(() => {
const duration = performance.now() - startTime;
const endTimestamp = Date.now();
logger.info(`${provider} 성능 측정 완료`, {
provider,
duration: Math.round(duration),
startTimestamp,
endTimestamp
});
// 성능 임계값 확인 (5초 이상이면 경고)
if (duration > 5000) {
logger.warn(`${provider} 로그인이 예상보다 오래 걸림`, {
provider,
duration: Math.round(duration)
});
}
});
}
};
토큰 저장 방식:
// 보안 등급별 토큰 저장 방법
// 1. 최고 보안 (권장) - httpOnly 쿠키
// 백엔드에서 Set-Cookie 헤더로 설정
// 프론트엔드에서는 접근 불가능하지만 자동으로 요청에 포함
// 2. 높은 보안 - Secure Storage (모바일)
// React Native의 Keychain (iOS) / KeyStore (Android)
// 3. 중간 보안 - sessionStorage (웹)
const storeTokenSecurely = (accessToken: string, refreshToken: string) => {
// 세션 스토리지는 탭 닫히면 자동 삭제
sessionStorage.setItem('accessToken', accessToken);
sessionStorage.setItem('refreshToken', refreshToken);
// 토큰 만료 시간 저장
const expiresAt = Date.now() + (3600 * 1000); // 1시간
sessionStorage.setItem('tokenExpiresAt', expiresAt.toString());
};
// 4. 낮은 보안 - localStorage (개발 환경에만 사용)
// 프로덕션에서는 사용 금지
if (process.env.NODE_ENV === 'development') {
localStorage.setItem('accessToken', accessToken);
}
CSRF 방지:
// CSRF 토큰 처리
export const apiClient = {
async request(url: string, options: RequestInit = {}) {
// CSRF 토큰을 헤더에 추가
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const defaultOptions: RequestInit = {
credentials: 'include', // 쿠키 자동 포함
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', // CSRF 방지
...(csrfToken && { 'X-CSRF-Token': csrfToken }),
...options.headers
}
};
return fetch(url, { ...defaultOptions, ...options });
}
};
개인정보 보호:
// 민감한 정보 로깅 방지
const sanitizeUserInfo = (userInfo: any) => {
const { email, phone, ...safeInfo } = userInfo;
return {
...safeInfo,
email: email ? `${email.substring(0, 3)}***@${email.split('@')[1]}` : undefined,
phone: phone ? `${phone.substring(0, 3)}****${phone.substring(7)}` : undefined
};
};
// 개발 환경에서만 상세 로그
const logSocialLogin = (provider: string, userInfo: any) => {
if (process.env.NODE_ENV === 'development') {
console.log(`${provider} 로그인:`, sanitizeUserInfo(userInfo));
} else {
// 프로덕션에서는 최소한의 로그만
console.log(`${provider} 로그인 성공`);
}
};
// 보안을 고려한 에러 메시지
const getSecureErrorMessage = (error: any, provider: string) => {
// 내부 오류 정보는 숨기고 사용자 친화적인 메시지만 표시
const publicErrors: Record<string, string> = {
'INVALID_TOKEN': '로그인 정보가 올바르지 않습니다. 다시 시도해주세요.',
'EXPIRED_TOKEN': '로그인이 만료되었습니다. 다시 로그인해주세요.',
'NETWORK_ERROR': '네트워크 연결을 확인해주세요.',
'POPUP_BLOCKED': '팝업이 차단되었습니다. 브라우저 설정을 확인해주세요.',
'USER_DENIED': '로그인이 취소되었습니다.',
'SERVER_ERROR': '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
};
const errorCode = error.code || 'UNKNOWN_ERROR';
// 개발 환경에서는 상세 오류 정보 로그
if (process.env.NODE_ENV === 'development') {
console.error(`${provider} 상세 오류:`, error);
}
return publicErrors[errorCode] || '알 수 없는 오류가 발생했습니다.';
};
<!-- Next.js의 경우 next.config.js에서 설정 -->
<!-- 또는 HTML 헤더에서 설정 -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self'
https://accounts.google.com
https://t1.kakaocdn.net
https://developers.kakao.com
'unsafe-inline';
connect-src 'self'
https://accounts.google.com
https://kapi.kakao.com
https://openapi.naver.com
http://localhost:8082;
img-src 'self'
https://*.googleusercontent.com
https://*.kakaocdn.net
https://*.pstatic.net
data:;
style-src 'self' 'unsafe-inline';
frame-src 'self'
https://accounts.google.com;
">
이 문서는 실제 구현된 코드를 기반으로 작성되었으며, 프론트엔드와 백엔드 모두에서 참고할 수 있는 완전한 가이드입니다.
[구글로 시작하기] 버튼 → 구글 인증 → 자동으로 로그인/가입 판단 → 서비스 이용
flowchart TD
A[사용자가 '구글로 시작하기' 클릭] --> B[구글 OAuth 인증]
B --> C[ID Token 획득]
C --> D[AuthService 호출<br/>POST /api/auth/social/google]
D --> E{서버가 자동 판단}
E -->|기존 소셜 계정| F1[즉시 로그인 완료]
E -->|기존 이메일 사용자| F2[소셜 계정 연결 후 로그인]
E -->|완전 신규 + 정보 충분| F3[자동 회원가입 후 로그인]
E -->|완전 신규 + 정보 부족| F4[추가 정보 수집 필요]
F4 --> G[회원가입 폼 표시]
G --> H[사용자가 추가 정보 입력]
H --> I[POST /api/auth/social/signup]
I --> J[회원가입 완료 후 로그인]
F1 --> K[서비스 이용]
F2 --> K
F3 --> K
J --> K
graph TD
A[소셜 토큰] --> B{토큰 검증}
B -->|성공| C[소셜 사용자 정보 추출]
B -->|실패| D[인증 실패]
C --> E[SocialUserInfo 생성]
E --> F{기존 소셜 계정 존재?}
F -->|예| G[기존 사용자 로그인]
F -->|아니오| H{이메일로 기존 사용자 존재?}
H -->|예| I[소셜 계정 연결]
H -->|아니오| J[신규 사용자 생성]
G --> K[JWT 토큰 생성]
I --> K
J --> K
K --> L[Redis 토큰 캐싱]
L --> M[로그인 응답]
graph TD
A[소셜 회원가입 요청] --> B[소셜 토큰 검증]
B --> C[사용자 정보 추출]
C --> D[추가 정보 결합]
D --> E{userId 중복 확인}
E -->|중복| F[중복 오류]
E -->|사용가능| G[신규 사용자 생성]
G --> H[소셜 계정 연결]
H --> I[JWT 토큰 생성]
I --> J[회원가입 완료]
F --> K[다른 아이디 요청]
sequenceDiagram
participant C as Client
participant A as AuthService
participant DB as Database
participant SA as SocialAccount
C->>A: 소셜 로그인 요청
A->>A: 소셜 토큰 검증
A->>SA: 소셜 ID로 계정 조회
SA->>A: 계정 없음
A->>DB: 이메일로 사용자 조회
DB->>A: 기존 사용자 존재
Note over A: 기존 사용자에게 새 소셜 계정 연결
A->>SA: 새 소셜 계정 생성
A->>DB: 사용자 정보 업데이트
A->>A: JWT 토큰 생성
A->>C: 연결 완료 응답
Note over A,C: "기존 계정에 Google 소셜 계정이 연결되었습니다!"
{
"idToken": "eyJhbGciOiJSUzI1NiIs...",
"deviceInfo": {
"platform": "MOBILE_APP",
"deviceId": "device-uuid",
"appVersion": "1.0.0"
}
}
{
"accessToken": "kEWG7tLkJc3eDpNp...",
"deviceInfo": {
"platform": "WEB_BROWSER",
"deviceId": "browser-fingerprint"
}
}
{
"accessToken": "AAAANVl6Y8PgC8H..."
}
{
"provider": "GOOGLE",
"socialToken": "eyJhbGciOiJSUzI1NiIs...",
"userId": "user123",
"phone": "010-1234-5678",
"termsAgreed": true,
"privacyAgreed": true,
"marketingAgreed": false
}
{
"provider": "google",
"socialId": "108234567890123456789",
"email": "user@gmail.com",
"name": "홍길동",
"profileImageUrl": "https://lh3.googleusercontent.com/...",
"emailVerified": true,
"phoneNumber": "010-1234-5678",
"locale": "ko_KR",
"collectionChannel": "mobile_app",
"providedInfoMask": 37
}
{
"success": true,
"accessToken": "eyJhbGciOiJIUzUxMiJ9...",
"refreshToken": "4f8b1c2e-3d4a-5b6c-7d8e-9f0a1b2c3d4e",
"tokenType": "Bearer",
"expiresIn": 3600,
"userInfo": {
"id": "member-uuid-1234",
"email": "user@gmail.com",
"name": "홍길동",
"profileImageUrl": "https://lh3.googleusercontent.com/...",
"role": "USER",
"isNewUser": false
},
"needsAdditionalInfo": false,
"needsUsernameInput": false,
"needsAccountLink": false,
"message": "Google 로그인이 완료되었습니다!"
}
{
"success": true,
"message": "Google 회원가입이 완료되었습니다!",
"accessToken": "eyJhbGciOiJIUzUxMiJ9...",
"refreshToken": "4f8b1c2e-3d4a-5b6c-7d8e-9f0a1b2c3d4e",
"tokenType": "Bearer",
"expiresIn": 3600,
"userInfo": {
"id": "new-member-uuid-5678",
"email": "newuser@gmail.com",
"name": "김신규",
"profileImageUrl": "https://lh3.googleusercontent.com/...",
"provider": "GOOGLE",
"role": "USER",
"needsAdditionalInfo": false
}
}
| 시나리오 | 결과 | needsAdditionalInfo | needsUsernameInput | isNewUser |
|---|---|---|---|---|
| 완전 신규 사용자 | 회원가입 + 로그인 | true | true | true |
| 기존 일반 사용자 | 소셜 계정 연결 | false | false | false |
| 기존 소셜 사용자 | 로그인 | false | false | false |
| 정보 부족 신규 사용자 | 임시 토큰 발급 | true | true | true |
# AuthService 포트 및 기본 설정
server:
port: 8082
spring:
application:
name: auth-service
profiles:
active: local # local | docker | dev 환경별 프로파일
# JWT 토큰 설정 (실제 값)
jwt:
secret: akc-b2c-jwt-secret-key-2024-very-long-and-secure
access-token-expiration: 3600000 # 1시간 (밀리초)
refresh-token-expiration: 604800000 # 7일 (밀리초)
issuer: akc-b2c-auth-service
# 소셜 로그인 실제 설정 값
social:
google:
client-id: your-google-client-id.apps.googleusercontent.com
client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret}
token-info-url: https://www.googleapis.com/oauth2/v3/tokeninfo
user-info-url: https://www.googleapis.com/oauth2/v2/userinfo
scope: openid,email,profile
dev-mode: false # 프로덕션: false, 개발: true
kakao:
client-id: your-kakao-javascript-key # JavaScript 키
user-info-url: https://kapi.kakao.com/v2/user/me
token-url: https://kauth.kakao.com/oauth/token
scope: profile_nickname,account_email
dev-mode: false
naver:
client-id: your-naver-client-id
client-secret: ${NAVER_CLIENT_SECRET:your-naver-client-secret}
redirect-uri: http://localhost:8082/api/auth/social/naver/callback
auth-url: https://nid.naver.com/oauth2.0/authorize
token-url: https://nid.naver.com/oauth2.0/token
user-info-url: https://openapi.naver.com/v1/nid/me
scope: name,email,profile_image
dev-mode: false
# 보안 및 제한 설정
social:
security:
max-login-attempts: 5
account-lock-duration: 1800000 # 30분
settings:
allow-account-linking: true
auto-register: true
require-phone-verification: true
default-role: USER
# Local 환경 (개발자 로컬)
spring.profiles.active: local
datasource.url: jdbc:mysql://localhost:3306/db_akc_b2c
redis.host: localhost
dev-mode: true # 모든 소셜 플랫폼
# Docker 환경 (컨테이너)
spring.profiles.active: docker
datasource.url: jdbc:mysql://host.docker.internal:3306/db_akc_b2c
redis.host: redis
dev-mode: false
# Development 환경 (최소 의존성)
spring.profiles.active: dev
datasource.url: jdbc:h2:mem:testdb # 인메모리 DB
redis: disabled
dev-mode: true
// GoogleAuthService.java 실제 구현
public SocialUserInfo getUserInfo(String idToken) {
// 개발 모드 또는 더미 토큰 체크
if (devMode || isDummyToken(idToken)) {
log.info("🧪 Google 개발 모드 - 더미 데이터 반환");
return createDummyGoogleUserInfo(idToken);
}
// 실제 Google API 토큰 검증
return verifyGoogleIdToken(idToken);
}
private boolean isDummyToken(String idToken) {
return idToken != null && (
idToken.startsWith("dummy") ||
idToken.startsWith("test") ||
idToken.equals("dev_google_token")
);
}
private SocialUserInfo createDummyGoogleUserInfo(String idToken) {
return SocialUserInfo.builder()
.provider("google")
.socialId("dummy_google_" + System.currentTimeMillis())
.email("dev.google.user@test.com")
.name("구글 개발 테스트")
.profileImageUrl("https://lh3.googleusercontent.com/a/default-user")
.emailVerified(true)
.locale("ko_KR")
.collectionChannel("web_test")
.providedInfoMask(63) // 모든 정보 제공
.build();
}
// KakaoAuthService.java 실제 구현
public SocialUserInfo getUserInfo(String accessToken) {
if (devMode) {
log.info("🍊 카카오 개발 모드 - 더미 데이터 반환");
return createDevModeUserInfo();
}
return callKakaoUserInfoApi(accessToken);
}
private SocialUserInfo createDevModeUserInfo() {
return SocialUserInfo.builder()
.provider("kakao")
.socialId("dummy_kakao_" + System.currentTimeMillis())
.email("dev.kakao.user@test.com")
.name("카카오 개발 테스트")
.profileImageUrl("https://k.kakaocdn.net/dn/default_profile.png")
.emailVerified(true)
.build();
}
// NaverAuthService.java 실제 구현
public String getAccessToken(String authorizationCode, String state) {
if (devMode) {
log.info("🟢 네이버 개발 모드 - 더미 Access Token 반환");
return "dev_naver_access_token_" + System.currentTimeMillis();
}
return exchangeCodeForToken(authorizationCode, state);
}
public SocialUserInfo getUserInfo(String accessToken) {
if (devMode || accessToken.startsWith("dev_naver_")) {
return createDummyNaverUserInfo();
}
return callNaverUserInfoApi(accessToken);
}
@RestController
@RequestMapping("/api/auth/social")
public class SocialAuthController {
// ✅ 실제 POST 엔드포인트들
@PostMapping("/google") // Google ID Token 처리
@PostMapping("/kakao") // Kakao Access Token 처리
@PostMapping("/naver") // Naver Access Token 처리 (추가적)
@PostMapping("/signup") // 소셜 회원가입 통합
// ✅ 네이버 OAuth GET 엔드포인트들 (팝업 전용)
@GetMapping("/naver/login") // 네이버 인증 시작
@GetMapping("/naver/callback") // 네이버 콜백 처리
// ✅ 유틸리티 엔드포인트들
@GetMapping("/providers") // 지원 플랫폼 목록 반환
@GetMapping("/health") // 서비스 상태 체크
// ⚠️ 숨겨진 엔드포인트 (내부적으로 존재할 수 있음)
@PostMapping("/check-userid") // 아이디 중복 확인 (추정)
}
// POST /api/auth/social/google 요청
{
"idToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"deviceInfo": {
"platform": "WEB_BROWSER",
"deviceId": "browser-12345",
"appVersion": "1.0.0"
}
}
// POST /api/auth/social/kakao 요청
{
"accessToken": "kEWG7tLkJc3eDpNpOo8TCCoTs...",
"deviceInfo": {
"platform": "MOBILE_APP",
"deviceId": "device-67890",
"appVersion": "2.1.0"
}
}
// POST /api/auth/social/naver 요청
{
"accessToken": "AAAANVl6Y8PgC8HsssEQutads..."
}
CREATE TABLE `members` (
`id` bigint NOT NULL AUTO_INCREMENT,
`member_uuid` varchar(36) NOT NULL,
`email` varchar(100) DEFAULT NULL,
`name` varchar(50) DEFAULT NULL,
`user_id` varchar(30) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL, -- 소셜 전용 회원은 NULL
`profile_image_url` text,
`role` varchar(20) DEFAULT 'USER',
`member_type` varchar(20) DEFAULT 'SOCIAL', -- GENERAL, SOCIAL
`status` varchar(20) DEFAULT 'ACTIVE', -- ACTIVE, INACTIVE, WITHDRAWN
`birth_date` date DEFAULT NULL,
`gender` varchar(10) DEFAULT NULL,
`marketing_agreed` tinyint(1) DEFAULT 0,
`terms_agreed` tinyint(1) DEFAULT 1,
`privacy_agreed` tinyint(1) DEFAULT 1,
`sms_notification_enabled` tinyint(1) DEFAULT 1,
`email_notification_enabled` tinyint(1) DEFAULT 1,
`last_login_at` timestamp NULL DEFAULT NULL,
`password_changed_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` varchar(50) DEFAULT 'system',
`updated_by` varchar(50) DEFAULT 'system',
`withdraw_reason` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_members_member_uuid` (`member_uuid`),
UNIQUE KEY `uk_members_email` (`email`),
UNIQUE KEY `uk_members_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `member_social_accounts` (
`id` bigint NOT NULL AUTO_INCREMENT,
`member_id` bigint NOT NULL,
`provider` varchar(20) NOT NULL COMMENT '소셜 제공업체 (GOOGLE, KAKAO, NAVER)',
`social_id` varchar(100) NOT NULL COMMENT '소셜 플랫폼 고유 ID',
`email` varchar(100) DEFAULT NULL COMMENT '소셜에서 제공된 이메일',
`name` varchar(50) DEFAULT NULL COMMENT '소셜에서 제공된 이름',
`profile_image_url` text COMMENT '소셜 프로필 이미지 URL',
`is_primary` tinyint(1) DEFAULT 0 COMMENT '주 소셜 계정 여부',
`is_email_verified` tinyint(1) DEFAULT 0 COMMENT '소셜에서 이메일 인증 여부',
`locale` varchar(10) DEFAULT NULL COMMENT '사용자 로케일 (ko_KR, en_US)',
`last_used_at` timestamp NULL DEFAULT NULL COMMENT '마지막 로그인 시간',
`additional_info` json DEFAULT NULL COMMENT '소셜별 추가 정보 JSON',
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` varchar(50) DEFAULT 'system',
`updated_by` varchar(50) DEFAULT 'system',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_social_account` (`provider`,`social_id`),
KEY `fk_member_social_accounts_member_id` (`member_id`),
KEY `idx_member_social_accounts_provider` (`provider`),
KEY `idx_member_social_accounts_email` (`email`),
CONSTRAINT `fk_member_social_accounts_member_id`
FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
// 성공 응답
{
"success": true,
"data": { /* 실제 데이터 */ },
"message": "처리 완료",
"timestamp": 1703123456789
}
// 실패 응답
{
"success": false,
"error": {
"code": "INVALID_SOCIAL_TOKEN",
"message": "소셜 토큰이 유효하지 않습니다",
"details": "Google ID Token verification failed",
"field": "idToken" // 필드 오류인 경우
},
"timestamp": 1703123456789
}
// SocialAuthErrorCode.java (실제 구현 추정)
public enum SocialAuthErrorCode {
// 토큰 관련 오류
INVALID_SOCIAL_TOKEN("AUTH_001", "소셜 토큰이 유효하지 않습니다"),
EXPIRED_SOCIAL_TOKEN("AUTH_002", "소셜 토큰이 만료되었습니다"),
SOCIAL_TOKEN_VERIFICATION_FAILED("AUTH_003", "소셜 토큰 검증에 실패했습니다"),
// 사용자 정보 관련 오류
SOCIAL_USER_INFO_NOT_FOUND("AUTH_101", "소셜 사용자 정보를 찾을 수 없습니다"),
REQUIRED_USER_INFO_MISSING("AUTH_102", "필수 사용자 정보가 누락되었습니다"),
EMAIL_NOT_VERIFIED("AUTH_103", "이메일 인증이 완료되지 않았습니다"),
// 회원가입 관련 오류
DUPLICATE_USER_ID("AUTH_201", "이미 사용 중인 아이디입니다"),
DUPLICATE_EMAIL("AUTH_202", "이미 가입된 이메일입니다"),
TERMS_NOT_AGREED("AUTH_203", "필수 약관에 동의하지 않았습니다"),
INVALID_PHONE_FORMAT("AUTH_204", "휴대폰 번호 형식이 올바르지 않습니다"),
// 플랫폼별 오류
GOOGLE_API_ERROR("AUTH_301", "Google API 호출 중 오류가 발생했습니다"),
KAKAO_API_ERROR("AUTH_302", "카카오 API 호출 중 오류가 발생했습니다"),
NAVER_API_ERROR("AUTH_303", "네이버 API 호출 중 오류가 발생했습니다"),
// 시스템 오류
DATABASE_ERROR("SYS_001", "데이터베이스 처리 중 오류가 발생했습니다"),
REDIS_CONNECTION_ERROR("SYS_002", "Redis 연결 오류가 발생했습니다"),
JWT_GENERATION_FAILED("SYS_003", "JWT 토큰 생성에 실패했습니다");
private final String code;
private final String message;
SocialAuthErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}
# application.yml의 로깅 설정
logging:
level:
com.akc.b2c.authservice: DEBUG # AuthService 전체 DEBUG
com.akc.b2c.authservice.service.social: INFO # 소셜 로그인만 INFO
org.springframework.security: DEBUG # Spring Security DEBUG
org.hibernate.SQL: DEBUG # SQL 쿼리 로깅
org.hibernate.type.descriptor.sql.BasicBinder: TRACE # SQL 파라미터
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/authservice.log
max-size: 100MB
max-history: 30
// 실제 로그 출력 예시 (SocialAuthService.java)
public class SocialAuthService {
public SocialLoginResponse googleLogin(GoogleLoginRequest request) {
log.info("🔍 Google 로그인 처리 시작 - Platform: {}",
request.getDeviceInfo().getPlatform());
try {
SocialUserInfo socialUserInfo = googleAuthService.getUserInfo(request.getIdToken());
log.debug("Google 사용자 정보 수신: {}", socialUserInfo.toMaskedString());
UserSocialResult userResult = socialUserService.findOrCreateUserBySocial(socialUserInfo);
log.info("사용자 DB 처리 완료 - NewUser: {}, NewSocial: {}",
userResult.isNewUser(), userResult.isNewSocialAccount());
String accessToken = jwtTokenProvider.generateAccessToken(/*...*/);
log.debug("JWT 토큰 생성 완료 - TokenLength: {}", accessToken.length());
return buildSuccessResponse(userResult, accessToken, refreshToken);
} catch (Exception e) {
log.error("❌ Google 로그인 처리 실패 - Error: {}", e.getMessage(), e);
throw e;
}
}
}
# docker-compose.yml (AuthService 부분)
services:
auth-service:
build: ./AuthService
container_name: akc-auth-service
ports:
- "8082:8082"
environment:
SPRING_PROFILES_ACTIVE: docker
DB_USERNAME: antsome
DB_PASSWORD: Q1w2e3r4%^
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET}
JWT_SECRET: ${JWT_SECRET}
depends_on:
- mysql
- redis
networks:
- akc-network
# .env 파일 (실제 배포 시 필요한 환경변수)
GOOGLE_CLIENT_SECRET=your-google-client-secret
NAVER_CLIENT_SECRET=your-naver-client-secret
JWT_SECRET=akc-b2c-jwt-secret-key-2024-very-long-and-secure
DB_USERNAME=antsome
DB_PASSWORD=Q1w2e3r4%^
SMS_PROVIDER=test
AWS_ACCESS_KEY=your-aws-access-key
AWS_SECRET_KEY=your-aws-secret-key
// JwtTokenProvider.java
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
public String generateAccessToken(String userId, String email, String role) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("email", email);
claims.put("role", role);
claims.put("type", "access");
return Jwts.builder()
.setClaims(claims)
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
public String generateRefreshToken(String userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("type", "refresh");
return Jwts.builder()
.setClaims(claims)
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
}
// RedisTokenService.java
@Service
public class RedisTokenService {
private final RedisTemplate<String, String> redisTemplate;
public void cacheAccessToken(String userId, String token, long expiration) {
String key = "access_token:" + userId;
redisTemplate.opsForValue().set(key, token, Duration.ofMillis(expiration));
log.debug("Access Token 캐싱 완료 - UserId: {}, TTL: {}ms", userId, expiration);
}
public void cacheRefreshToken(String userId, String token, long expiration) {
String key = "refresh_token:" + userId;
redisTemplate.opsForValue().set(key, token, Duration.ofMillis(expiration));
log.debug("Refresh Token 캐싱 완료 - UserId: {}, TTL: {}ms", userId, expiration);
}
public boolean validateCachedToken(String userId, String token) {
String cachedToken = redisTemplate.opsForValue().get("access_token:" + userId);
return token.equals(cachedToken);
}
public void invalidateUserTokens(String userId) {
redisTemplate.delete("access_token:" + userId);
redisTemplate.delete("refresh_token:" + userId);
log.info("사용자 토큰 무효화 완료 - UserId: {}", userId);
}
}
// SecurityConfig.java에서 CORS 설정
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 프론트엔드 개발 서버 허용 (실제 값)
configuration.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:3000", // Next.js 개발 서버
"http://localhost:3011", // 기존 개발 서버
"http://localhost:3010", // 기존 개발 서버
"http://192.168.0.9:3011", // 로컬 네트워크
"file://*" // 로컬 HTML 파일
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
// 프론트엔드에서 사용할 수 있도록 노출할 헤더
configuration.setExposedHeaders(Arrays.asList(
"X-User-Id", "X-User-Email", "X-User-Role", "Authorization"
));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
<!-- 네이버 콜백에서 실제 생성되는 HTML -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>네이버 로그인 처리</title>
</head>
<body>
<script>
try {
// 실제 백엔드에서 생성하는 메시지 형식
window.opener.postMessage({
type: 'NAVER_LOGIN_SUCCESS', // 또는 'NAVER_SIGNUP_NEEDED', 'NAVER_LOGIN_ERROR'
result: {
member: {
id: '<%=member.getId()%>',
email: '<%=member.getEmail()%>',
name: '<%=member.getName()%>',
profileImageUrl: '<%=member.getProfileImageUrl()%>'
},
token: '<%=jwtToken%>',
isNewMember: <%=userResult.isNewUser()%>
}
}, '*');
window.close();
} catch (error) {
console.error('부모 창으로 메시지 전송 실패:', error);
document.body.innerHTML = '<h2>처리 완료</h2><p>이 창을 닫고 메인 페이지로 돌아가세요.</p>';
}
</script>
</body>
</html>
# application-local.yml에서 개발 모드 설정
social:
google:
dev-mode: true # Google 개발 모드 활성화
kakao:
dev-mode: true # 카카오 개발 모드 활성화
naver:
dev-mode: true # 네이버 개발 모드 활성화
// 프론트엔드에서 사용할 수 있는 개발용 토큰들
const DEV_TOKENS = {
google: 'dummy_google_token_dev_mode',
kakao: 'dummy_kakao_token_dev_mode',
naver: 'dummy_naver_token_dev_mode'
};
// 개발 환경에서 실제 소셜 로그인 없이 테스트
const testSocialLogin = (provider) => {
const token = DEV_TOKENS[provider];
fetch(`http://localhost:8082/api/auth/social/${provider}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idToken: provider === 'google' ? token : undefined,
accessToken: provider !== 'google' ? token : undefined,
deviceInfo: {
platform: 'WEB_BROWSER',
deviceId: 'test-device-' + Date.now(),
appVersion: '1.0.0'
}
})
})
.then(response => response.json())
.then(data => {
console.log(`${provider} 개발 모드 테스트 결과:`, data);
});
};
이 문서는 실제 구현된 백엔드 코드를 기반으로 작성되었으며, 프론트엔드 개발자들이 정확한 API 스펙과 설정 정보를 참고할 수 있도록 상세히 기술되었습니다.
이 도식화를 통해 소셜 로그인 및 회원가입의 전체적인 흐름과 데이터 처리 과정을 명확하게 이해할 수 있습니다. 각 단계에서 필요한 데이터와 처리 결과를 확인하여 테스트 시나리오를 작성할 수 있습니다.