
백엔드 부분 구현 및 연동에 앞서, 먼저 네이버 소셜 로그인 구현 과정을 정리해두려 한다.
React Native + Expo 환경에서 안드로이드 네이버 로그인을 구현하는 글이 생각보다 잘 안 보여서, 직접 겪은 과정을 빠르게 기록해두려 한다. 구현에는 @react-native-seoul/naver-login 라이브러리를 사용했다.

모바일과 백엔드가 역할을 나눠서 처리하는 구조로 설계했다.
모바일 앱
→ 네이버 SDK로 로그인
→ accessToken을 백엔드로 전송
백엔드
→ 받은 토큰으로 네이버 API 호출 (사용자 프로필 조회)
→ DB에 사용자 저장/업데이트
→ JWT 발급 후 앱으로 반환
모바일 앱
→ 로그인 완료
모바일은 토큰만 받아서 넘기고, 프로필 조회와 DB 저장은 전부 백엔드에서 처리한다. 역할이 명확하게 분리되어 있어서 구조 자체는 깔끔했다.
웹 소셜 로그인만 해보다가 앱을 해보았는데 복잡하지 않고 생각보다 훨씬 편리했다!!!
먼저 app.json에 네이버 로그인 플러그인을 추가해준다.
{
"expo": {
"android": {
"package": "com.moti.app"
},
"ios": {
"bundleIdentifier": "com.moti.app"
},
"plugins": [
"expo-router",
[
"@react-native-seoul/naver-login",
{
"urlScheme": "com.moti.app"
}
]
]
}
}
urlScheme은android.package,ios.bundleIdentifier와 동일한 값으로 맞춰줘야 한다.
로그인 레이아웃에서 SDK를 초기화해준다.
// app/auth/_layout.tsx
import NaverLogin from '@react-native-seoul/naver-login';
import { Stack } from 'expo-router';
import { useEffect } from 'react';
export default function AuthLayout() {
const NAVER_CLIENT_ID = process.env.EXPO_PUBLIC_NAVER_CLIENT_ID;
const NAVER_CLIENT_SECRET = process.env.EXPO_PUBLIC_NAVER_CLIENT_SECRET;
useEffect(() => {
NaverLogin.initialize({
appName: 'moti',
consumerKey: NAVER_CLIENT_ID,
consumerSecret: NAVER_CLIENT_SECRET,
serviceUrlSchemeIOS: 'com.moti.app',
disableNaverAppAuthIOS: true, // 네이버 앱 없이 웹으로 로그인
});
}, []);
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'fade',
contentStyle: { backgroundColor: '#F9F5EB' },
}}
>
<Stack.Screen name="login" />
</Stack>
);
}
// app/auth/login.tsx
import NaverLogin from '@react-native-seoul/naver-login';
import { useAuthStore } from '@store/authStore';
import { authApi } from '@services/api';
import { useState } from 'react';
import { Alert, TouchableOpacity, Image } from 'react-native';
import { router } from 'expo-router';
export default function LoginScreen() {
const { loginWithNaver } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const handleNaverLogin = async () => {
setIsLoading(true);
try {
// 1. 네이버 SDK로 로그인
const naverResponse = await NaverLogin.login();
if (naverResponse.isSuccess && naverResponse.successResponse) {
const { accessToken } = naverResponse.successResponse;
// 2. 토큰만 백엔드로 전송
const { user } = await authApi.naverLogin(accessToken);
// 3. 사용자 정보 저장
loginWithNaver(user);
// 4. 메인 화면으로 이동
router.replace('/(tabs)');
} else if (naverResponse.failureResponse) {
throw new Error(naverResponse.failureResponse.message || '로그인에 실패했습니다');
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '로그인에 실패했습니다';
Alert.alert('로그인 실패', errorMessage);
} finally {
setIsLoading(false);
}
};
return (
<TouchableOpacity onPress={handleNaverLogin} disabled={isLoading}>
<Image
source={require('@/assets/images/NAVER_login_button.png')}
style={{ width: '100%', height: 48 }}
resizeMode="contain"
/>
</TouchableOpacity>
);
}
// src/services/api.ts
import { Platform } from 'react-native';
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:4000';
const getApiUrl = (): string => {
if (Platform.OS === 'android' && API_URL.includes('localhost')) {
return API_URL.replace('localhost', '10.0.2.2');
}
return API_URL;
};
const apiClient = axios.create({
baseURL: `${getApiUrl()}/api`,
});
export const authApi = {
async naverLogin(accessToken: string) {
const response = await apiClient.post('/auth/naver', { accessToken });
if (response.data.success && response.data.data) {
const { user, tokens } = response.data.data;
// 토큰 저장
await tokenStorage.setTokens(tokens.accessToken, tokens.refreshToken);
return { user, tokens };
}
throw new Error(response.data.error?.message || 'Login failed');
},
};
POST /api/auth/naver로 accessToken을 받아서 처리한다.
GET https://openapi.naver.com/v1/nid/me) 호출네이버 API 응답 예시:
{
"resultcode": "00",
"message": "success",
"response": {
"id": "12345678",
"email": "user@naver.com",
"nickname": "닉네임",
"name": "홍길동",
"profile_image": "https://..."
}
}
앱으로 반환하는 응답 예시:
{
"success": true,
"data": {
"user": {
"id": "clxxx...",
"naverId": "12345678",
"naverEmail": "user@naver.com",
"naverName": "홍길동",
"naverProfileImage": "https://..."
},
"tokens": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci...",
"expiresIn": 900
}
}
}
네이티브 모듈을 사용하는 경우 Expo Go에서는 테스트가 안 되기 때문에 프리빌드 후 에뮬레이터로 실행해야 한다.
npx expo prebuild # iOS / android 폴더 생성
npx expo run:android
1. JDK 미설치 오류
npx expo run:android 실행 시 Unable to locate a Java Runtime 오류가 발생했다. JDK가 설치되어 있지 않아서 생기는 문제로, 아래 명령어로 설치 후 재시도했다.
brew install openjdk@17
2. 테스트 기기 미실행 오류
안드로이드 스튜디오에서 에뮬레이터(테스트 기기)를 켜두지 않으면 오류가 발생한다. npx expo run:android 전에 에뮬레이터를 먼저 실행해두자.
3. 안드로이드 스튜디오 경로 오류
안드로이드 스튜디오에서 프로젝트를 열 때 루트 폴더가 아닌 android 폴더가 있는 경로로 열어야 한다.
빌드가 성공적으로 진행 중이라면 아래 아래 이미지가 보일것이다.

