social-login

Social Login Implementation Guide. 소셜 로그인(Google, Apple, Kakao, Naver) 구현 가이드. React Native(Expo), Swift, Kotlin, Web 환경. 소셜 로그인, social login, 카카오/네이버/구글/애플 로그인, KOE205, DEVELOPER_ERROR, keyHash 문제와

SKILL.md

GitHub

Social Login Implementation Guide

소셜 로그인 구현 종합 가이드. Google, Apple, Kakao, Naver 4사를 React Native(Expo), Swift(iOS), Kotlin/Java(Android), Web 환경에서 구현합니다. 실제 프로덕션 삽질 경험에서 추출한 gotcha + 체크리스트 포함.

Trigger

다음 키워드에 자동 활성화:

  • "소셜 로그인", "social login", "OAuth 로그인"
  • "카카오 로그인", "네이버 로그인", "구글 로그인", "애플 로그인"
  • "kakao login", "naver login", "google sign-in", "apple sign-in"
  • "DEVELOPER_ERROR", "KOE205", "keyHash validation"

핵심 개념: "프론트만 값을 가지고 있으면 안 되는 거구나?"

이것이 소셜 로그인에서 가장 많이 하는 실수입니다.

[사용자] → [프론트엔드] → [소셜 프로바이더] → [프론트엔드] → [백엔드] → [DB]
           ① SDK로 로그인    ② 토큰 발급         ③ 토큰 전달    ④ 토큰 검증
단계 프론트엔드 백엔드
① 로그인 요청 SDK로 소셜 로그인 UI 호출 -
② 토큰 획득 idToken 또는 accessToken 받음 -
③ 토큰 전달 백엔드 /auth/social API 호출 -
④ 토큰 검증 - 소셜 서버에 직접 확인 (위조 방지)
⑤ JWT 발급 우리 앱 JWT 저장 사용자 생성/조회 + JWT 생성

왜 백엔드도 키가 필요한가? 프론트에서 "카카오로 로그인했어요"라고만 하면 누구나 가짜 토큰을 보낼 수 있다. 백엔드가 카카오/구글 서버에 직접 물어봐서 진짜인지 확인해야 한다.

프로바이더별 백엔드 검증 방식

프로바이더 프론트가 보내는 것 백엔드 검증 방식
Google idToken (JWT) JWT의 aud(Client ID)가 우리 앱인지 검증
Apple iOS: idToken / Android: authorizationCode JWT 서명 검증 (JWKS) / code 교환
Kakao accessToken 카카오 API로 사용자 정보 조회
Naver accessToken 네이버 API로 사용자 정보 조회

플랫폼 × 프로바이더 매트릭스

각 조합마다 설정 방식이 완전히 다릅니다.

React Native (Expo) Swift (iOS) Kotlin (Android) Web
Google @react-native-google-signin + google-services.json GoogleSignIn pod credentials-manager + google-services.json gsi/client JS SDK
Apple iOS: expo-apple-authentication (네이티브) / Android: 웹 OAuth AuthenticationServices (네이티브) 웹 OAuth (Service ID 필요) JS SDK + Service ID
Kakao @react-native-seoul/kakao-login KakaoSDK pod kakao-sdk gradle Kakao JS SDK
Naver @react-native-seoul/naver-login NaverThirdPartyLogin pod naver-sdk gradle Naver JS SDK

iOS vs Android 핵심 차이 (가장 혼란스러운 부분)

iOS:  대부분 네이티브 SDK → 설정이 비교적 단순
Android: 서명 키(keystore) 기반 인증 → SHA-1, keyHash 등록 필수
항목 iOS Android
Google GoogleService-Info.plist google-services.json + SHA-1 등록
Apple 네이티브 (자동) 웹 OAuth (Service ID + Return URL 필요)
Kakao Bundle ID 등록 keyHash 등록 (debug/release 각각)
Naver URL Scheme 등록 Package Name + 서명 인증서
서명 키 자동 관리 (Xcode) debug.keystore ≠ release.jks (SHA-1/keyHash 다름!)

초보자 함정: "iOS에서 되는데 Android에서 안 돼요"는 90% 서명 키 문제입니다.


