React Native + Expo 세팅 검은 화면부터 iOS 터널링까지 삽질기

궁금하면 500원·2026년 3월 6일

미생의 개발 이야기

목록 보기
71/81


React Native Expo Go 크로스 플랫폼 개발 환경 구축 트러블슈팅 기록

프로젝트 개요

위치 기반 메시징 앱의 모바일 클라이언트를 React Native로 개발하면서, Android 에뮬레이터와 iOS 실기기 양쪽에서 실행 환경을 구축하는 과정에서 마주친 문제들과 해결 과정을 정리한 문서이다.
백엔드는 Spring Boot + Kotlin으로 구성되어 있으며, 모바일 클라이언트는 Expo SDK 기반의 Managed Workflow를 채택했다.

기술 스택은 다음과 같다.

  • 모바일: React Native 0.76 → 0.83, Expo SDK 52 → 55, TypeScript
  • 빌드 환경: Windows 11, Pixel 4 / Pixel Fold 에뮬레이터, iPhone 실기기
  • 패키지 매니저: pnpm 9.x

1. Android 에뮬레이터와 ADB 연결 불안정 문제

문제 상황

프로젝트를 처음 실행했을 때, pnpm start:android 스크립트가 adb reverseexpo start를 동시에 실행하도록 구성되어 있었다.
그런데 에뮬레이터가 아직 부팅 완료되기 전에 adb 명령이 먼저 실행되면서 adb.exe: no devices/emulators found 에러가 반복적으로 발생했다.

package.json의 scripts를 보면 "start:android": "adb reverse tcp:9080 tcp:9080 & expo start --android --port 9080" 이렇게 & 연산자로 두 명령을 병렬 실행하고 있었는데, 이 방식은 adb reverse의 성공 여부와 무관하게 expo start가 동시에 실행되기 때문에 race condition이 발생할 수밖에 없는 구조였다.

해결 과정

결국 자동화 스크립트에 의존하지 않고, 수동으로 단계별 실행 순서를 확립하는 것이 가장 안정적이었다.
먼저 Android Studio Device Manager에서 에뮬레이터를 실행하고, 홈 화면이 완전히 로드될 때까지 기다린다.
그 다음 adb devices 명령으로 emulator-5554 device 상태를 확인한 후에야 Metro 번들러를 시작한다.

추가로 adb 서버 자체가 꼬여서 에뮬레이터를 인식하지 못하는 경우도 있었는데, 이때는 adb kill-serveradb start-server로 데몬을 재시작하면 해결되었다.

교훈

CI/CD가 아닌 로컬 개발 환경에서는 "에뮬레이터 부팅 완료 → adb 연결 확인 → Metro 실행"이라는 순차적 워크플로우를 체화하는 것이 결국 가장 빠르다.
스크립트 자동화는 에뮬레이터 부팅 상태를 polling하는 로직을 추가하지 않는 한, 오히려 디버깅 시간을 늘릴 수 있다.


2. Android API 36과 Expo Go 호환성으로 인한 검은 화면

문제 상황

에뮬레이터에서 앱을 실행하면 Metro 번들링은 성공하고(Android Bundled 16063ms index.js (816 modules)), 터미널에는 에러 로그가 전혀 찍히지 않는데, 에뮬레이터 화면은 완전히 검은 상태로 아무것도 렌더링되지 않았다.

