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 생성 |
왜 백엔드도 키가 필요한가? 프론트에서 "카카오로 로그인했어요"라고만 하면 누구나 가짜 토큰을 보낼 수 있다. 백엔드가 카카오/구글 서버에 직접 물어봐서 진짜인지 확인해야 한다.
프로바이더별 백엔드 검증 방식
| 프로바이더 | 프론트가 보내는 것 | 백엔드 검증 방식 |
|---|---|---|
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 | |
|---|---|---|---|---|
@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 |
|---|---|---|
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
개발자 콘솔 설정
- Firebase Console → 프로젝트 생성
- Android 앱 추가 → SHA-1 등록 (debug + release 둘 다!)
- iOS 앱 추가 → Bundle ID 입력
google-services.json/GoogleService-Info.plist다운로드- 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로 확인)
플랫폼별 코드
// 설치: 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';
// 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>
// 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/디렉토리에 있는지 확인.
<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 }),
});
}
// 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 전부 넣어야 합니다. 하나라도 빠지면 해당 플랫폼에서 검증 실패.
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. 두 필드를 반드시 분리하세요.
개발자 콘솔 설정
- Apple Developer → Certificates, Identifiers & Profiles
- App ID 생성 → Sign in with Apple 활성화
- Service ID 생성 (Android/Web용) → Configure:
- Domains:
yourdomain.com - Return URLs:
https://yourdomain.com/auth/apple/callback
- Domains:
- Keys 생성 → APNs + Sign in with Apple 체크 →
.p8파일 다운로드
GOTCHA: Apple Developer License Agreement에 동의 안 하면 콘솔 기능 전체가 잠깁니다. 작업 시작 전 콘솔 접속부터 확인하세요. 연 1회 갱신 필요.
GOTCHA:
.p8파일은 딱 1번만 다운로드 가능합니다. 분실하면 새 Key 생성 필수.
플랫폼별 코드
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!
});
}
}
};
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회만 줍니다. 놓치면 앱 삭제 후 재설치해야 다시 받을 수 있습니다.
// 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 추출 → 백엔드 전송
// 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개월. 갱신 로직 필요.
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
개발자 콘솔 설정
- 카카오 개발자센터 → 내 애플리케이션
- 앱 생성 → 앱 키 확인:
- Native App Key (모바일 SDK용)
- REST API Key (백엔드 검증용)
- 플랫폼 등록:
- Android: 패키지명 + keyHash (debug/release 각각)
- iOS: Bundle ID
- Web: 도메인
- 카카오 로그인 활성화 + 동의항목 설정 (이메일, 프로필)
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): "잘못된 요청" 에러는 동의항목 미설정입니다. 카카오 개발자센터 → 동의항목 → 이메일(선택), 프로필(필수) 활성화 필수.
플랫폼별 코드
// 설치: 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 없이 하나만 사용하세요.
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>
// 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"/>
// <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 추출 → 백엔드 전송
// 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);
}
4. Naver
개발자 콘솔 설정
- 네이버 개발자센터 → Application → 등록
- 사용 API: 네아로(네이버 로그인) 선택
- 환경 추가:
- Android: 패키지명 + 다운로드 URL
- iOS: URL Scheme + Bundle ID
- Web: 서비스 URL + 콜백 URL
- Client ID + Client Secret 확인
GOTCHA: 앱이 "개발 중" 상태이면 등록된 테스터만 로그인 가능합니다. "한 기기에서 되고 다른 기기에서 안 된다"면 이전 세션 캐시 때문일 수 있습니다. 양쪽 모두 로그아웃 후 깨끗한 상태에서 테스트하세요.
해결: 네이버 개발자센터 → 멤버관리 → 테스터 ID 추가, 또는 검수 요청.
플랫폼별 코드
// 설치: 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,
});
}
};
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)
}
// 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)
// 네이버 로그인 버튼 생성
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 추출 → 백엔드 전송
// 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);
}
종합 설정 체크리스트
소셜 로그인 키를 변경하거나 새로 설정할 때, 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 + 테스터 등록(개발 중이면)
실전 디버깅 가이드
"모든 소셜 로그인이 안 돼요"
→ 공통 원인부터 확인:
- 백엔드가 살아있나? (
curl확인) - 앱 → 백엔드 네트워크 연결 가능한가? (HTTP/HTTPS, cleartext 설정)
- 프론트-백엔드 키 값이 일치하나? (가장 흔한 원인)
- 환경변수가 실제로 주입되고 있나? (
console.log또는Alert으로 확인)
에러별 빠른 진단
| 에러 | 프로바이더 | 원인 | 해결 |
|---|---|---|---|
DEVELOPER_ERROR |
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 | 필수 여부 |
|---|---|---|
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는 실제 발생한 에러에서 추출했습니다.