Provider별 구현 가이드

1. Google

개발자 콘솔 설정

  1. Firebase Console → 프로젝트 생성
  2. Android 앱 추가 → SHA-1 등록 (debug + release 둘 다!)
  3. iOS 앱 추가 → Bundle ID 입력
  4. google-services.json / GoogleService-Info.plist 다운로드
  5. Google Cloud Console → API 및 서비스 → 사용자 인증 정보
    • Web Client ID (백엔드 검증용)
    • Android Client ID (SHA-1별로)
    • iOS Client ID

SHA-1 확인 방법

# debug keystore (로컬 개발)
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# release keystore (프로덕션)
keytool -list -v -keystore your-release.jks -alias your-alias

# EAS 빌드 keystore
eas credentials  # → Android → Keystore → SHA-1 확인

GOTCHA: Firebase에 SHA-1 등록 후 반드시 google-services.json을 다시 다운로드해야 합니다. 안 하면 oauth_client 섹션이 비어있어 DEVELOPER_ERROR가 납니다.

DEVELOPER_ERROR 디버깅 체크리스트

DEVELOPER_ERROR는 "설정이 뭔가 안 맞다"는 포괄적 에러입니다. 4곳을 전부 확인하세요:

  • Firebase Console에 SHA-1 등록했나? (debug + release)
  • google-services.json을 SHA-1 등록 후에 다시 다운로드했나?
  • Google Cloud Console → OAuth 클라이언트에 Android SHA-1이 있나?
  • 코드에서 webClientId가 실제로 전달되고 있나? (console.log로 확인)

플랫폼별 코드

<details> <summary>React Native (Expo)</summary>
// 설치: npx expo install @react-native-google-signin/google-signin
import { GoogleSignin } from '@react-native-google-signin/google-signin';

GoogleSignin.configure({
  webClientId: 'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com', // 상수 권장
  offlineAccess: true,
});

const googleLogin = async () => {
  await GoogleSignin.hasPlayServices();
  const { data } = await GoogleSignin.signIn();
  const idToken = data?.idToken;
  // → 백엔드로 idToken 전송
  await api.post('/auth/social', { provider: 'google', token: idToken });
};

GOTCHA: process.env.EXPO_PUBLIC_*는 EAS 클라우드 빌드에서만 주입됩니다. 로컬 Gradle 빌드(expo run:android)에서는 빈 값! 상수 fallback 필수.

const WEB_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID
  || 'fallback-value.apps.googleusercontent.com';
</details> <details> <summary>Swift (iOS)</summary>
// GoogleSignIn pod 설치
import GoogleSignIn

func googleLogin() {
    // iOS 15+: UIApplication.shared.windows는 deprecated — connectedScenes 경유
    guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
          let presentingVC = scene.keyWindow?.rootViewController else { return }

    GIDSignIn.sharedInstance.signIn(withPresenting: presentingVC) { result, error in
        guard let idToken = result?.user.idToken?.tokenString else { return }
        // → 백엔드로 idToken 전송
        AuthAPI.socialLogin(provider: "google", token: idToken)
    }
}

Info.plist에 URL Scheme 추가:

<key>GIDClientID</key>
<string>YOUR_IOS_CLIENT_ID.apps.googleusercontent.com</string>
</details> <details> <summary>Kotlin (Android)</summary>
// build.gradle: implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
// build.gradle: implementation("androidx.credentials:credentials:1.3.0")

val googleIdOption = GetGoogleIdOption.Builder()
    .setFilterByAuthorizedAccounts(false)
    .setServerClientId("YOUR_WEB_CLIENT_ID")
    .build()

val request = GetCredentialRequest.Builder()
    .addCredentialOption(googleIdOption)
    .build()

val credentialManager = CredentialManager.create(context)
val result = credentialManager.getCredential(context, request)
val idToken = (result.credential as GoogleIdTokenCredential).idToken
// → 백엔드로 idToken 전송

google-services.json이 app/ 디렉토리에 있는지 확인.

