팀원이 iOS 빌드가 갑자기 안된다고 했다.
다른 팀원도 같은 에러가 발생했다.
그래서 나도 그런가? 하고 해보니 나도 그런다.
정확히는 아래 prebuild 명령어 실행 과정에서 에러가 발생했다.
APP_MODE=development npx expo prebuild --platform ios
APP_MODE는 환경변수
prebuild 명령어는 네이티브 폴더를 생성하는 expo만의 유니크한 명령어이다.
prebuild는 다음 네가지를 바탕으로 네이티브 폴더를 생성한다.
prebuild 명령어를 실행하면 다음과 같은 에러가 발생했다.
✖ Prebuild failed
TypeError [ERR_INVALID_ARG_TYPE]: [ios.dangerous]: withIosDangerousBaseMod: The "paths[0]" argument must be of type string. Received undefined
TypeError [ERR_INVALID_ARG_TYPE]: [ios.dangerous]: withIosDangerousBaseMod: The "paths[0]" argument must be of type string. Received undefined
at Object.resolve (node:path:1101:7)
at /Users/byungchanko/Documents/GitHub/frontend/node_modules/@sentry/react-native/plugin/build/withSentryIOS.js:49:55
at action (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:199:29)
at interceptingMod (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:104:27)
at action (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:204:14)
at async interceptingMod (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:104:21)
✖ Prebuild failed
prebuild가 실패했다.
TypeError [ERR_INVALID_ARG_TYPE]: [ios.dangerous]: withIosDangerousBaseMod: The "paths[0]" argument must be of type string. Received undefined
TypeError: 타입에러인데
[ERR_INVALID_ARG_TYPE]: 인자의 타입이 invalid임
[ios.dangerous]: ios.danerous가 뭔지 모르겠지만 저기서 생긴건가
withIosDangerousBaseMod: 좀 더 구체적인 이름이 나옴.
The "paths[0]" argument must be of type string. Received undefined: 위 함수에서 paths 배열의 첫번째 인자는 string이여야하는데 undefined가 들어옴!
at Object.resolve (node:path:1101:7)
at /Users/byungchanko/Documents/GitHub/frontend/node_modules/@sentry/react-native/plugin/build/withSentryIOS.js:49:55
at action (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:199:29)
at interceptingMod (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:104:27)
at action (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:204:14)
at async interceptingMod (/Users/byungchanko/Documents/GitHub/frontend/node_modules/@expo/config-plugins/build/plugins/withMod.js:104:21)
스택 트레이스도 보았다.
가장 마지막에 node.js 어딘가에서 Object.resolve가 호출되었고 그 전에withSentryIOS.js에서 49번째 줄 55번째 칸이 호출되었다.
withSentryIOS.js가 문제인 듯하다.
const withSentryIOS = (config, sentryProperties) => {
const cfg = (0, config_plugins_1.withXcodeProject)(config, config => {
const xcodeProject = config.modResults;
const sentryBuildPhase = xcodeProject.pbxItemByComment('Upload Debug Symbols to Sentry', 'PBXShellScriptBuildPhase');
if (!sentryBuildPhase) {
xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
shellPath: '/bin/sh',
shellScript: `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH}`,
});
}
const bundleReactNativePhase = xcodeProject.pbxItemByComment('Bundle React Native code and images', 'PBXShellScriptBuildPhase');
modifyExistingXcodeBuildScript(bundleReactNativePhase);
return config;
});
return (0, config_plugins_1.withDangerousMod)(cfg, [
'ios',
config => {
(0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
return config;
},
]);
};
여기서
(0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
49번째 줄에서
path.resolve(config.modRequest.projectRoot, 'ios')
55번째 칸을 보니 .resolve가 있다.
여기서 에러가 발생했다
path는 node.js 내장 라이브러리이고 경로를 다룰 때 사용하는 라이브러리이다.
const path = __importStar(require("path"));
withSentryIOS.js 28번째 라인에서 임포트한걸 확인
path.resolve에서 문제가 발생한 것을 인지하고 좀 더 찾아보니
The "paths[0]" argument must be of type string. Received undefined
이 에러는 확실히 path 함수에서 인자로 string이 아닌 undefined가 들어오면 나는 에러였다.
그럼 첫번째 인자인 config.modRequest.projectRoot가 undefined라는 말이다.
config.modRequest.projectRoot가 undefinedconfig가 어디서 오는지 잘 모르겠다. 다시 withSentryIOS 함수를 보면...
...
return (0, config_plugins_1.withDangerousMod)(cfg, [
'ios',
config => {
(0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
return config;
},
]);
그 중에서도 리턴 부분만 보면 형태가 처음 보는 형태다.
괄호가 두개가 연달아 있는 형태인데 뭔지 몰라서 피티에게 물어보았다.
()() 이런 형태의 문법이 js에서 사용되는 경우 보통
이지만 위의 리턴 부분의 경우 둘 다 아니다.
(0, someFunction)() 형태의 꼴인데 이건 쉼표 연산자를 활용해 함수를 호출하는 방식이다.
스택오버플로에도 해당 방식에 대한 질문 글을 발견하였다.
What is the meaning of (0, someFunction)() in javascript
그렇다면 왜 읽기 힘들게 쉼표 연산자를 활용해서 함수를 호출할까?
gpt에게 물어보고 스택오버플로를 읽어보니 함수를 독립적인 함수로 호출해 특정 객체에 바인딩될 수 있는 this를 끊어내는 것이 목적이라고 한다.
스택오버플로에도 예시가 나오는데
var obj = {
method: function() { return this; }
};
console.log(obj.method() === obj); // true
console.log((0,obj.method)() === obj); // false
이렇게 this를 리턴하는 method라는 메소드를 가진 obj객체가 있는데 쉼표 연산자를 활용해 obj.method를 호출하면 this가 obj에서 끊긴 것을 볼 수 있다.
그렇다면 쉼표 연산자를 활용하면 this는 왜 끊기는 것일까?
그것은 js에서 this를 배울 때 많이 보던 예시에서 알 수 있다.
var obj = {
method: function() { return this; }
};
const someFunction = obj.method
console.log(someFunction() === obj); // false
이렇게 객체의 메소드를 someFunction에 할당해 독립적인 함수로 만들면 this가 끊기는 것을 볼 수 있는데 이것과 비슷한 맥락이다.
쉼표 연산자는 주어진 표현식을 왼쪽부터 순차적으로 평가하다가 마지막 표현식의 값만 반환(return)한다. 이를 바탕으로면 (0, someFunction)는 someFunction만 반환하는데 이것이 위 예시에서 someFunction에 할당하는 것과 같은 것이다.
아무튼 다시 돌아오면 헷갈리게 적히긴 했지만 결국
(0, config_plugins_1.withDangerousMod)
이 식은 즉 config_plugins_1.withDangerousMod를 실행하고
(cfg, [
'ios',
config => {
(0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
return config;
},
])
인자로 cfg 그리고 ['ios', config => {...}] 를 주는 것이다.
그럼 config_plugins_1.withDangerousMod는 어떤 함수인가
const config_plugins_1 = require("expo/config-plugins");
withSentryIOS.js에서 27번째 라인에 config_plugins_1는 expo/config-plugins를 불러온 것이다.
expo config-plugins를 찾아보니 리액트 네이티브에서 특정 모듈을 사용하려고 할 때 setup 과정에서 네이티브 폴더를 수정해야 할 때가 있는데 해당 작업을 자동화해주는 라이브러리이다.
expo를 사용해서 작업을 할 때 app.conig.js에서 plugins 배열에 특정 값을 추가할 때가 있었는데 그게 이것과 관련이 있던 것이었다. 지금까지 그냥 넣으라고 해서 넣은거였는데 다 이유가 있었다!
예를 들어
In your app's config, you can add expo-camera to the list of plugins:
app.json { "expo": { "plugins": ["expo-camera"] } }
카메라 기능을 사용하려면 네이티브에 접근해야 한다. 이를 위해 원래라면 개발자가 Android 혹은 iOS 네이티브 폴더에 들어가 네이티브 코드를 수정해서 react native에서 카메라 기능을 사용할 수 있게 해야했다.
하지만 expo의 경우 해당 기능을 스크립트처럼 만들어 놓아 개발자가 app.config.js 혹은 app.json에서 plugins 배열에 사용할 플러그인만 추가하면 되는 것이다. 네이티브 폴더를 수정하는 작업을 config-plugins 라이브러리를 사용해 자동화한 것이다.
위 예시처럼 expo-camera 같이 expo에서 자체적으로 만들어 놓은 플러그인들도 있고 React Native Firebase 라이브러리처럼 라이브러리에서 expo setup을 위해 플러그인을 제공하는 경우도 있다.
그럼 이 config-plugins는
정도 기능을 한다고 생각한다.
Plugins and mods 문서를 읽어보았다.
플러그인이 무엇인지 잘 정리되어 있었다.
플러그인이란?
플러그인은 ExpoConfig를 입력으로 받아 수정된 ExpoConfig를 반환하는 동기 함수입니다.
- 플러그인은 with<기능명> 형식으로 이름을 지정해야 하며, 예를 들어 withFacebook처럼 작성합니다.
- 플러그인은 동기적이어야 하고, 반환 값은 추가된 mods를 제외하고 직렬화할 수 있어야 합니다. 선택적으로 두 번째 인자를 통해 설정을 추가할 수 있습니다.
- 플러그인은 항상 expo/config의 getConfig 메서드가 설정을 읽을 때 호출됩니다. 그러나 mods는 npx expo prebuild의 “동기화” 단계에서만 실행됩니다.
여기서 ExpoConfig는 app.config.js/app.json 내에서 expo 필드의 값을 말하는 것
플러그인에 대한 설명을 withSentryIOS에 대입해서 생각해보면
withSentryIOS 라는 이름이 플러그인은 with~~~이렇게 이름이 시작해야 한다고 해서 그렇게 지어진 것이었다.
withSentryIOS는 동기 함수이다. 리턴은 직렬화할 수 있어야한다. 그런데 mods를 제외하고. 근데 이때 mods를 제외한다는게 동기함수와 리턴 두가지 모두 해당 안한다는건지 직렬화할 수 있어야한다에서만 제외한다는건지 좀 헷갈린다.
const withSentryIOS = (config, sentryProperties) => {
...
return (0, config_plugins_1.withDangerousMod)(cfg, [
'ios',
config => {
(0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
return config;
},
]);
}
const withSentryIOS = (config, sentryProperties) => {
const cfg = (0, config_plugins_1.withXcodeProject)(config, config => {
const xcodeProject = config.modResults;
const sentryBuildPhase = xcodeProject.pbxItemByComment('Upload Debug Symbols to Sentry', 'PBXShellScriptBuildPhase');
if (!sentryBuildPhase) {
xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
shellPath: '/bin/sh',
shellScript: `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH}`,
});
}
const bundleReactNativePhase = xcodeProject.pbxItemByComment('Bundle React Native code and images', 'PBXShellScriptBuildPhase');
modifyExistingXcodeBuildScript(bundleReactNativePhase);
return config;
});
...
아무튼 이렇게 withSentryIOS에서 return의 윗 부분은 동기 함수여야 한다. 지금 보면 cfg를 정의하는 과정에서 withXcodeProject라는 또 다른 플러그인을 return 부분 즉 withDangerousMod(cfg, ['ios', config => {...}]) 이것
withSentryIOS는 getConfig 메서드가 설정을 읽을 때마다 호출되는데 이 안에 있는 mods는 prebuild 명령어를 실행할 때만 실행된다.
그럼 계속 말하는 mods란 무엇인가
mods란?
모디파이어(줄여서 mod)는 설정(config)과 데이터 객체를 받아 조작한 후, 수정된 두 객체를 하나의 객체로 반환하는 비동기 함수입니다.
Mods는 app config의 mods 객체에 추가됩니다. mods 객체는 앱 설정의 나머지 부분과 달리 초기 읽기 이후에는 직렬화되지 않기 때문에, 이를 통해 코드 생성 중에 특정 작업을 수행할 수 있습니다. 가능하다면, mods 대신 다루기 쉬운 기본 플러그인을 사용하는 것이 좋습니다.
- Mods는 매니페스트에 포함되지 않으며 Updates.manifest를 통해 접근할 수 없습니다. Mods는 오직 코드 생성 중 네이티브 프로젝트 파일을 수정하는 용도로 존재합니다.
- mods는 npx expo prebuild 명령어를 실행하는 동안 파일을 안전하게 읽고 쓸 수 있으며, Expo CLI가 Info.plist, entitlements, xcproj 파일 등을 수정하는 방식입니다.
- mods는 플랫폼별로 다르게 구성해야 하므로, 항상 플랫폼에 맞는 객체에 추가해야 합니다.
해당 설명을 내 상황에 대입해서 생각해보면
mods는 app config의 mods 객체에 추가된다. -> 라고 하는데 나는 app.config.js에서 명시적으로 mods 필드를 만들어두지 않았다.
Plugins and mods 문서에 나오는 예시에서는
module.exports = {
name: 'my-app',
mods: {
ios: {
/* iOS mods... */
},
android: {
/* Android mods... */
},
},
};
이렇게 mods 필드가 있는 것을 볼 수 있다.
종합하자면