Expo로 React Native 앱 만들기: Expo Router, Expo SDK, EAS Build, OTA 업데이트

이 글의 핵심

Expo로 프로젝트를 만들고 Expo Router로 화면을 구성하는 법, 카메라·알림 같은 Expo SDK 기능, EAS Build로 스토어용 빌드 만들기, OTA 업데이트와 환경 변수 관리를 다룹니다.

이 글의 핵심

Expo로 React Native 앱을 개발하는 흐름을 정리한 글입니다. Managed Workflow, EAS Build, OTA Updates, 배포를 예제로 다룹니다.

실무에서 마주치는 문제들

Xcode, Android Studio 설정이 어려워요

순수 React Native 프로젝트는 ios/와 android/ 폴더에 네이티브 프로젝트가 들어 있어서, CocoaPods, Gradle, JDK 버전, Android SDK 경로가 하나만 어긋나도 빌드가 실패합니다. React Native 버전을 올릴 때마다 이 네이티브 파일의 변경 사항을 손으로 반영해야 하는 것도 부담입니다. Expo는 네이티브 프로젝트를 app.json 설정과 플러그인으로부터 필요할 때 생성하는 방식(Continuous Native Generation)이라, 개발자가 네이티브 파일을 직접 관리하지 않아도 됩니다.

빌드가 어려워요

iOS 앱을 빌드하려면 macOS와 Xcode가 필요하고, 배포하려면 인증서와 프로비저닝 프로필을 관리해야 합니다. Windows나 Linux 개발자에게는 iOS 빌드 자체가 불가능합니다. EAS Build는 Expo의 클라우드 서버에서 빌드하고, 서명 자격 증명도 대신 생성·보관해 줍니다.

업데이트가 느려요

작은 문구 수정이나 버그 수정도 스토어 심사를 거쳐야 하고, 사용자가 앱을 업데이트해야 반영됩니다. EAS Update는 앱의 JavaScript 번들과 이미지 같은 에셋만 서버에서 내려받아 교체하므로, 네이티브 코드가 바뀌지 않는 수정은 심사 없이 배포할 수 있습니다.

Expo를 쓴다고 네이티브 기능에 제약이 생기는 것은 아닙니다. 예전에는 Expo SDK에 없는 네이티브 모듈을 쓰려면 “eject”로 Expo를 벗어나야 했지만, 지금은 Config Plugin과 개발 빌드(development build)로 대부분의 서드파티 네이티브 라이브러리를 Expo 프로젝트 안에서 쓸 수 있습니다. 대신 이 경우 Expo Go 앱으로는 테스트할 수 없고 직접 만든 개발 빌드가 필요하다는 점이 처음에 헷갈리는 부분입니다.


Expo란?

핵심 특징

Expo는 React Native 개발 플랫폼입니다. 주요 장점:

  • Managed Workflow: Native 설정 불필요
  • EAS Build: 클라우드 빌드
  • OTA Updates: 즉시 업데이트
  • Expo Go: 실기기 테스트
  • 풍부한 API: 카메라, 위치, 알림

프로젝트 생성

npx create-expo-app my-app
cd my-app
npx expo start

실행

  • iOS: Expo Go 앱에서 QR 코드 스캔
  • Android: Expo Go 앱에서 QR 코드 스캔
  • Web: w 키 입력

npx expo start는 Metro 번들러를 띄우고 QR 코드를 보여 줍니다. 휴대폰에 설치한 Expo Go 앱이 이 QR 코드로 개발 서버에 연결해 JavaScript 번들을 받아 실행하므로, Xcode나 Android Studio 없이 실제 기기에서 바로 확인할 수 있습니다. 코드를 저장하면 Fast Refresh로 화면이 즉시 갱신됩니다.

처음 흔히 막히는 곳은 네트워크입니다. 휴대폰과 컴퓨터가 같은 Wi-Fi에 있어야 하고, 회사 네트워크처럼 기기 간 통신을 막는 환경이나 VPN을 켠 상태에서는 QR 코드를 찍어도 연결 시간 초과가 납니다. 이럴 때는 npx expo start --tunnel로 외부 터널을 거치면 됩니다(대신 느립니다). 또 Expo Go는 특정 SDK 버전만 지원하므로, 오래된 SDK로 만든 프로젝트를 최신 Expo Go로 열면 버전이 맞지 않는다는 에러가 납니다. npx expo install --fix로 패키지 버전을 SDK에 맞추는 명령도 함께 알아 두면 좋습니다.