</details> <details> <summary>Web (JavaScript)</summary>
<script src="https://accounts.google.com/gsi/client" async></script>
<div id="g_id_onload"
     data-client_id="YOUR_WEB_CLIENT_ID"
     data-callback="handleGoogleLogin">
</div>
function handleGoogleLogin(response) {
  const idToken = response.credential;
  fetch('/auth/social', {
    method: 'POST',
    body: JSON.stringify({ provider: 'google', token: idToken }),
  });
}
</details> <details> <summary>백엔드 (Spring Boot)</summary>
// Google idToken 검증
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
    .setAudience(Arrays.asList(webClientId, androidClientId, iosClientId))
    .build();

GoogleIdToken idToken = verifier.verify(tokenString);
if (idToken != null) {
    GoogleIdToken.Payload payload = idToken.getPayload();
    String email = payload.getEmail();
    String name = (String) payload.get("name");
}

GOTCHA: setAudience에 Web + Android + iOS Client ID 전부 넣어야 합니다. 하나라도 빠지면 해당 플랫폼에서 검증 실패.

</details>

2. Apple

핵심: iOS와 Android는 완전히 다른 흐름

iOS:     네이티브 SDK → idToken(JWT) 직접 반환 → 백엔드에서 JWKS 검증
Android: 웹 OAuth → authorization_code 반환 → 백엔드에서 Apple 서버에 code 교환
Web:     JS SDK → authorization_code 또는 idToken → 백엔드 처리

GOTCHA: Android에서 받은 authorization_code를 idToken 필드로 보내면 JWT 파싱 실패 → 401. 두 필드를 반드시 분리하세요.

개발자 콘솔 설정

  1. Apple Developer → Certificates, Identifiers & Profiles
  2. App ID 생성 → Sign in with Apple 활성화
  3. Service ID 생성 (Android/Web용) → Configure:
    • Domains: yourdomain.com
    • Return URLs: https://yourdomain.com/auth/apple/callback
  4. Keys 생성 → APNs + Sign in with Apple 체크 → .p8 파일 다운로드

GOTCHA: Apple Developer License Agreement에 동의 안 하면 콘솔 기능 전체가 잠깁니다. 작업 시작 전 콘솔 접속부터 확인하세요. 연 1회 갱신 필요.

GOTCHA: .p8 파일은 딱 1번만 다운로드 가능합니다. 분실하면 새 Key 생성 필수.

플랫폼별 코드

<details> <summary>React Native (Expo)</summary>
import * as AppleAuthentication from 'expo-apple-authentication';
import * as WebBrowser from 'expo-web-browser';
import { Platform } from 'react-native';

const appleLogin = async () => {
  if (Platform.OS === 'ios') {
    // iOS: 네이티브
    const credential = await AppleAuthentication.signInAsync({
      requestedScopes: [
        AppleAuthentication.AppleAuthenticationScope.EMAIL,
        AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
      ],
    });
    await api.post('/auth/social', {
      provider: 'apple',
      idToken: credential.identityToken,  // JWT
    });
  } else {
    // Android: 웹 OAuth (Expo Auth Proxy 또는 직접 구현)
    const result = await WebBrowser.openAuthSessionAsync(
      `https://appleid.apple.com/auth/authorize?` +
      `client_id=${SERVICE_ID}&` +
      `redirect_uri=${encodeURIComponent(RETURN_URL)}&` +
      `response_type=code&` +
      `scope=name email&` +
      `response_mode=form_post`,
      RETURN_URL
    );
    if (result.type === 'success') {
      const code = extractCodeFromUrl(result.url);
      await api.post('/auth/social', {
        provider: 'apple',
        authorizationCode: code,  // code ≠ idToken!
      });
    }
  }
};
</details> <details> <summary>Swift (iOS)</summary>
import AuthenticationServices

class AppleSignInDelegate: NSObject, ASAuthorizationControllerDelegate {
    func authorizationController(controller: ASAuthorizationController,
                                didCompleteWithAuthorization authorization: ASAuthorization) {
        if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential,
           let identityToken = appleIDCredential.identityToken,
           let tokenString = String(data: identityToken, encoding: .utf8) {
            // → 백엔드로 idToken 전송
            AuthAPI.socialLogin(provider: "apple", idToken: tokenString)
        }
    }
}