빌드가 성공하면 기존 exp://192.168.x.x:8081 경로에서 com.moti.app://expo-development-client/?url=http://192.168.x.x:8081로 바뀌고, Using Expo Go → Using development build로 변경된 것을 확인할 수 있다.
처음에 SDK 초기화를 어디서 해야 할지 몰라서 좀 헤맸다. app.json 플러그인 설정을 빠뜨리면 빌드 자체가 제대로 안 되고, initialize()를 너무 늦게 호출하면 로그인 시도 시 오류가 발생한다. 로그인 화면 레이아웃의 useEffect에서 초기화하는 게 가장 자연스러웠다.
가장 오래 걸렸던 부분이다.
안드로이드 에뮬레이터에서는 localhost가 호스트 머신이 아닌 에뮬레이터 자신을 가리킨다. 그래서 백엔드 서버가 분명히 실행 중인데도 API 요청이 계속 실패했다.
해결책은 안드로이드일 때 localhost를 10.0.2.2로 바꿔주는 것이다. 10.0.2.2는 안드로이드 에뮬레이터에서 호스트 머신을 가리키는 특수 IP다.
const getApiUrl = (): string => {
if (Platform.OS === 'android' && API_URL.includes('localhost')) {
return API_URL.replace('localhost', '10.0.2.2');
}
return API_URL;
};
iOS 시뮬레이터는 localhost 그대로 써도 되는데 안드로이드만 이렇게 처리해줘야 한다는 게 포인트다.
서버는 멀쩡히 켜져 있는데 요청이 안 된다면 이것부터 의심해보자.
백엔드를 처음 구현해봤는데, 네이버 로그인 자체보다 에뮬레이터 실행이 안 돼서 환경 차이를 파악하는 데 시간이 더 걸렸다ㅜㅜㅜㅠ 다음 글에서는 아마도? 백엔드 설계에 대해 작성해볼 예정이다. 최대한 빨리 정리하고 싶은데, 제대로 이해한 다음에 쓰고 싶어 미뤄지는 것 같다...