React Native Expo 안드로이드 네이버 로그인 구현하기

2_현주·2026년 3월 18일
post-thumbnail

백엔드 부분 구현 및 연동에 앞서, 먼저 네이버 소셜 로그인 구현 과정을 정리해두려 한다.

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

실제 로그인 구현 화면


전체 흐름

모바일과 백엔드가 역할을 나눠서 처리하는 구조로 설계했다.

모바일 앱
  → 네이버 SDK로 로그인
  → accessToken을 백엔드로 전송
백엔드
  → 받은 토큰으로 네이버 API 호출 (사용자 프로필 조회)
  → DB에 사용자 저장/업데이트
  → JWT 발급 후 앱으로 반환
모바일 앱
  → 로그인 완료

모바일은 토큰만 받아서 넘기고, 프로필 조회와 DB 저장은 전부 백엔드에서 처리한다. 역할이 명확하게 분리되어 있어서 구조 자체는 깔끔했다.
웹 소셜 로그인만 해보다가 앱을 해보았는데 복잡하지 않고 생각보다 훨씬 편리했다!!!


모바일 구현

Expo 설정

먼저 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"
        }
      ]
    ]
  }
}

urlSchemeandroid.package, ios.bundleIdentifier와 동일한 값으로 맞춰줘야 한다.

네이버 SDK 초기화

로그인 레이아웃에서 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>
  );
}

API 클라이언트

// 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을 받아서 처리한다.

  1. 받은 토큰으로 네이버 API(GET https://openapi.naver.com/v1/nid/me) 호출
  2. 응답으로 받은 사용자 프로필로 DB 저장/업데이트
  3. JWT 발급 후 반환

네이버 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 GoUsing development build로 변경된 것을 확인할 수 있다.


막혔던 부분들

네이버 SDK 설정

처음에 SDK 초기화를 어디서 해야 할지 몰라서 좀 헤맸다. app.json 플러그인 설정을 빠뜨리면 빌드 자체가 제대로 안 되고, initialize()를 너무 늦게 호출하면 로그인 시도 시 오류가 발생한다. 로그인 화면 레이아웃의 useEffect에서 초기화하는 게 가장 자연스러웠다.

안드로이드 에뮬레이터 localhost 문제

가장 오래 걸렸던 부분이다.

안드로이드 에뮬레이터에서는 localhost가 호스트 머신이 아닌 에뮬레이터 자신을 가리킨다. 그래서 백엔드 서버가 분명히 실행 중인데도 API 요청이 계속 실패했다.

해결책은 안드로이드일 때 localhost10.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 그대로 써도 되는데 안드로이드만 이렇게 처리해줘야 한다는 게 포인트다.

서버는 멀쩡히 켜져 있는데 요청이 안 된다면 이것부터 의심해보자.


마치며

백엔드를 처음 구현해봤는데, 네이버 로그인 자체보다 에뮬레이터 실행이 안 돼서 환경 차이를 파악하는 데 시간이 더 걸렸다ㅜㅜㅜㅠ 다음 글에서는 아마도? 백엔드 설계에 대해 작성해볼 예정이다. 최대한 빨리 정리하고 싶은데, 제대로 이해한 다음에 쓰고 싶어 미뤄지는 것 같다...

profile
프론드엔드 개발자 이현주 입니다.

0개의 댓글