기본 컴포넌트

import { View, Text, StyleSheet } from 'react-native';
export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Hello Expo!</Text>
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#fff',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
  },
});

React Native에는 div나 span이 없고, View(레이아웃 상자)와 Text(글자)가 각 플랫폼의 네이티브 뷰로 변환됩니다. 모든 글자는 반드시 <Text> 안에 있어야 하며, <View> 안에 문자열을 바로 쓰면 Text strings must be rendered within a <Text> component 에러가 납니다. 웹에서 넘어온 개발자가 가장 먼저 만나는 에러입니다.

스타일은 CSS와 비슷하지만 자바스크립트 객체로 쓰고 속성 이름은 camelCase입니다. 레이아웃은 flexbox만 지원하며 기본 방향이 웹과 달리 세로(column)입니다. flex: 1은 부모의 남은 공간을 모두 차지하라는 뜻이라 최상위 컨테이너에 흔히 씁니다. StyleSheet.create는 스타일 객체에 타입 검사를 붙여 주고, 스타일이 한곳에 모여 읽기 쉬워지는 효과가 있습니다. 단위 없는 숫자는 기기 픽셀 밀도와 무관한 논리 단위라서 같은 fontSize: 24가 여러 화면에서 비슷한 크기로 보입니다.


Expo Router

npx create-expo-app my-app --template tabs

파일 기반 라우팅

app/
├── (tabs)/
│   ├── index.tsx      # /
│   └── profile.tsx    # /profile
├── users/
│   └── [id].tsx       # /users/:id
└── _layout.tsx

Expo Router는 app/ 폴더의 파일 구조가 곧 화면 경로가 되는 파일 기반 라우터로, React Navigation 위에서 동작합니다. _layout.tsx는 같은 폴더의 화면들을 감싸는 레이아웃(스택이나 탭 내비게이터)을 정의하고, [id].tsx처럼 대괄호로 감싼 이름은 동적 경로가 되어 화면에서 useLocalSearchParams()로 id를 읽습니다. 괄호로 감싼 (tabs)는 그룹이라 URL에는 나타나지 않으므로 (tabs)/profile.tsx의 경로는 /profile입니다. 이 구조 덕분에 탭 레이아웃 안의 화면과 밖의 화면(예: 탭 없이 전체 화면으로 뜨는 상세 페이지)을 폴더로 나눌 수 있습니다.

파일 기반 라우팅의 또 다른 장점은 딥 링크가 자동으로 생긴다는 점입니다. myapp://users/1 같은 링크나 푸시 알림에서 특정 화면을 여는 설정을 경로별로 따로 할 필요가 없습니다. 웹으로 빌드하면 같은 경로가 그대로 URL이 됩니다.

app/index.tsx

아래 코드는 설명용으로 경로를 app/index.tsx라고 적었지만, 위 폴더 구조에서는 app/(tabs)/index.tsx에 해당합니다. 같은 경로(/)를 가리키는 파일이 두 개 있으면 Expo Router가 어느 쪽을 쓸지 정할 수 없어 경고를 냅니다.

import { View, Text } from 'react-native';
import { Link } from 'expo-router';
export default function Home() {
  return (
    <View>
      <Text>Home Screen</Text>
      <Link href="/profile">Go to Profile</Link>
      <Link href="/users/1">Go to User 1</Link>
    </View>
  );
}

<Link href="...">는 웹의 링크처럼 경로 문자열로 화면을 이동합니다. 코드에서 이동하려면 useRouter()의 router.push('/users/1')을 쓰고, 뒤로 가기 기록을 남기지 않으려면 router.replace를 씁니다. Expo Router의 타입 경로 기능(typed routes)을 켜면 존재하지 않는 경로를 href에 적었을 때 TypeScript가 에러를 내주어 오타로 인한 빈 화면을 막을 수 있습니다. 존재하지 않는 경로로 이동하면 기본 “Unmatched Route” 화면이 뜨는데, app/+not-found.tsx를 만들어 두면 이 화면을 원하는 대로 바꿀 수 있습니다.


Expo SDK

Camera

npx expo install expo-camera