// 로그인 요청
let provider = ASAuthorizationAppleIDProvider()
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.performRequests()

Apple은 이름/이메일을 최초 1회만 줍니다. 놓치면 앱 삭제 후 재설치해야 다시 받을 수 있습니다.

</details> <details> <summary>Kotlin (Android) — 웹 OAuth</summary>
// Android에서 Apple 로그인 = 웹 OAuth
val authUrl = "https://appleid.apple.com/auth/authorize?" +
    "client_id=$SERVICE_ID&" +
    "redirect_uri=${URLEncoder.encode(RETURN_URL, "UTF-8")}&" +
    "response_type=code&" +
    "scope=name email&" +
    "response_mode=form_post"

// Custom Tab 또는 WebView로 열기
val intent = CustomTabsIntent.Builder().build()
intent.launchUrl(context, Uri.parse(authUrl))

// redirect 수신 → authorization_code 추출 → 백엔드 전송
</details> <details> <summary>백엔드 (Spring Boot)</summary>
// iOS: idToken(JWT) 검증
public AppleUser verifyIdToken(String idToken) {
    // Apple JWKS 엔드포인트에서 공개키 가져오기
    String jwksUrl = "https://appleid.apple.com/auth/keys";
    // JWT 서명 검증 + claims 추출
    Claims claims = Jwts.parserBuilder()
        .setSigningKey(applePublicKey)
        .build()
        .parseClaimsJws(idToken)
        .getBody();
    return new AppleUser(claims.getSubject(), claims.get("email", String.class));
}

// Android/Web: authorization_code → 토큰 교환
public AppleTokenResponse exchangeCode(String code) {
    // Apple 토큰 엔드포인트에 POST
    // client_secret은 JWT로 직접 생성 (team_id + key_id + .p8)
    RestTemplate rest = new RestTemplate();
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("client_id", serviceId);
    params.add("client_secret", generateClientSecret()); // .p8로 JWT 서명
    params.add("code", code);
    params.add("grant_type", "authorization_code");
    return rest.postForObject("https://appleid.apple.com/auth/token", params, AppleTokenResponse.class);
}

GOTCHA: Apple client_secret은 고정 문자열이 아니라 .p8 키로 서명한 JWT입니다. 만료 6개월. 갱신 로직 필요.

</details>

App Store 심사 필수 요구사항

Apple 로그인을 제공하면 계정 삭제(revoke) 기능이 필수입니다:

// 백엔드: Apple 토큰 revoke
public void revokeAppleToken(String refreshToken) {
    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("client_id", serviceId);
    params.add("client_secret", generateClientSecret());
    params.add("token", refreshToken);
    params.add("token_type_hint", "refresh_token");
    restTemplate.postForObject("https://appleid.apple.com/auth/revoke", params, Void.class);
}

3. Kakao

개발자 콘솔 설정

  1. 카카오 개발자센터 → 내 애플리케이션
  2. 앱 생성 → 앱 키 확인:
    • Native App Key (모바일 SDK용)
    • REST API Key (백엔드 검증용)
  3. 플랫폼 등록:
    • Android: 패키지명 + keyHash (debug/release 각각)
    • iOS: Bundle ID
    • Web: 도메인
  4. 카카오 로그인 활성화 + 동의항목 설정 (이메일, 프로필)

keyHash 등록

# debug keyHash (Mac/Linux)
keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore \
  | openssl dgst -sha1 -binary | openssl base64

# release keyHash
keytool -exportcert -alias your-alias -keystore your-release.jks \
  | openssl dgst -sha1 -binary | openssl base64

GOTCHA: keyHash validation failed가 나와도 실제 keyHash 문제가 아닐 수 있습니다. Native App Key가 빈 값이어도 카카오 SDK는 keyHash validation failed를 리턴합니다. 런타임에 실제 값을 확인하세요:

import { getKeyHashAndroid } from '@react-native-seoul/kakao-login';
const hash = await getKeyHashAndroid();
Alert.alert('KeyHash', hash); // 이 값을 카카오 콘솔에 등록

