social-login · git:20260402.b02765c · 2026-04-02 · sha256 a66193100e949d25
social-login git:20260402.b02765cB
Immutable. This exact content is served forever at /api/v1/blob/a66193100e949d25.
---
name: social-login
description: Social Login Implementation Guide. 소셜 로그인(Google, Apple, Kakao, Naver) 구현 가이드. 트리거: "소셜 로그인", "social login", "카카오 로그인", "구글 로그인", "애플 로그인", "네이버 로그인", "DEVELOPER_ERROR", "KOE205", "keyHash".
---
# 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](https://console.firebase.google.com/) → 프로젝트 생성
2. Android 앱 추가 → **SHA-1 등록** (debug + release 둘 다!)
3. iOS 앱 추가 → Bundle ID 입력
4. `google-services.json` / `GoogleService-Info.plist` 다운로드
5. [Google Cloud Console](https://console.cloud.google.com/) → API 및 서비스 → 사용자 인증 정보
- Web Client ID (백엔드 검증용)
- Android Client ID (SHA-1별로)
- iOS Client ID
#### SHA-1 확인 방법
```bash
# 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>
```typescript
// 설치: 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 필수**.
> ```typescript
> const WEB_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID
> || 'fallback-value.apps.googleusercontent.com';
> ```
</details>
<details>
<summary>Swift (iOS)</summary>
```swift
// GoogleSignIn pod 설치
import GoogleSignIn
func googleLogin() {
guard let presentingVC = UIApplication.shared.windows.first?.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 추가:
```xml
<key>GIDClientID</key>
<string>YOUR_IOS_CLIENT_ID.apps.googleusercontent.com</string>
```
</details>
<details>
<summary>Kotlin (Android)</summary>
```kotlin
// 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>
```html
<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>
```
```javascript
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>
```java
// 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](https://developer.apple.com/) → 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>
```typescript
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>
```swift
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>
```kotlin
// 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>
```java
// 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) 기능이 필수**입니다:
```java
// 백엔드: 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. [카카오 개발자센터](https://developers.kakao.com/) → 내 애플리케이션
2. 앱 생성 → 앱 키 확인:
- **Native App Key** (모바일 SDK용)
- **REST API Key** (백엔드 검증용)
3. 플랫폼 등록:
- Android: 패키지명 + **keyHash** (debug/release 각각)
- iOS: Bundle ID
- Web: 도메인
4. **카카오 로그인** 활성화 + **동의항목** 설정 (이메일, 프로필)
#### keyHash 등록
```bash
# 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`를 리턴합니다.
> 런타임에 실제 값을 확인하세요:
> ```typescript
> import { getKeyHashAndroid } from '@react-native-seoul/kakao-login';
> const hash = await getKeyHashAndroid();
> Alert.alert('KeyHash', hash); // 이 값을 카카오 콘솔에 등록
> ```
> **GOTCHA (KOE205)**: "잘못된 요청" 에러는 **동의항목 미설정**입니다.
> 카카오 개발자센터 → 동의항목 → 이메일(선택), 프로필(필수) 활성화 필수.
#### 플랫폼별 코드
<details>
<summary>React Native (Expo)</summary>
```typescript
// 설치: 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`:
```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>
```swift
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`:
```xml
<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>
```kotlin
// 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`:
```xml
<meta-data android:name="com.kakao.sdk.AppKey" android:value="YOUR_NATIVE_APP_KEY"/>
```
</details>
<details>
<summary>Web (JavaScript)</summary>
```javascript
// <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>
```java
// 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. [네이버 개발자센터](https://developers.naver.com/) → 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>
```typescript
// 설치: 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>
```swift
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>
```kotlin
// 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>
```javascript
// 네이버 로그인 버튼 생성
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>
```java
// 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%는 **"내가 넣은 값이 실제로 들어갔는지"** 확인하면 풀립니다:
```typescript
// 임시 디버깅 코드 (해결 후 반드시 삭제)
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는 실제 발생한 에러에서 추출했습니다.