패키지는 npm install 대신 npx expo install로 설치하는 것이 중요합니다. 이 명령은 현재 Expo SDK 버전과 호환되는 패키지 버전을 골라 설치합니다. npm install expo-camera로 최신 버전을 받으면 SDK와 네이티브 코드 버전이 맞지 않아, 실행하자마자 Cannot find native module 'ExpoCamera' 같은 에러가 나거나 특정 기능만 조용히 동작하지 않을 수 있습니다.

import { CameraView, useCameraPermissions } from 'expo-camera';
import { useState } from 'react';
import { Button, View } from 'react-native';
export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const [facing, setFacing] = useState<'front' | 'back'>('back');
  if (!permission) {
    return <View />;
  }
  if (!permission.granted) {
    return (
      <View>
        <Button onPress={requestPermission} title="Grant Permission" />
      </View>
    );
  }
  return (
    <CameraView style={{ flex: 1 }} facing={facing}>
      <Button onPress={() => setFacing(facing === 'back' ? 'front' : 'back')} title="Flip" />
    </CameraView>
  );
}

useCameraPermissions는 권한 상태와 요청 함수를 돌려줍니다. 첫 렌더링에서는 권한 상태를 아직 모르므로 permission이 null이라 빈 화면을 먼저 보여 주고, 권한이 없으면 요청 버튼을, 있으면 카메라를 보여 주는 세 단계 흐름입니다. 사용자가 권한을 한 번 거부하면 iOS는 다시 묻지 않고 requestPermission이 곧바로 거부 결과를 돌려주므로, permission.canAskAgain이 false라면 “설정에서 권한을 켜 주세요” 안내와 함께 Linking.openSettings()로 설정 화면을 여는 버튼을 보여 주는 것이 좋습니다.

스토어에 제출할 때 흔히 걸리는 부분은 권한 설명 문구입니다. iOS는 카메라를 쓰는 이유를 Info.plist의 NSCameraUsageDescription에 적어야 하고, 없으면 앱이 크래시하거나 심사에서 거절됩니다. Expo에서는 app.json의 plugins에 ["expo-camera", { "cameraPermission": "프로필 사진 촬영에 카메라를 사용합니다" }]처럼 적으면 빌드할 때 자동으로 들어갑니다. 문구가 막연하면(“카메라 사용”) 심사에서 반려되는 경우가 있으니 구체적인 용도를 적습니다. 참고로 최근 버전의 CameraView는 자식 요소를 넣는 방식보다 버튼 같은 UI를 카메라 뷰 위에 position: 'absolute'로 겹쳐 두는 방식을 권장하므로, 새로 작성한다면 공식 예제의 구조를 따르는 편이 좋습니다.

Location

npx expo install expo-location
import * as Location from 'expo-location';
import { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
export default function LocationScreen() {
  const [location, setLocation] = useState<Location.LocationObject | null>(null);
  useEffect(() => {
    (async () => {
      const { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== 'granted') return;
      const location = await Location.getCurrentPositionAsync({});
      setLocation(location);
    })();
  }, []);
  return (
    <View>
      <Text>Latitude: {location?.coords.latitude}</Text>
      <Text>Longitude: {location?.coords.longitude}</Text>
    </View>
  );
}

useEffect의 콜백은 async 함수가 될 수 없어서 즉시 실행하는 async 함수로 감쌌습니다. 권한을 요청하고 허용되면 현재 위치를 한 번 가져옵니다. 예제는 권한이 거부되면 조용히 return해서 화면에 빈 값만 보이는데, 실제로는 거부 상태를 상태 변수에 담아 사용자에게 이유를 알려 주어야 합니다.

getCurrentPositionAsync는 GPS 신호가 약한 실내나 에뮬레이터에서 수 초 이상 걸리거나 실패할 수 있습니다. 첫 화면에 대략적인 위치만 필요하다면 getLastKnownPositionAsync()로 캐시된 위치를 먼저 보여 주고, 정확도가 중요할 때만 { accuracy: Location.Accuracy.High }를 지정하는 것이 배터리와 대기 시간 모두에 유리합니다. 에러를 try/catch로 잡지 않으면 위치 서비스가 꺼진 기기에서 처리되지 않은 Promise 거부 경고가 납니다. 앱이 백그라운드에 있을 때도 위치가 필요하다면 requestBackgroundPermissionsAsync와 별도 설정이 필요하고, 스토어 심사에서 사용 목적을 훨씬 엄격하게 확인합니다.


EAS Build

설치

npm install -g eas-cli
eas login
eas build:configure

eas.json

{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {}
  }
}