GOTCHA (KOE205): "잘못된 요청" 에러는 동의항목 미설정입니다. 카카오 개발자센터 → 동의항목 → 이메일(선택), 프로필(필수) 활성화 필수.

플랫폼별 코드

<details> <summary>React Native (Expo)</summary>
// 설치: npm install @react-native-seoul/kakao-login
// expo prebuild 필요 (네이티브 모듈)
import { login, getProfile } from '@react-native-seoul/kakao-login';

const kakaoLogin = async () => {
  const result = await login();
  // result.accessToken → 백엔드 전송
  await api.post('/auth/social', {
    provider: 'kakao',
    token: result.accessToken,
  });
};

app.json:

{
  "expo": {
    "plugins": [
      ["@react-native-seoul/kakao-login", {
        "nativeAppKey": "YOUR_NATIVE_APP_KEY"
      }]
    ]
  }
}

GOTCHA: fallback 로직(login() 실패 시 login({ useKakaoAccountLogin: true }))을 넣으면 카카오톡 앱 로그인 성공 후 웹 로그인이 또 실행되어 동의 화면 2번 + 앱 프리징. fallback 없이 하나만 사용하세요.

</details> <details> <summary>Swift (iOS)</summary>
import KakaoSDKAuth
import KakaoSDKUser

// 카카오톡 앱 로그인 가능 여부 확인
if UserApi.isKakaoTalkLoginAvailable() {
    UserApi.shared.loginWithKakaoTalk { (oauthToken, error) in
        guard let accessToken = oauthToken?.accessToken else { return }
        AuthAPI.socialLogin(provider: "kakao", token: accessToken)
    }
} else {
    UserApi.shared.loginWithKakaoAccount { (oauthToken, error) in
        guard let accessToken = oauthToken?.accessToken else { return }
        AuthAPI.socialLogin(provider: "kakao", token: accessToken)
    }
}

Info.plist:

<key>LSApplicationQueriesSchemes</key>
<array><string>kakaokompassauth</string><string>kakaolink</string></array>
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>kakao{NATIVE_APP_KEY}</string></array>
  </dict>
</array>
</details> <details> <summary>Kotlin (Android)</summary>
// build.gradle: implementation("com.kakao.sdk:v2-user:2.20.6")
import com.kakao.sdk.user.UserApiClient

if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
    UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
        token?.accessToken?.let { sendToBackend("kakao", it) }
    }
} else {
    UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
        token?.accessToken?.let { sendToBackend("kakao", it) }
    }
}

AndroidManifest.xml:

<meta-data android:name="com.kakao.sdk.AppKey" android:value="YOUR_NATIVE_APP_KEY"/>
</details> <details> <summary>Web (JavaScript)</summary>
// <script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js"></script>
Kakao.init('YOUR_JAVASCRIPT_KEY');

Kakao.Auth.authorize({
  redirectUri: 'https://yourdomain.com/auth/kakao/callback',
  scope: 'profile_nickname,account_email',
});

// 콜백 페이지에서 authorization code 추출 → 백엔드 전송
</details> <details> <summary>백엔드 (Spring Boot)</summary>
// Kakao accessToken으로 사용자 정보 조회
public KakaoUser getKakaoUser(String accessToken) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(accessToken);
    HttpEntity<?> entity = new HttpEntity<>(headers);

    ResponseEntity<Map> response = restTemplate.exchange(
        "https://kapi.kakao.com/v2/user/me",
        HttpMethod.GET, entity, Map.class);

    Map<String, Object> kakaoAccount = (Map) response.getBody().get("kakao_account");
    String email = (String) kakaoAccount.get("email");
    Map<String, Object> profile = (Map) kakaoAccount.get("profile");
    String nickname = (String) profile.get("nickname");
    return new KakaoUser(email, nickname);
}
</details>

4. Naver

개발자 콘솔 설정

  1. 네이버 개발자센터 → Application → 등록
  2. 사용 API: 네아로(네이버 로그인) 선택
  3. 환경 추가:
    • Android: 패키지명 + 다운로드 URL
    • iOS: URL Scheme + Bundle ID
    • Web: 서비스 URL + 콜백 URL
  4. Client ID + Client Secret 확인