이 문제가 까다로웠던 이유는, 로그상으로는 모든 것이 정상이었기 때문이다.
Bridgeless mode is enabled이라는 로그와 expo-notifications 관련 경고만 출력될 뿐, 실질적인 에러 메시지가 없었다.
처음에는 앱 코드의 문제를 의심하여 path alias 설정 @application/*, @infrastructure/* 등 이나 babel.config.js의 module-resolver 설정, 그리고 각 레이어별 파일 존재 여부까지 전부 확인했지만 문제가 없었다.

원인 분석

문제의 범위를 좁히기 위해 최소 재현 테스트를 수행했다.
App.tsx를 가장 단순한 형태로 교체하여 환경 문제인지 코드 문제인지를 구분하는 전략이었다.

import React from 'react';
import { View, Text } from 'react-native';

export default function App() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#fff' }}>
      <Text style={{ fontSize: 24 }}>Hello Test!</Text>
    </View>
  );
}

이 최소 코드로도 검은 화면이 그대로 유지되었기 때문에 앱 코드가 아닌 실행 환경 자체의 문제임을 확정할 수 있었다.
최종적으로, Pixel_4_API_36 에뮬레이터가 Expo Go와 호환되지 않는 것이 원인이었다. Android Studio SDK Manager에서 Android 14.0을 추가 설치하고, Pixel Fold + API 34 구성으로 새 에뮬레이터를 생성하자 "Hello Test!" 텍스트가 정상적으로 렌더링되었다.

교훈

새로운 개발 환경을 세팅할 때, 최신 API 레벨이 반드시 최선은 아니다.
Expo Go는 Managed Workflow 특성상 특정 SDK 버전과 긴밀하게 결합되어 있으므로, Expo 공식 문서에서 권장하는 API 레벨을 먼저 확인하는 것이 좋다.
또한 검은 화면처럼 에러 메시지가 없는 문제를 만났을 때, 최소 재현 코드로 환경 vs 코드 문제를 먼저 분리하는 접근법은 디버깅 시간을 크게 단축시킨다.


3. Expo Go APK 수동 설치와 Android 권한 문제

문제 상황

API 34 에뮬레이터로 전환한 후에도, Expo Go 버전 불일치 문제가 반복되었다.
Metro를 시작할 때마다 "Expo Go 2.32.20 is recommended for SDK 52.0.0"이라는 메시지가 나타나면서 재설치를 시도하는데, 이 과정에서 Expo Go가 삭제된 후 재설치에 실패하는 경우가 있었다.
심지어 한 번은 Attempt to invoke virtual method 'int java.lang.String.length()' on a null object reference라는 Java NullPointerException으로 앱이 크래시되기도 했다.

에뮬레이터 내부의 Chrome 브라우저에서 expo.dev/go 페이지를 통해 APK를 직접 다운로드하려 했으나, 에뮬레이터의 네트워크가 불안정하여 오프라인 캐시 페이지가 표시되었다.
APK 파일 자체는 /sdcard/Download/에 존재했지만, adb shell pm install 명령으로 설치하려 하면 SELinux 권한 거부 avc: denied에러가 발생했다.

해결 과정

에뮬레이터 내부에서의 직접 설치를 포기하고, PC 로컬로 APK를 pull한 뒤 다시 install하는 우회 방법을 사용했다.

adb pull /sdcard/Download/Expo-Go-2.32.20.apk ./expo-go.apk
adb install ./expo-go.apk

이 방식이 작동한 이유는, adb install은 PC에서 에뮬레이터로 스트리밍 방식으로 APK를 전송하면서 설치하기 때문에 에뮬레이터 내부의 파일 시스템 권한 문제를 우회할 수 있기 때문이다.

교훈

에뮬레이터 환경에서 APK를 직접 설치할 때는 /data/local/tmp/에 복사 후 설치하거나, 호스트 PC를 경유하는 방법이 안정적이다.
에뮬레이터 내부의 /sdcard/ 경로에 있는 파일은 fuse 파일시스템을 통해 마운트되므로, system_server 프로세스가 직접 읽지 못하는 SELinux 정책 제한이 있을 수 있다.
이는 실제 기기에서는 발생하지 않는 에뮬레이터 특유의 문제이다.


4. 네트워크 격리와 SDK 버전 불일치 iOS 실기기 연결

문제 상황

Android 에뮬레이터에서 정상 동작을 확인한 후, 위치 추적 기능의 실제 GPS 테스트를 위해 iPhone 실기기에서의 실행을 시도했다.
같은 WiFi 네트워크에 연결된 상태에서 QR 코드를 스캔했지만 "The Internet connection appears to be offline"이라는 에러가 발생했다.

Windows 방화벽에서 9080 포트를 인바운드 허용 규칙으로 추가했음에도 동일한 문제가 지속되었다.
Safari에서 http://192.168.45.215:9080에 직접 접속해보니 웹 페이지 자체는 정상적으로 로드되었으나, Expo Go 프로토콜로 연결하면 번들 다운로드에 실패했다.

원인 분석 및 해결

네트워크 접근성이 HTTP 레벨에서는 확인되었으므로, AP 격리보다는 Expo Go의 번들 요청 경로에서 타임아웃이 발생하는 것으로 판단했다.
이를 해결하기 위해 ngrok 터널링을 도입했다.

처음에는 별도 터미널에서 ngrok http 9080을 실행하고 발급된 URL을 exp:// 스킴으로 변환하여 Safari에서 입력하는 방식을 시도했다.
이 방식은 ngrok이 포워딩하는 포트와 Expo Go가 내부적으로 붙이는 포트가 중복되면서 index.bundle 요청이 https://xxx.ngrok-free.app:9080/index.bundle로 잘못 라우팅되는 문제가 있었다.

최종적으로는 Expo의 내장 tunnel 모드를 사용하는 것이 올바른 방법이었다.
이 모드에서는 Expo CLI가 내부적으로 @expo/ngrok을 사용하여 Metro 번들러와 ngrok 터널 간의 포트 매핑을 자동으로 처리해주기 때문에 포트 중복 문제가 발생하지 않는다.

pnpm add -D @expo/ngrok
npx expo start --tunnel --port 9080

iOS Expo Go SDK 버전 불일치

ngrok 터널이 정상적으로 연결된 후에도, iOS Expo Go와 프로젝트 SDK 버전 간 불일치 문제가 추가로 발생했다.
핵심적인 차이점은 Android에서는 특정 SDK 버전에 맞는 Expo Go APK를 수동으로 설치할 수 있지만, iOS App Store에서는 항상 최신 버전만 제공된다는 것이다.

당시 프로젝트는 SDK 52였고 iOS Expo Go는 SDK 54를 요구했기 때문에, 프로젝트 SDK를 업그레이드해야 했다.

npx expo install expo@latest --fix

이 명령은 expo 코어뿐 아니라 react, react-native, expo-location, expo-notifications 등 의존 패키지들도 호환 버전으로 일괄 업데이트해준다.
SDK 52에서 55로의 메이저 업그레이드였기 때문에 React 18 → 19, React Native 0.76 → 0.83 등 상당한 변경 되었다.

교훈

크로스 플랫폼 Expo 프로젝트에서 iOS 실기기 테스트를 계획하고 있다면, 프로젝트 초기 단계부터 iOS Expo Go의 현재 SDK 버전을 확인하고 그에 맞춰 프로젝트를 세팅하는 것이 바람직하다 생각이 들었다.

iOS는 Android처럼 구버전 Expo Go를 사이드로딩할 수 없으므로, SDK 버전 선택이 곧 iOS 테스트 가능 여부를 결정한다.
또한 ngrok 터널링은 같은 네트워크 환경이 보장되지 않는 상황에서도 유용하므로, 개발 초기에 @expo/ngrok을 devDependency로 포함시켜두는 것을 배우게되었다


5. expo-doctor를 활용한 프로젝트 설정 검증

문제 상황

검은 화면 디버깅 과정에서 npx expo-doctor를 실행하여 프로젝트 설정을 점검했더니, 두 가지 문제가 발견되었다.

첫 번째는 app.json의 android 섹션에 usesCleartextTraffic이라는 비표준 속성이 포함되어 있었다는 점이다.
이 속성은 Expo의 config schema에 정의되어 있지 않기 때문에 빌드 시 무시되거나 예기치 않은 동작을 유발할 수 있다. HTTP 평문 통신을 허용하려면 app.json의 android 섹션이 아니라, 별도의 네트워크 보안 설정이나 development build 설정에서 처리해야 한다.

두 번째는 프로젝트에 app.jsonapp.config.js가 동시에 존재하면서, app.config.jsapp.json의 값을 제대로 반영하지 못하고 있었다는 점이다.
실제로 app.config.jsrequire('./app.json')으로 값을 가져오고 있었지만, Expo CLI는 app.config.js가 존재하면 app.json을 직접 읽지 않기 때문에, 두 파일 간의 동기화가 깨질 위험이 있었다.

교훈

Expo 프로젝트를 세팅한 후에는 npx expo-doctor를 한 번 실행하여 설정 파일의 schema 위반이나 중복 설정 문제를 사전에 잡아내는 것이 좋다. 특히 app.jsonapp.config.js를 동시에 사용하는 경우, 동적 설정(app.config.js)에서 정적 설정(app.json)을 import하는 구조가 Expo의 설정 로딩 우선순위와 맞는지 확인해야 한다.


마무리

이번 환경 구축 과정에서 가장 큰 시간을 소모한 부분은 "에러 메시지가 없는 문제"였다.
API 36 에뮬레이터의 검은 화면은 터미널 로그상으로 완전히 정상이었기 때문에, 코드 레이어를 하나씩 벗겨가며 원인을 찾아야 했다.
이 경험을 통해 체득한 디버깅을 원칙으로 해야한다는것을 배웠다.

첫째, 최소 재현 코드로 문제 범위를 축소하는 것이 가장 효율적인 첫 번째 단계이다. App.tsx를 "Hello Test!"로 교체하는 단 한 번의 테스트로, 몇 시간에 걸친 코드 레벨 디버깅을 건너뛸 수 있었다.

둘째, 최신 버전이 항상 최선은 아니다. API 36은 최신이었지만 Expo Go와의 호환성이 검증되지 않았고, SDK 52로 시작한 프로젝트는 iOS 테스트를 위해 결국 SDK 55까지 올려야 했다. 프로젝트 초기에 타겟 플랫폼의 Expo Go 지원 현황을 확인하고 SDK 버전을 결정하는 것이 나중에 발생할 마이그레이션 비용을 줄인다는것을 배우게되었다.

셋째, 네트워크 레이어의 문제는 계층적으로 검증해야 한다.
같은 WiFi인데도 Expo Go 연결이 안 될 때, HTTP 레벨에서의 접근성을 먼저 확인하고, 그 위에 Expo 프로토콜의 동작을 확인하는 순서로 문제를 좁혀나가야 했는데 너무 2일동안
바보같은짓을 하면서 배운거라 많이 배우게되었다.

마지막으로, 도구의 자동화에 지나치게 의존하기보다 각 도구가 내부적으로 무엇을 하는지 이해하는 것이 중요하다.
ngrok이 포트를 어떻게 매핑하는지, adb installadb shell pm install의 차이가 무엇인지, Expo Go가 exp:// URL에서 번들을 어떤 경로로 요청하는지를 파악하고 있으면,
예상치 못한 문제가 발생했을 때 근본 원인에 훨씬 빠르게 도달할 수 있다.

AI 도구가 개발 생산성을 크게 높여주는 것은 사실이다.
최근에는 코드 생성, 디버깅, 아키텍처 제안까지 사람보다 빠르게 도움을 줄 때도 많다.
하지만 실제로 기능을 구현하고 문제를 해결하는 과정에서 느낀 점은 기본기와 스스로 사고하는 과정의 중요성이다.

이번에 처음 접한 React Native 환경에서 특정 기능을 직접 구현하면서, AI 도움 없이 기능 정의부터 테스트, 에러 해결까지 스스로 진행해봤다.
결과적으로 하나의 기능을 안정적으로 동작시키기까지 약 하루 정도의 시간이 걸렸다.
시간만 놓고 보면 AI를 활용했다면 훨씬 빠르게 해결했을 가능성이 높다.
하지만 이 과정을 통해 플랫폼의 동작 방식, 에러가 발생하는 구조, 문제 해결 접근 방식을 더 깊이 이해할 수 있었다.

개발을 하다 보면 AI에 지나치게 의존하게 되는 순간이 생기는데,
이번 경험을 통해 AI는 도구일 뿐이고, 문제를 정의하고 해결 방향을 판단하는 능력은 결국 개발자 본인의 역량이라는 것을 다시 한번 느꼈다.
앞으로도 AI를 적극적으로 활용하되,
단순히 답을 받아 사용하는 것이 아니라 스스로 먼저 고민하고 구조를 이해한 뒤 도구로 활용하는 방식이 장기적으로 더 좋은 개발자가 되는 길이라고 생각한다.

이번 경험은 개인적으로 기본기와 사고력의 중요성을 다시 확인한 의미 있는 시간이었다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글