빌드

# iOS
eas build --platform ios
# Android
eas build --platform android
# 둘 다
eas build --platform all

eas.json의 빌드 프로필은 용도별 설정입니다. development는 expo-dev-client가 포함된 개발 빌드로, Expo Go처럼 개발 서버에 연결하지만 프로젝트에 추가한 네이티브 모듈까지 들어 있습니다. Expo Go에 없는 라이브러리를 설치했다면 이 빌드를 한 번 만들어 기기에 설치해야 합니다. preview는 팀원이나 QA에게 나눠 줄 내부 배포용이고(Android는 APK, iOS는 등록된 기기용 빌드), production은 스토어 제출용입니다. 명령에 --profile preview처럼 프로필을 지정하며, 생략하면 production이 쓰입니다.

eas build를 처음 실행하면 iOS 인증서와 프로비저닝 프로필, Android 키스토어를 만들어 Expo 서버에 보관할지 묻습니다. 특히 Android 키스토어는 잃어버리면 같은 앱으로 업데이트를 올릴 수 없으므로 eas credentials로 백업해 두는 것이 중요합니다. iOS 빌드에는 유료 Apple Developer 계정이 필요합니다. 빌드가 실패하면 EAS 웹 대시보드의 로그에서 실패한 단계를 확인할 수 있는데, 대부분 네이티브 의존성 버전 충돌이나 app.json 설정 오류입니다. 빌드는 대기열을 거쳐 실행되므로, 무료 플랜에서는 대기 시간이 길고 월 빌드 횟수에도 제한이 있습니다. macOS가 있다면 eas build --local로 내 컴퓨터에서 같은 과정을 실행할 수도 있습니다.


OTA Updates

설치

npx expo install expo-updates

배포

eas update --branch production --message "Fix login bug"

EAS Update는 채널과 브랜치 개념으로 동작합니다. 빌드할 때 프로필에 "channel": "production"을 지정해 두면, 그 빌드는 앞으로 production 채널에 연결된 브랜치의 업데이트만 받습니다. eas update --branch production으로 올린 번들은 앱이 다음에 실행될 때(기본 설정) 백그라운드에서 내려받아지고, 그다음 실행부터 적용됩니다. 그래서 업데이트를 올린 직후 앱을 한 번 열어서는 바로 바뀌지 않는 것이 정상입니다. 처음 설정할 때는 eas update:configure를 실행해 app.json에 업데이트 URL과 runtimeVersion을 넣어야 합니다.

가장 중요한 개념은 런타임 버전(runtimeVersion)입니다. OTA로 바꿀 수 있는 것은 JavaScript와 에셋뿐이라, 새 네이티브 모듈을 추가하거나 Expo SDK를 올린 코드를 예전 빌드에 OTA로 보내면 앱이 없는 네이티브 모듈을 호출하다 실행 즉시 크래시합니다. 런타임 버전은 “이 JS 번들이 어떤 네이티브 빌드와 호환되는가”를 나타내는 값으로, 업데이트는 런타임 버전이 같은 빌드에만 전달됩니다. "runtimeVersion": { "policy": "fingerprint" }를 쓰면 네이티브 설정이 바뀔 때 런타임 버전이 자동으로 달라져, 호환되지 않는 업데이트가 예전 빌드로 가는 사고를 막아 줍니다. 네이티브가 바뀐 변경은 반드시 새 스토어 빌드로 배포하고, 잘못된 업데이트를 올렸다면 eas update:rollback으로 되돌릴 수 있습니다.

스토어 정책도 확인해야 합니다. Apple과 Google 모두 앱의 해석 코드(JavaScript) 업데이트를 허용하지만, 앱의 주요 목적이나 기능을 심사 없이 바꾸는 용도로 쓰는 것은 정책 위반입니다. 버그 수정과 작은 개선에 쓰고, 큰 기능 추가는 스토어 업데이트로 내는 것이 안전합니다.


환경 변수

.env

API_URL=https://api.example.com

app.config.ts

export default {
  expo: {
    name: 'My App',
    slug: 'my-app',
    extra: {
      apiUrl: process.env.API_URL,
    },
  },
};

사용

import Constants from 'expo-constants';
const apiUrl = Constants.expoConfig?.extra?.apiUrl;