GOTCHA: 앱이 "개발 중" 상태이면 등록된 테스터만 로그인 가능합니다. "한 기기에서 되고 다른 기기에서 안 된다"면 이전 세션 캐시 때문일 수 있습니다. 양쪽 모두 로그아웃 후 깨끗한 상태에서 테스트하세요.

해결: 네이버 개발자센터 → 멤버관리 → 테스터 ID 추가, 또는 검수 요청.

플랫폼별 코드

<details> <summary>React Native (Expo)</summary>
// 설치: npm install @react-native-seoul/naver-login
import { NaverLogin } from '@react-native-seoul/naver-login';

const naverLogin = async () => {
  const { successResponse } = await NaverLogin.login({
    appName: 'YourApp',
    consumerKey: 'YOUR_CLIENT_ID',
    consumerSecret: 'YOUR_CLIENT_SECRET',
    serviceUrlScheme: 'yourapp',
  });
  if (successResponse) {
    await api.post('/auth/social', {
      provider: 'naver',
      token: successResponse.accessToken,
    });
  }
};
</details> <details> <summary>Swift (iOS)</summary>
import NaverThirdPartyLogin

let loginInstance = NaverThirdPartyLoginConnection.getSharedInstance()
loginInstance?.delegate = self
loginInstance?.requestThirdPartyLogin()

// NaverThirdPartyLoginConnectionDelegate
func oauth20ConnectionDidFinishRequestACTokenWithAuthCode() {
    guard let accessToken = loginInstance?.accessToken else { return }
    AuthAPI.socialLogin(provider: "naver", token: accessToken)
}
</details> <details> <summary>Kotlin (Android)</summary>
// build.gradle: implementation("com.navercorp.nid:oauth:5.10.0")
NaverIdLoginSDK.initialize(context, CLIENT_ID, CLIENT_SECRET, APP_NAME)

val launcher = registerForActivityResult(NaverIdLoginSDK.getContract()) { result ->
    when (result) {
        is NaverIdLoginResult.Success -> {
            val accessToken = NaverIdLoginSDK.getAccessToken()
            sendToBackend("naver", accessToken!!)
        }
        is NaverIdLoginResult.Failure -> { /* 에러 처리 */ }
    }
}
launcher.launch(Unit)
</details> <details> <summary>Web (JavaScript)</summary>
// 네이버 로그인 버튼 생성
var naverLogin = new naver.LoginWithNaverId({
    clientId: "YOUR_CLIENT_ID",
    callbackUrl: "https://yourdomain.com/auth/naver/callback",
    isPopup: false,
    loginButton: { color: "green", type: 3, height: 60 }
});
naverLogin.init();

// 콜백에서 access_token 추출 → 백엔드 전송
</details> <details> <summary>백엔드 (Spring Boot)</summary>
// Naver accessToken으로 사용자 정보 조회
public NaverUser getNaverUser(String accessToken) {
    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(accessToken);
    HttpEntity<?> entity = new HttpEntity<>(headers);

    ResponseEntity<Map> response = restTemplate.exchange(
        "https://openapi.naver.com/v1/nid/me",
        HttpMethod.GET, entity, Map.class);

    Map<String, Object> naverResponse = (Map) response.getBody().get("response");
    String email = (String) naverResponse.get("email");
    String name = (String) naverResponse.get("name");
    return new NaverUser(email, name);
}
</details>

종합 설정 체크리스트

소셜 로그인 키를 변경하거나 새로 설정할 때, 3곳을 반드시 동시에 확인하세요.

프론트엔드

  • Google: webClientId 설정 (상수 fallback 권장)
  • Apple: Service ID (Android/Web용)
  • Kakao: Native App Key
  • Naver: Client ID + Client Secret

백엔드

  • Google: Web Client ID + Android Client ID + iOS Client ID
  • Apple: Team ID + Key ID + .p8 파일 경로 + Service ID
  • Kakao: REST API Key
  • Naver: Client ID + Client Secret

