
Expo의 Config plugins
2025. 9. 30.withInfoPlist
로 Info.plist를 수정하고, withXcodeProject
로 Xcode 프로젝트 설정을 변경한다. expo-build-properties는 네이티브 SDK 버전과 빌드 옵션을 관리하며, expo-apple-targets는 위젯 같은 Target 추가를 가능하게 한다. 정말 필요한 경우 withDangerousMod
로 파일 시스템에 직접 접근할 수 있지만, 신중한 사용이 필요하다.React Native 진영에서 Expo는 오랫동안 호불호가 갈리는 프레임워크였다. Expo Go라는 개발용 앱을 통해 빠르게 앱을 실행할 수 있는 장점이 있지만, 장점은 그것뿐이고 실무에서는 개발자 경험(DX)이 상당히 좋지 않다는 인식이 있었다.
그러나 최근에는 상황이 많이 바뀌었다. Meta의 React Native 방향성이 바뀌면서 Expo 팀과 긴밀한 협업이 이루어지고 있고, Expo SDK도 빠르게 발전하고 있다. 또한 EAS(Expo Application Services)라는 클라우드 플랫폼을 통해 빌드와 배포가 간편해졌다.
실제로 현업에서 Expo를 도입하는 사례가 늘어나고 있다. 채용 공고에서 React Native는 이미 흔하고, Expo도 간혹 눈에 보인다. 빠른 개발과 배포가 가능하다는 점, 별도 커스텀 없이는 사용하기 어려웠던 네이티브 기능을 Expo 라이브러리들을 통해 쉽게 적용할 수 있다는 점이 주요한 이유로 보인다.
이 글에서는 Expo의 네이티브 설정을 다루는 Config plugins에 대해 이야기하고자 한다. 웹 프론트엔드 개발자 입장에서 네이티브 프로젝트를 다루는 것은 낯설고 어렵게 느껴질 수 있다. 그러나 Expo의 Config plugins를 활용하면, 부족한 지식으로도 네이티브 설정을 안전하게 제어할 수 있다. 이 글에서는 Config plugins의 개념과 사용법, 그리고 몇가지 라이브러리들을 소개한다.
Prebuild
Config plugins를 이해하기 위해서는 Prebuild 개념을 먼저 알아야 한다.
create-expo-app으로 Expo 프로젝트를 처음 설정하면 Expo Go라는 개발용 앱을 통해 실행할 수 있다. 다만, Expo Go에서 사용할 수 있는 네이티브 기능은 한정적이다. Expo 팀에서 지속적인 개발을 통해 꽤 많은 것이 가능해졌지만, 프로덕션 수준의 앱을 만들기에는 부족한 점이 많다.
네이티브 기능을 제대로 사용하기 위해서는 추상화된 네이티브 프로젝트 폴더를 노출해야 한다. 이를 위한 기능이 바로 Prebuild 기능이다. expo prebuild
명령어를 통해 실행하며, 웹 프론트엔드 개발자에게는 친숙한 create-react-app에서 Webpack 설정을 노출하기 위한 eject
와 유사하다.
Prebuild는 설정 파일인 app.json 혹은 app.config.ts의 내용에 따라 iOS와 Android 각 플랫폼별 네이티브 설정을 구현한다. 설정 파일에는 플러그인을 설정할 수 있는데, 네이티브 기능이 담긴 대부분의 서드파티 라이브러리는 이 플러그인을 함께 제공하며, 일반적으로 설치 가이드에서 이를 안내한다.
설정 파일의 명칭은 동적 설정이 가능한 app.config.ts 로 통일한다.
그러나 모든 것이 당연하게 제공되는 것은 아니다. 필요한 네이티브 기능 구현을 위한 적절한 서드파티 라이브러리가 없을 때가 있다. 예를 들어, 특정 디바이스 권한이 필요한 기능이나 커스텀 네이티브 모듈, 또는 특별한 빌드 설정이 필요한 경우가 그렇다. 이때는 직접 네이티브 코드를 수정해야 하지만, 네이티브 코드를 수정할 때 사전에 알아두어야 할 것이 있다.
바로 Prebuild는 멱등성을 유지해야 한다는 점이다. 즉, Prebuild를 몇 번을 실행해도 결과가 동일해야 한다는 의미다. 네이티브 파일을 직접 접근하여 코드를 수정하게 되면 Prebuild를 진행할 때 작성했던 코드가 초기화되어 당황스러운 상황을 겪을 것이다. 또한 멱등성을 잃게 된다.
Prebuild의 멱등성은 Expo의 클라우드 플랫폼인 EAS에서도 중요한 의미가 있다. EAS Build와 같은 클라우드 환경에서 빌드할 때 멱등성이 유지되지 않으면, 로컬 환경에서의 빌드와 차이가 발생하므로 문제가 생길 것이다.
그렇다면 Prebuild의 멱등성을 유지하기 위해서는 어떤 방법이 있을까?
Config plugins
Expo에서는 네이티브 코드 수정을 위한 API를 크게 두가지 지원한다.
- Modules API는 완전히 새로운 네이티브 모듈을 만들 때 사용하며, 상당한 네이티브 지식이 필요하다.
- Config plugins는 기존 네이티브 설정을 수정하는 데 특화되어 있어, 네이티브 경험이 적어도 활용할 수 있다.
이 글에서는 진입 장벽이 낮은 Config Plugins에 대해 이야기하고자 한다.
Prebuild의 파이프라인에서는 app.config.ts 의 값을 읽고 이를 바탕으로 Expo의 내장 플러그인을 실행한다. 이때 앱 이름, 스키마, 아이콘 등 앱에 필수적인 값들이 설정된다. 내장 플러그인 실행이 완료된 후 커스텀 플러그인들을 실행시키는데, 이는 app.config.ts 의 plugins
속성에서 확인할 수 있다.
플러그인은 서드 파티 라이브러리를 사용하더라도 Prebuild의 멱등성을 유지할 수 있도록 한다. Prebuild를 실행할 때마다 플러그인이 실행되어 네이티브 파일 및 코드를 일관되게 변경하기 때문에 멱등성을 보장할 수 있다.
실제 프로젝트의 app.config.ts를 보면 plugins
배열에 다양한 라이브러리들이 나열되어 있을 것이다. 각각 특정 네이티브 기능을 담당한다.
import { ConfigContext, ExpoConfig } from "expo/config";
// (...)은 중략 표현
export default ({ config }: ConfigContext): ExpoConfig => {
(...)
return {
...config,
(...)
plugins: [
"expo-font",
"expo-asset",
"expo-router",
"expo-sqlite",
"expo-localization",
"expo-build-properties",
"react-native-edge-to-edge",
(...)
],
};
};
여기서 핵심은 서드파티 라이브러리들도 결국 같은 Config plugins을 사용한다는 점이다. 즉, 우리도 동일한 방식으로 플러그인을 만들 수 있다는 의미다.
Config plugins를 활용하면 네이티브 파일이 담긴 폴더(ios, android)에 접근할 수 있다. 접근하는 방법에는 Expo에서 권장하는 안정적인 Mods와 불안정하지만 강력한 Dangerous mods가 있다.
Mods
Mods는 Config plugins의 핵심으로, iOS와 Android의 네이티브 프로젝트 설정을 코드 작성으로 안전하게 수정할 수 있도록 도와주는 도구이다. 특히 Prebuild 과정에서 멱등성을 보장하여 같은 설정을 여러 번 적용해도 동일한 결과를 얻을 수 있다. 예를 들어 Info.plist, AndroidManifest.xml, build.gradle 같은 파일을 직접 열고 편집하는 대신, Mods를 통해 필요한 값을 삽입하거나 수정할 수 있다.
Mods는 다양한 API를 제공하는데 with
접두사로 시작하는 mod 함수들이 있으며 네이티브 설정 파일마다 mod 함수를 제공한다. 이 글에서는 Xcode 프로젝트에서의 두 가지 mod 함수를 소개할건데, 가급적 웹 프론트엔드 개발자 입장에서 이해하기 쉽게 설명해보겠다. Xcode만 소개하는 이유는 비록 Expo는 크로스 플랫폼 프레임워크지만, 필자가 iOS와 SwiftUI에 더 관심이 있기 때문이라는 제멋대로의 이유다.
withInfoPlist
withInfoPlist
는 Xcode 프로젝트의 Info.plist 파일을 수정할 수 있는 mod 함수다. Info.plist는 iOS 앱의 메타데이터와 설정 정보를 담고 있는 굉장히 중요한 파일로 앱 권한과 URL 스키마, 앱 정보 등을 정의한다.
웹에서의 package.json이나 HTML의
<head>
태그와 비슷한 역할을 한다.
네이티브 개발에서는 Xcode의 GUI를 이용하여 수정하지만, Expo에서는 이 withInfoPlist
를 통해 Prebuild의 멱등성을 유지하면서 수정할 수 있다.
import { withInfoPlist } from '@expo/config-plugins';
const withUserTrackingPermission = (config) => {
return withInfoPlist(config, (config) => {
// modResults가 실제 Info.plist 내용을 담고 있는 객체
config.modResults.NSUserTrackingUsageDescription =
"사용자에게 맞춤형 광고를 제공합니다.";
return config; // 수정된 config를 반드시 반환해야 함
});
};
export default withUserTrackingPermission;
간단한 예를 들면, iOS 14.5부터는 광고 추적을 위해 앱 추적 투명성(App Tracking Transparency) 권한을 반드시 요청해야 한다. 이때 시스템 팝업에서 ‘추적 허용’ 또는 ‘앱에서 추적하지 않도록 요청’을 선택하는 UI가 나타나면서 하단에 설명이 표시되는데, 설명에 들어갈 문구를 withInfoPlist
를 활용하여 안전하게 삽입할 수 있다.
Info.plist 파일을 직접 수정했다면, expo prebuild --clean 실행 시 변경사항이 모두 사라지게 된다. withInfoPlist
를 사용하면 이런 문제 없이 일관된 설정을 유지할 수 있다.
참고로 사용자에게 보여질 앱 이용 시 권한 설정에 대한 설명은 일반적으로 어미에
UsageDescription
가 붙는다.
만약 다국어를 지원해야하는 경우에는 작업이 조금 까다로워진다. 각 언어별 .lproj
폴더와 InfoPlist.strings
파일을 생성하고 UsageDescription
을 작성해야한다. 이 작업은 withInfoPlist
만으로는 불가능하며 withDangerousMod
라는 mod 함수가 필요하다. 이 함수는 뒤에서 다루겠다.
withXcodeProject
withXcodeProject
는 Xcode 프로젝트의 설정을 수정할 수 있는 mod 함수다. Xcode 프로젝트 파일(.pbxproj)은 빌드 설정과 타겟 구성, 파일 레퍼런스, 링크 라이브러리 등의 정보를 담고 있다. 앞서 withInfoPlist
와 마찬가지로 네이티브 개발에서는 Xcode GUI를 통해 설정하지만, Expo에서는 이 함수를 통해 수정할 수 있다.
IDE에서 ios 폴더 내부에 있는 .pbxproj 파일을 열어보면, 알 수 없는 장황한 텍스트들로 처음에는 당황스러울 것이다.
이를 Xcode로 열어보면 조금 더 사람이 이해하기 쉬운 형태로 볼 수 있다.
웹 프론트엔드 개발자 입장에서 Xcode의 GUI 방식은 다소 어색하게 느껴진다.
Raw 형식인 .pbxproj 파일을 보면 XCConfigurationList
섹션 항목이 있는데, 이 항목에 있는 설정들이 Xcode에서 PROJECT
와 TARGETS
이다. Raw 파일 내용을 자세히 보면 프로젝트와 각 Target 별 옵션 항목들이 서로 연결되어 있음을 확인할 수 있다.
withXcodeProject
는 강력하지만 복잡한 API이다. .pbxproj 파일의 구조를 이해해야 하고, Xcode 프로젝트 설정에 대한 지식이 필요하며, 잘못 사용하면 프로젝트가 깨질 수 있으므로 주의해야한다.
expo-build-properties
expo-build-properties 라이브러리는 내부적으로 앞서 소개한 withInfoPlist
와 withXcodeProject
를 활용하는 대표적인 예시이다. Podfile 파일의 제어를 위한 createBuildPodfilePropsConfigPlugin
라는 함수도 내부적으로 사용하지만, 여기서 자세한 설명은 생략하겠다.
네이티브 수정이 필요한 의존성을 설치하다보면, 문서에서 expo-build-properties 설치를 요구하는 경우가 있는데, 예를 들어 Google Admob을 사용할 때 주로 사용하는 오픈소스인 react-native-google-mobile-ads 에서는 expo-build-properties 설치를 강제로 요구하며, app.config.ts에서 아래 코드 같은 설정을 하도록 안내한다.
// <root>/app.config.ts
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static"
}
}
]
]
}
}
React Native의 iOS에서는 패키지 매니저로 Pod을 사용하는데, useFrameworks
키를 static
으로 설정하면, Podfile.properties.json의 값이 변경되며 Podfile이 이를 참고하는 방식이다.
// Podfile.properties.json 예시
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true",
"ios.useFrameworks": "static",
}
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
useFrameworks
에 대해 간단하게 설명하면, static
은 모든 의존성이 컴파일 타임에 앱 바이너리에 포함된다. dynamic
옵션도 있는데, 이는 의존성을 동적으로 요청하게된다.
광고 SDK가 앱 시작 시점에 즉시 초기화되어야 하므로 static
옵션을 요구하는 것으로 추측하고 있다. 다만, useFrameworks를 명시적으로 선언해주지 않아도 기본 설정이 static
으로 알고 있는데, 라이브러리에서 이를 요구하는 의도를 구체적으로는 알지 못하겠다.
이외에도 주요 사용 방식은 네이티브 SDK 버전과 Deployment Target 버전 설정에 활용하며, React Native의 canary 버전 플래그를 켜고 실험적 API를 미리 사용해볼 수도 있다.
아래는 실무에서 사용할 법한 몇가지 설정에 대한 간단한 설명이다.
- reactNativeReleaseLevel
- React Native 릴리스 빌드 설정
- 'stable' | 'canary' | 'experimental'
- extraPods
- iOS 패키지 매니저인 Pod에 직접 의존성을 설치
- ccacheEnabled
- iOS 빌드에서 C++ 컴파일러 캐시 활성화
- 소규모 빌드에서는 캐시를 불러오는 오버헤드가 더 클 수 있음
Expo 문서에는 없지만, forceStaticLinking
이라는 옵션도 있다. 이 옵션은 코드 레벨에서 확인할 수 있는데, useFrameworks
가 dynamic
일 때, 몇 가지 의존성은 강제로 컴파일 타임에 포함시키는 옵션이다.
더 많은 설정 옵션들은 직접 코드를 참고해보자.
https://github.com/expo/expo/blob/main/packages/expo-build-properties/src/ios.ts
expo-apple-targets
expo-apple-targets은 Expo의 핵심 개발 멤버중 한 명인 Evan Bacon이 공개한 오픈소스 라이브러리이다. 내부적으로 withInfoPlist
와 withXcodeProject
같은 Config plugins를 적극 활용한다. 추후 설명하겠지만 withDangerousMod
라는 무서운 함수도 함께 사용한다.
네이티브 개발 경험이 없는 웹 프론트엔드 개발자라면 생소할 수 있는데, Apple의 소프트웨어는 Xcode 프로젝트 구성 시 Target이라는 것을 추가해야한다. XCode로 처음 프로젝트를 구성하면 메인 앱이 추가되어있고, 그 후 Extension이나 Target들을 추가할 수 있는 구조다.
expo-apple-targets은 라이브러리 이름처럼 Expo 워크플로우에 Target 추가를 가능하게 만든다. 주로 위젯, 라이브 액티비티 같은 앱의 보조적인 기능을 Expo 워크플로우로 추가하고 싶을때 사용한다. 물론 추가한 Target을 React Native로 개발할 수 있는건 아니고 SwiftUI를 활용해야한다.
SwiftUI로 직접 개발한 위젯을 네이티브 폴더에 직접 추가할 수는 있지만, Prebuild 과정에서 멱등성이 깨지면서 제거된다. expo-apple-targets은 Config plugins를 활용해서 이 문제를 해결했다. Expo와 SwiftUI 간에 데이터 공유도 지원한다. 기존에는 위젯과 앱 간 데이터 동기화를 위해 별도 API 서버나 복잡한 설정이 필요했었다.
주의할 것은 공식 Expo 라이브러리가 아니며, Evan Bacon이 독립적으로 개발하고 있는 오픈 소스와 다름없다. 내부를 보면 .pbxproj 파일의 TypeScript Parser를 자체적으로 제작해서 사용하고 있으며, 앞서 얘기한 withDangerousMod
를 상당 부분에 사용하는 등 Expo SDK 업데이트로 인해 깨질 수 있는 불안정한 로직이 많다. 개발한 당사자도 개념 증명에 가까운 라이브러리라고 언급한만큼, Expo 핵심 개발자의 방향성이 이렇구나 정도만 참고하자.
Dangerous mods
Mods에서는 대체로 제한적인 수정을 지원한다. 그래서 Expo에서는 이를 초월한 슈퍼(?) 기능을 제공한다. 바로 Dangerous mods이다. 이름부터 위험한 이 Mods에는 한 가지 함수가 있다. 바로 앞서 언급해왔던 withDangerousMod
이다.
withDangerousMod
withDangerousMod
는 프로젝트 파일을 직접 다룰 수 있다는 점에서 강력하지만, 동시에 멱등성을 깨뜨리기 쉽고, 플러그인의 유지보수 난이도를 높인다는 단점이 있다. 따라서 Expo 팀에서도 가급적 withInfoPlist
나 withXcodeProject
같은 안전한 Mods 함수들을 먼저 사용하고, 정말 불가피한 경우에만 withDangerousMod
를 사용할 것을 권장한다.
withDangerousMod
의 특징은 아래와 같다.
- 네이티브 프로젝트의 파일 및 디렉토리에 직접 접근 가능하다.
- 파일 생성, 복사, 수정, 삭제 등 파일 시스템에서 가능한 모든 작업 수행 가능하다.
- 네이티브 코드가 작성된 파일을 생성하거나, 특정 리소스를 Xcode/Android Studio 프로젝트에 추가 가능하다.
특징을 종합하면, 이론상 네이티브 프로젝트에서 무엇이든 가능하다.
플러그인 내부에서 Kotlin과 Swift 코드 파일을 작성하는 것도 가능하다. 또한 TypeScript를 통해 코드를 생성한 뒤 네이티브 프로젝트로 포팅하는 것도 가능하다. 물론 이를 위해서는 정규식을 과도하게 사용하거나, 별도의 파서 엔진이 필요할 정도라 어디까지나 이론상이다.
Mods에서 언급했던 expo-apple-targets에서는 Xcode 프로젝트의 Image Asset, Color Set, Widget을 제어하는데 활용한다.
다음은 Xcode 프로젝트에서 Image Asset을 관리할 때 사용하는 withImageAsset
함수다.
export const withImageAsset: ConfigPlugin<{
cwd: string;
name: string;
image: string | { "1x"?: string; "2x"?: string; "3x"?: string };
}> = (config, { cwd, name, image }) => {
return withDangerousMod(config, [
"ios",
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const iosNamedProjectRoot = join(projectRoot, cwd);
const imgPath = `Assets.xcassets/${name}.imageset`;
await fs.promises.mkdir(join(iosNamedProjectRoot, imgPath), {
recursive: true,
});
const userDefinedIcon =
typeof image === "string"
? { "1x": image, "2x": undefined, "3x": undefined }
: image;
await writeContentsJsonAsync(join(iosNamedProjectRoot, imgPath), {
images: await generateResizedImageAsync(
Object.fromEntries(
Object.entries(userDefinedIcon).map(([key, value]) => [
key,
value?.match(/^[./]/) ? path.join(cwd, value) : value,
])
),
name,
projectRoot,
iosNamedProjectRoot,
path.join(cwd, "gen-image", name)
),
});
return config;
},
]);
};
코드를 이해하려면 Xcode 프로젝트에 대한 기본적인 이해가 필요하다.
웹 프론트엔드 개발자 입장에서 Xcode 프로젝트에서 에셋 관리는 다소 독특하다. 각 이미지 세트가 .imageset 폴더 단위로 구성된다. 이 폴더 안에는 실제 이미지 파일들과 함께 Contents.json 파일이 존재하며, 이미지의 메타데이터(배율, 경로 등)를 담고 있다.
어떤 의미인지는 직접 보면 이해가 빠르다.
Xcode GUI에서는 .imageset 폴더를 직접 노출하지 않고, 개발자가 우측 패널에서 드래그 앤 드롭 방식으로 이미지를 관리하게끔 추상화한다.
반면 VSCode 등 IDE로 열어보면 Assets.xcassets 내부에 .imageset 폴더들이 존재하고, 각각 Contents.json을 포함하고 있음을 확인할 수 있다.
위 코드에서는 배율(1x, 2x, 3x)에 따른 이미지를 파라미터로 받아 .imageset 폴더를 생성하고, writeContentsJsonAsync
를 통해 Contents.json을 자동으로 작성한다. 즉, 에셋 관리에 필요한 모든 작업이 withDangerousMod
내부에서 수행되는 셈이다.
expo-apple-targets는 이외에도 다음과 같은 기능을 제공한다.
- .colorset 파일을 수정하기 위한
withIosColorset
- .appiconset 파일을 수정하기 위한
withIosIcon
- Widget Target을 추가하기 위한
withWidget
현재 공식 Expo 워크플로우에서는 메인 앱 이외의 Target을 추가하는 방법이 없다. 따라서 위젯이나 라이브 액티비티 같은 기능을 추가하려면 결국 네이티브 파일을 직접 제어해야 한다. 규모가 큰 조직에서는 자체적인 방법으로 React Native와 네이티브 프로젝트를 결합하는 경우가 많다. 소규모 팀에서는 현실적으로 품이 많이 드는 영역이었는데 expo-apple-targets의 구현 방식을 참고해 필요한 Target을 제어할 수 있다.
이 라이브러리는 Apple의 애플리케이션 한정이긴 하지만, 내부에서 사용된 Mods 플러그인들의 활용 방법을 바탕으로 Android 역시 비슷한 방식으로 접근할 수 있다. 다만, Gradle 스크립트와 리소스 구조에 대한 수준 높은 이해가 필요하다.
마치며
실제 프로젝트에서 플러그인을 적용하려면 개발/프로덕션 환경 분리 등 고려할 점이 많아 쉽지 않다. 그럼에도 Expo의 강력한 Mods 함수들 덕분에 과거에는 어려웠던 네이티브 기능들도 점차 가능해지고 있다.
필자 역시 withAppDelegate
를 활용해 AppDelegate.swift
를 수정하는 플러그인을 직접 작성했고, 필요한 모듈을 가져오는데 사용하고 있다. 위젯도 플러그인을 통해 성공적으로 적용했는데, 이 경험도 별도의 글에서 공유해보겠다.