app.config.ts는 빌드(또는 개발 서버 시작) 시점에 Node.js에서 실행되므로 process.env를 읽을 수 있고, 그 결과가 extra에 담겨 앱에 포함됩니다. 앱 코드에서는 expo-constants로 꺼내 씁니다. 이 방식의 함정은 .env 파일이 EAS 클라우드 빌드 서버에는 없다는 점입니다. .env를 .gitignore에 넣어 두었다면 EAS Build가 프로젝트를 업로드할 때 이 파일이 빠져서, 로컬에서는 잘 되던 apiUrl이 스토어 빌드에서는 undefined가 됩니다. 이 경우 EAS 대시보드나 eas env:create로 등록한 환경 변수, 또는 eas.json 프로필의 env 항목에 값을 지정해야 합니다.

SDK 49 이상에서는 더 간단한 방법이 있습니다. 이름이 EXPO_PUBLIC_으로 시작하는 환경 변수(예: EXPO_PUBLIC_API_URL)는 앱 코드에서 process.env.EXPO_PUBLIC_API_URL로 바로 읽을 수 있고, 번들링할 때 값으로 치환됩니다. 다만 치환은 process.env.EXPO_PUBLIC_API_URL처럼 정적으로 적은 경우에만 동작하므로 process.env[key] 같은 동적 접근은 안 됩니다.

어느 방식이든 이 값들은 앱 번들 안에 평문으로 들어가므로, 앱 파일을 풀어 보면 누구나 볼 수 있습니다. API 주소처럼 공개돼도 되는 값만 넣고, 결제·관리자용 API 키 같은 비밀 값은 앱에 넣지 말고 자체 서버를 거쳐 호출해야 합니다.


정리 및 체크리스트

핵심 요약

  • Expo: React Native 개발 플랫폼
  • Managed Workflow: Native 설정 불필요
  • EAS Build: 클라우드 빌드
  • OTA Updates: 즉시 업데이트
  • Expo Go: 실기기 테스트
  • 풍부한 API: 카메라, 위치, 알림

구현 체크리스트

  • Expo 프로젝트 생성
  • 기본 컴포넌트 사용
  • Expo Router 구현
  • Expo SDK 활용
  • EAS Build 설정
  • OTA Updates 설정
  • 환경 변수 설정
  • 배포

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Bare React Native와 비교하면 어떤가요?

A. Expo는 네이티브 프로젝트 생성, SDK 버전 호환, 빌드와 배포를 관리해 주어 시작과 유지보수가 훨씬 수월합니다. Bare(순수 React Native)는 ios/, android/ 폴더를 직접 관리하므로 네이티브 코드를 자유롭게 수정할 수 있지만 그만큼 업그레이드 부담이 큽니다. React Native 공식 문서도 새 프로젝트에는 Expo 같은 프레임워크를 권장하고 있고, Expo에서도 npx expo prebuild로 네이티브 폴더를 생성해 직접 수정할 수 있어 둘 사이의 경계가 많이 흐려졌습니다.

Q. Native 모듈을 사용할 수 있나요?

A. 대부분의 서드파티 네이티브 라이브러리는 Config Plugin이 제공되거나 자동 링크되어 개발 빌드에서 바로 쓸 수 있습니다. 직접 네이티브 코드를 작성해야 한다면 Expo Modules API로 Swift·Kotlin 모듈을 만들 수 있습니다. 어느 경우든 Expo Go로는 테스트할 수 없고 개발 빌드가 필요합니다.

Q. 무료인가요?

A. Expo 프레임워크와 CLI는 오픈소스라 무료입니다. EAS Build·Update 같은 클라우드 서비스는 무료 플랜에 빌드 횟수와 업데이트 사용량 한도가 있고, 그 이상은 유료입니다. 한도는 요금제 개편에 따라 바뀌므로 공식 가격 페이지를 확인하세요.

Q. 앱 크기나 성능에 불리하지 않나요?

A. 예전 Managed Workflow는 쓰지 않는 SDK까지 모두 포함해 앱이 컸지만, 지금은 설치한 패키지만 빌드에 들어가므로 순수 React Native 앱과 큰 차이가 없습니다. 실행 성능은 Expo 여부보다 React Native 자체와 앱 코드(리스트 가상화, 이미지 크기, 불필요한 리렌더링)에 좌우됩니다.