개발자 콘솔

  • Google: Firebase SHA-1 (debug+release) → google-services.json 재다운로드
  • Apple: Service ID → Return URL 등록, Keys → .p8 발급
  • Kakao: 플랫폼(Android keyHash, iOS Bundle ID) + 동의항목 + 앱 활성화
  • Naver: 환경(Android/iOS/Web) + 콜백 URL + 테스터 등록(개발 중이면)

실전 디버깅 가이드

"모든 소셜 로그인이 안 돼요"

→ 공통 원인부터 확인:

  1. 백엔드가 살아있나? (curl 확인)
  2. 앱 → 백엔드 네트워크 연결 가능한가? (HTTP/HTTPS, cleartext 설정)
  3. 프론트-백엔드 키 값이 일치하나? (가장 흔한 원인)
  4. 환경변수가 실제로 주입되고 있나? (console.log 또는 Alert으로 확인)

에러별 빠른 진단

에러 프로바이더 원인 해결
DEVELOPER_ERROR Google SHA-1 미등록 또는 webClientId null SHA-1 등록 + google-services.json 재다운
KOE205 Kakao 동의항목 미설정 카카오 콘솔 → 동의항목 활성화
keyHash validation failed Kakao keyHash 미등록 또는 Native App Key 빈 값 런타임 Alert으로 실제 값 확인
401 Unauthorized Apple (Android) authorizationCode를 idToken으로 전송 필드 분리 (code ≠ idToken)
"개발 중 상태" Naver 앱 미검수 + 테스터 미등록 테스터 등록 또는 검수 요청
Network Error 전체 백엔드 URL 또는 네트워크 cleartext 허용 + URL 확인

디버깅 꿀팁: 런타임 값 확인

설정 문제의 90%는 "내가 넣은 값이 실제로 들어갔는지" 확인하면 풀립니다:

// 임시 디버깅 코드 (해결 후 반드시 삭제)
Alert.alert('Debug', JSON.stringify({
  googleWebClientId: GOOGLE_WEB_CLIENT_ID,
  kakaoAppKey: KAKAO_NATIVE_APP_KEY,
  naverClientId: NAVER_CLIENT_ID,
}, null, 2));

도메인/서버 변경 시 업데이트 포인트

서버 도메인이 바뀌면 (예: api.example.com → api.newdomain.com):

위치 변경 항목
Google Cloud Console 승인된 JavaScript 출처, 리디렉션 URI
Apple Developer Service ID → Return URL, Domains
Kakao 개발자센터 Web 플랫폼 도메인
Naver 개발자센터 서비스 URL, 콜백 URL
백엔드 application.yml redirect URI
프론트엔드 API base URL
Firebase Authorized domains

탈퇴(회원 삭제) 시 소셜 연결 해제

App Store/Play Store 심사에서 계정 삭제 기능이 필수입니다. 삭제 시 소셜 연결도 해제해야 합니다:

프로바이더 API 필수 여부
Google https://oauth2.googleapis.com/revoke?token={token} 권장
Apple https://appleid.apple.com/auth/revoke (POST) 필수 (심사 요건)
Kakao https://kapi.kakao.com/v1/user/unlink 권장
Naver https://nid.naver.com/oauth2.0/token?grant_type=delete 권장

참고: 이 스킬이 만들어진 배경

이 스킬은 멜라토닌 앱 프로젝트(Expo + Spring Boot)에서 2026년 2~3월, 약 한 달간 4개 소셜 로그인을 구현하며 겪은 실전 삽질 경험에서 만들어졌습니다.

초보자가 실제로 물어본 질문들:

  • "백엔드도 변경해야 하는구나? 프론트만 값을 가지고 있으면 안되는거구나?"
  • "파이어베이스도 내 걸로 해도 되겠지?" → 개발/테스트 단계에서는 OK
  • "소셜로그인이 이렇게 어려웠던가?" → 공식문서는 쉬워 보이지만, 4사 × 다중 플랫폼 조합이 복잡도를 폭발시킴
  • "iOS에서 되는데 Android에서 안 돼" → 서명 키, 캐시된 세션, 개발 상태 차이

모든 gotcha는 실제 발생한 에러에서 추출했습니다.