expo config plugins 관련 에러

고병찬·2024년 11월 1일

TIL

목록 보기
50/54

들어가며

팀원이 iOS 빌드가 갑자기 안된다고 했다.
다른 팀원도 같은 에러가 발생했다.
그래서 나도 그런가? 하고 해보니 나도 그런다.

정확히는 아래 prebuild 명령어 실행 과정에서 에러가 발생했다.

APP_MODE=development npx expo prebuild --platform ios

APP_MODE는 환경변수

prebuild 명령어는 네이티브 폴더를 생성하는 expo만의 유니크한 명령어이다.
prebuild는 다음 네가지를 바탕으로 네이티브 폴더를 생성한다.

  1. The app config file (app.json, app.config.js).
  2. Arguments passed to the npx expo prebuild command.
  3. Version of expo that's installed in the project and its corresponding prebuild template.
  4. Autolinking, for linking native modules found in package.json.

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가 문제인 듯하다.

withSentryIOS.js:49:55

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.projectRootundefined라는 말이다.

config.modRequest.projectRootundefined

config가 어디서 오는지 잘 모르겠다. 다시 withSentryIOS 함수를 보면...

...
    return (0, config_plugins_1.withDangerousMod)(cfg, [
        'ios',
        config => {
            (0, utils_1.writeSentryPropertiesTo)(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
            return config;
        },
    ]);

그 중에서도 리턴 부분만 보면 형태가 처음 보는 형태다.
괄호가 두개가 연달아 있는 형태인데 뭔지 몰라서 피티에게 물어보았다.

()()이 뭘까

()() 이런 형태의 문법이 js에서 사용되는 경우 보통

  1. 즉시 실행 함수
  2. 고차함수

이지만 위의 리턴 부분의 경우 둘 다 아니다.

(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에 할당하는 것과 같은 것이다.

expo config-plugins

아무튼 다시 돌아오면 헷갈리게 적히긴 했지만 결국

(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_1expo/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는

  1. app.config.js 내부에 plugins 배열을 읽고
  2. 각각 플러그인에 해당하는 작업을 수행한다.

정도 기능을 한다고 생각한다.

Plugins and mods

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 필드가 있는 것을 볼 수 있다.

종합하자면

profile
안녕하세요, 반갑습니다.

0개의 댓글