React Native Firebase pod install 오류 해결: GoogleUtilities does not define modules

React Native Firebase의 pod install 오류 'GoogleUtilities does not define modules'는 useFrameworks: 'static'으로, 이어진 비모듈 헤더 컴파일 오류는 Expo Config Plugin으로 해결했다. New Architecture와 네이티브 빌드 과정도 함께 정리한다.

배경 설명

  1. React Native(RN)로 개발하던 포에이 앱에 Firebase Analytics를 도입해야 했다.
  2. 사용자가 어떤 화면을 방문하고, 게시글이나 댓글을 작성하며, 약 리뷰 기능을 얼마나 이용하는지 분석할 기반이 필요했다.
  3. 작업은 Firebase 패키지를 설치하고 앱 설정을 추가하는 것으로 시작했다.
  4. 그러나 iOS 빌드 과정에서 오류가 발생했다.
  5. 이를 해결하기 위해 JavaScript 코드뿐만 아니라 RN 내부 동작 원리, CocoaPods의 의존성 구성, 헤더와 모듈의 차이, 네이티브 컴파일 설정, Expo의 Prebuild 과정까지 살펴보게 됐다.
  6. 경험 정리를 하면서 중간중간 알게 된 개념을 정리할 계획이다. 만약 이미 알고 있는 파트가 나온다면 빠르게 넘어가는 것을 추천한다.

💡 New Architecture 살펴보기

https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here

  1. RN 앱 안에는 JS 코드와 네이티브 코드가 함께 있다.

  2. JS 코드는 보통 앱 안의 JavaScript 엔진인 Hermes라는 엔진에서 실행된다.

  3. 반면 iOS 화면을 실제로 표시하거나 iOS용 SDK를 실행하는 부분은 네이티브 쪽에서 동작한다.

  4. 예를 들어 버튼을 누를 때, “입력값이 비어 있으면 경고를 띄워야겠다”는 판단은 JS 코드가 한다.

    • iOS의 실제 경고창을 띄우는 작업은 네이티브 코드가 한다.
    • RN은 이렇게 JS로 앱의 동작을 작성하면서 네이티브 화면과 기능을 사용할 수 있게 해 주는 프레임워크이다.
  5. 중요한 점은 코드 자체의 번역이라기 보다, 미리 구현돼 있는 네이티브 경고창 기능에 제목과 내용을 전달하고 실행을 요청하는 것이다.

  6. 이전에는 둘 사이에서 호출과 데이터를 전달하는 방식이 브릿지였다.

  7. 지금은 New Architecture이다.

  8. 기존 Bridge와 New Architecture는 전달 방식이 다르다.

    구분기존 Bridge 방식New Architecture
    연결 방식호출 내용과 인자를 직렬화해 큐에 넣고 전달JSI를 이용해 JS와 네이티브 사이의 호출을 연결
    직렬화란?전달할 내용을 메시지 형태로 바꾸는 것기존 Bridge의 메시지 직렬화 경로를 거치지 않음
    호출 특성일반적으로 비동기 메시지 전달동기, 비동기 호출을 지원
  9. 여기까지 RN의 동작 원리가 과거와 달라졌다는 사실을 인지하자. 다음은 그 핵심 원리인 JSI를 알아보자.

💡 JSI (JavaScript Interface)

  1. New Architecture에서도 여전히 JavaScript와 네이티브 코드의 소통이 필요하다.
  2. JSI는 그 역할을 돕는 JavaScript 엔진(Hermes)과 C++ 코드가 상호작용할 수 있도록 하는 인터페이스이다.
  3. 헷갈릴 수도 있는데, 특정 “기능”에 대해서 JSI가 네이티브 언어(swift 등)로 번역하는 게 아니다. JavaScript 코드로 기능을 호출하면, 연결된 네이티브 코드가 반응하는 방식이다.
  4. 기존 Bridge에서는 JavaScript와 네이티브 함수가 연결되어 있지 않아, 이런 요청을 전달할 메시지로 만들어 보내야 했다.
  5. JSI를 활용하면 JS에서 연결된 네이티브 함수를 호출할 수 있어, 기존 Bridge의 메시지 전달 과정을 거치지 않아도 된다.
  6. 이 연결을 바탕으로 네이티브 기능을 JavaScript에서 사용할 수 있게 해 주는 시스템이 TurboModules이다.
  7. 한편, React로 작성한 화면을 실제 네이티브 화면에 반영하는 시스템은 Fabric이다. 이제 각각이 어떤 역할을 하는지 살펴보자.

💡 TurboModules: 네이티브 기능을 JS에 제공하는 모듈 시스템

https://reactnative.dev/docs/turbo-native-modules-introduction

  1. JSI로 JavaScript 코드와 네이티브 언어를 연결할 수 있다고 해도, 앱에서 사용할 기능마다 연결을 직접 구성할 수는 없다. 너무 복잡하기 때문이다.
  2. 그래서 우리는 어떤 기능을 어떤 이름으로 제공하고, 어떤 인자를 받으며, 언제 초기화할지 등을 모듈 단위로 다룰 수 있어야 한다.
  3. TurboModules는 네이티브 기능을 JavaScript에서 사용할 수 있는 모듈로 제공하는 새로운 Native Module 시스템이다. (물론 Bridge 시절에도 Native Modules 개념이 있었다.)
  4. 기기 정보를 읽거나 네이티브 저장소에 접근하고, 플랫폼 전용 SDK를 실행하는 기능 등을 이 방식으로 제공할 수 있다.
  5. 예를 들어 앱에서 네이티브 저장소에 값을 저장한다고 생각해 보자. JS 코드는 저장소 모듈의 메서드를 호출하면서 키와 값을 전달한다. 실제 저장 작업은 연결된 네이티브 구현이 수행한다. 앱 개발자는 JS API를 통해 기능을 사용하고, 모듈 구현자는 그 API 뒤에서 플랫폼에 필요한 작업을 처리한다.
  6. TurboModules에서는 동기, 비동기 메서드를 제공할 수 있다. 결과를 바로 반환하도록 설계된 메서드라면 JavaScript에서 반환값을 즉시 받아 다음 코드에 사용할 수 있다. 반면 시간이 오래 걸리는 작업은 비동기로 처리하는 편이 적절하다. 동기 호출 중에는 호출한 스레드가 결과를 기다려야 하기 때문이다.
    • 앱에서 JS 스레드, UI 스레드 등 주요 개념들이 있지만 이번 글에서는 다루지 않는다.
  7. 우리가 알아야 할 것은 RN을 “JavaScript로 앱을 개발할 수 있는 기술”로 생각하지 않고 무척 복잡한 내부 동작 원리를 가지고 있다는 사실이다.

Analytics를 붙이면서 달라진 빌드 환경

  1. 다시 돌아와서, 나는 RN으로 개발하던 포에이 앱에 Firebase Analytics를 도입해야 했다.

  2. 이를 위해 @react-native-firebase/app과 @react-native-firebase/analytics를 추가했다.

  3. iOS용 Firebase 설정 파일 경로와 Firebase App 플러그인을 등록했다.

  4. lockfile과 앱 설정을 기준으로 당시 환경은 다음과 같았다.

    항목설정
    Expo54.0.30
    React Native0.81.5
    RNFirebase App / Analytics23.7.0
    New Architecture활성화
    iOS 프레임워크 방식기본값 (use_frameworks! 미적용, Pod를 정적 라이브러리로 통합)
  5. RNFirebase는 JavaScript에서 호출하는 API와 네이티브 Firebase SDK를 연결한다.

  6. 따라서 npm 패키지 설치만으로 연동이 끝나지 않는다.

  7. iOS 프로젝트에도 필요한 의존성이 추가되고, 그 의존성을 포함한 네이티브 코드가 빌드되어야 한다. (바로 뒤에서 왜 RNFirebase를 설치할 때 네이티브 코드가 필요한지 설명한다.)

  8. 이번에 막힌 지점은 JavaScript 이벤트 호출 코드가 아니었다.

  9. iOS 의존성을 구성하는 pod install 단계였다.

💡 CocoaPods와 pod install은 무엇일까?

https://guides.cocoapods.org/using/getting-started.html

  1. JavaScript 패키지를 npm이나 Yarn으로 관리하듯, iOS 프로젝트의 네이티브 라이브러리 의존성을 관리하는 도구가 CocoaPods이다.
  2. CocoaPods가 관리하는 라이브러리 패키지를 Pod라고 부른다. 터미널에서 사용하는 pod는 CocoaPods의 명령어다.
  3. iOS 프로젝트의 Podfile에는 사용할 의존성과 통합 설정을 정의한다.
  4. pod install을 실행하면 CocoaPods가 의존성 관계를 확인하고, 필요한 라이브러리를 설치한 뒤 Xcode에서 함께 빌드할 수 있도록 프로젝트를 구성한다.
  5. 따라서 pod install은 네이티브 의존성을 준비하고 통합하는 단계다. 실제로 앱을 컴파일하고 실행 가능한 결과물을 만드는 작업은 이후 네이티브 빌드에서 이루어진다.
  6. 이번 RNFirebase 도입에서는 RNFirebase의 iOS 네이티브 모듈과 Firebase iOS SDK가 앱에 포함되어야 했다. 내가 막힌 곳은 이 의존성을 준비하는 pod install 단계였다.

💡 RN은 JavaScript 코드와 네이티브 기능을 연결한다. 그렇다면 RNFirebase는 왜 필요할까?

  1. Firebase는 외부 SDK를 제공한다. RN 자체가 모든 외부 SDK의 함수와 사용법을 알고 있지는 않다.
  2. RN은 네이티브 기능을 연결할 수 있는 공통 기반을 제공하고, RNFirebase는 그 기반을 사용해서 Firebase 기능을 연결해 준다.
  3. 그래서 외부 네이티브 SDK를 RN에서 사용하려면 누군가 다음 기능을 구현해야 한다.
    • JS 개발자가 호출할 이벤트 기록 API
    • 이벤트 이름과 매개변수를 받아 Firebase iOS SDK의 함수를 호출하는 네이티브 코드
  4. 이 Firebase 전용 연결을 제공하는 라이브러리가 RNFirebase이다.
  5. pod install로 의존성을 통합하고, 네이티브 빌드로 실행 가능한 앱을 만드는 과정이 필요하다.
  6. Firebase뿐만 아니라, 모바일 네이티브 기능을 사용하는 특정 라이브러리들은 동일한 과정이 필요하다. 당연히 JavaScript만으로 돌아가는 라이브러리도 많다.

1️⃣ 로그에서 확인한 문제: GoogleUtilities의 모듈 정의

  1. 당시 남긴 로그의 핵심은 다음과 같았다.

    ⚠️  Something went wrong running `pod install` in the `ios` directory.
    Command `pod install` failed.
    └─ Cause: The following Swift pods cannot yet be integrated as static libraries:
    
    The Swift pod `FirebaseCoreInternal` depends upon `GoogleUtilities`, which does not define modules. To opt into those targets generating module maps (which is necessary to import them from Swift when building as static libraries), you may set `use_modular_headers!` globally in your Podfile, or specify `:modular_headers => true` for particular dependencies.
    
    pod install --repo-update --ansi exited with non-zero code: 1
  2. 로그에서 볼 수 있었던 것은 FirebaseCoreInternal과 GoogleUtilities 사이의 의존성 관계였다.

  3. Swift로 작성된 Pod인 FirebaseCoreInternal이 GoogleUtilities를 사용하는데, Pod를 정적 라이브러리로 통합하는 기본 구성에서는 GoogleUtilities가 모듈을 정의하지 않아 통합할 수 없다는 내용이었다.

  4. 그리고 Swift에서 이 의존성을 가져오려면 모듈 맵이 필요하다며, 전체 또는 특정 Pod에 모듈 헤더 설정을 적용하라고 적혀있다. 즉, 모듈 정의 부족이었다.

  5. 이 검사는 CocoaPods가 Pod를 정적 라이브러리로 통합할 때만 수행된다. 해결 경로는 두 가지였다.

    • 로그가 안내한 대로 해당 Pod에 모듈 헤더 설정을 적용한다.
    • RNFirebase 문서가 안내하는 대로 Pod를 프레임워크로 구성한다. (use_frameworks! :linkage => :static)
  6. 여기서 의문이 생겼다. 모듈을 정의한다는 것은 무엇이고, 프레임워크로 구성하면 왜 이 문제가 해결될까?

  7. 이 질문에 답하려면 먼저 라이브러리 바이너리, 헤더, 프레임워크, 링크가 무엇인지 알고, 거기서 한 단계 더 나아가 헤더를 모듈로 가져오는 방식을 알아야 했다. 먼저 문서가 안내한 설정이 무엇을 바꾸는지부터 살펴보자.

💡 useFrameworks: "static"은 무엇을 바꾸는 설정일까?

https://docs.expo.dev/versions/v54.0.0/sdk/build-properties/

  1. 이 오류를 해결하기 위해 RNFirebase 문서가 안내하는 대로 expo-build-properties에 다음 설정을 추가했다.

    [
      "expo-build-properties",
      {
        "ios": {
          "useFrameworks": "static"
        }
      }
    ]
  2. 이 설정은 iOS 네이티브 의존성을 어떤 형태로 구성하고, 앱에 어떤 방식으로 연결할지 지정한다. Expo의 Prebuild 과정에서 iOS 프로젝트 설정에 반영된다.

  3. 설정을 이해하려면 먼저 라이브러리 바이너리, 공개 헤더, 프레임워크, 링크가 무엇인지 알아야 한다.

  4. 라이브러리 개발자는 소스 코드로 기능을 구현한다.

  5. 이 소스 코드를 컴파일하면 기계가 실행할 수 있는 형태의 코드가 만들어진다.

  6. 라이브러리 바이너리는 이러한 코드를 담은 파일이다.

  7. 일반적으로 혼자 실행하는 앱이 아니라, 다른 프로그램에 연결해서 사용하는 코드라고 생각하면 된다.

  8. 그런데 실제 기능이 바이너리 안에 들어 있다고 해서, 그 기능을 호출하는 방법까지 저절로 알 수 있는 것은 아니다.

  9. 호출하는 코드를 컴파일하려면 “어떤 함수가 있는지”, “어떤 값을 전달해야 하는지”, “어떤 결과를 반환하는지”를 알아야 한다.

  10. C나 Objective-C 같은 언어에서는 이런 정보를 주로 헤더 파일(.h)에 선언한다.

  11. 그중 라이브러리 외부의 코드에서 사용하도록 제공하는 것을 공개 헤더라고 한다.

  12. 예를 들어 정수 두 개를 더하는 가상의 라이브러리가 있다고 생각해 보자. 공개 헤더에는 다음과 같은 선언이 들어갈 수 있다.

    int add(int a, int b);

    이 선언은 “add라는 함수는 정수 두 개를 받아 정수 하나를 반환한다”는 정보를 알려 준다. 실제로 두 값을 어떻게 더하는지는 나와 있지 않다.

  13. 실제 동작은 별도의 구현 코드에 작성한다.

    int add(int a, int b) {
        return a + b;
    }
  14. 이 구현을 컴파일한 코드가 라이브러리 바이너리에 들어간다.

  15. 즉, 공개 헤더는 기능을 호출하는 데 필요한 정보를 제공하고, 바이너리는 실제 기능을 수행하는 코드를 담는다.

  16. 둘이 필요한 이유도 여기서 나온다. 앱에서 add(2, 3)을 호출하려면, 빌드 도구가 올바른 호출인지 확인할 수 있어야 하고, 실제 덧셈을 수행하는 코드도 있어야 한다.

  17. 헤더에 선언만 있고 구현이 없다면 계산을 수행할 수 없다. 반대로 바이너리만 있고 헤더 같은 인터페이스 정보가 없다면, 컴파일러가 그 함수를 어떻게 호출해야 하는지 알기 어렵다.

  18. 이렇게 함께 사용하는 파일들을 정해진 구조로 묶는 방식이 프레임워크(.framework)이다.

  19. 컴파일된 라이브러리 바이너리와 공개 헤더, 모듈 정보 등을 하나의 구조 안에서 관리할 수 있다.

    • 여기서 프레임워크는 “Next.js, React Native 같은 프레임워크” 의미가 아니라, Apple 플랫폼에서 라이브러리를 구성하는 패키지 형태를 뜻한다.
  20. 관련 파일을 함께 묶으면 라이브러리를 배포하거나 프로젝트에 추가할 때 서로 맞는 선언과 구현을 관리하기 편해진다.

  21. 다음은 링크이다. 앱 코드에 add(2, 3)이라고 작성하면, 컴파일러는 헤더의 선언을 참고해 그 호출을 컴파일할 수 있다.

  22. 하지만 최종적으로 동작하려면 이 호출이 라이브러리에 들어 있는 실제 add 구현으로 이어져야 한다.

  23. 이렇게 컴파일된 코드가 참조하는 외부 함수 등을 실제 구현과 연결하는 과정을 링크라고 한다.

  24. 링크 방식은 크게 정적 링크와 동적 링크로 나눌 수 있다.

    구분라이브러리 코드를 연결하는 방식
    정적 링크빌드할 때 라이브러리의 필요한 코드를 이를 사용하는 최종 바이너리에 포함한다.
    동적 링크라이브러리를 별도 바이너리로 유지하고, 실행 시 로드해 사용하도록 연결한다.
  25. 예를 들어 Firebase를 앱 실행 바이너리에 정적으로 연결하면, 필요한 Firebase 코드가 그 안에 포함된다.

  26. 동적으로 연결한다면 Firebase 라이브러리 바이너리를 별도로 두고 앱 실행 시 로드해 사용한다.

  27. 바이너리를 앱에 포함해 배포하면서도 실행 바이너리와는 별도 파일로 유지할 수 있다.

  28. 여기서 구분해야 할 점은 프레임워크는 파일을 묶는 방식이고, 링크(정적, 동적)는 코드를 연결하는 방식이라는 것이다.

  29. 서로 다른 기준이므로 프레임워크라고 해서 반드시 동적으로 연결되는 것은 아니다.

  30. 프레임워크 형태로 구성하면서 정적으로 연결하는 정적 프레임워크도 가능하다.

  31. 이제 useFrameworks: "static"을 다시 보면 두 부분으로 이해할 수 있다.

    설정의미
    useFrameworks해당 설정을 적용받는 Pod들을 프레임워크 형태로 구성한다.
    "static"그 프레임워크의 코드를 정적으로 연결하도록 지정한다.
  32. CocoaPods의 Podfile에서는 다음 표현에 해당한다.

    https://guides.cocoapods.org/syntax/podfile.html#use_frameworks_bang

    use_frameworks! :linkage => :static
  33. 이 설정을 넣는 순간 라이브러리 코드가 앱에 합쳐지는 것은 아니다.

  34. pod install에서는 이 설정에 따라 의존성과 빌드 구성을 준비하고, 이후 네이티브 빌드 과정에서 필요한 컴파일과 링크가 이루어진다.

  35. 또한 Pod에 따라 소스 코드를 받아 빌드하기도 하고, 이미 컴파일된 바이너리를 사용하기도 한다.

  36. 마지막으로 이 설정은 Firebase 하나만을 대상으로 하는 옵션이 아니다.

  37. 설정이 적용되는 CocoaPods 타겟, 즉 앱 같은 빌드 대상에 속한 다른 Pod들의 구성에도 영향을 줄 수 있다.

  38. 따라서 Firebase를 도입하기 위해 추가한 설정이라도, 기존 네이티브 의존성과 함께 동작하는지 확인해야 한다.

  39. 이 때문에 Firebase 연동 작업이 iOS 의존성 구성과 빌드 구조를 살펴보는 작업으로 이어질 수 있다.

💡 모듈 맵: 헤더를 모듈로 가져오기 위한 정보

  1. 앞서 헤더는 외부에서 기능을 사용할 때 필요한 선언을 제공한다고 설명했다.

  2. 그런데 헤더가 존재하는 것과, 그 헤더를 특정 이름의 모듈로 “가져올 수 있는 것”은 별개의 조건이다.

  3. 여기서 말하는 모듈은 컴파일러가 라이브러리의 인터페이스를 하나의 단위로 인식하고 가져올 수 있도록 구성한 것이다.

  4. C나 Objective-C 헤더를 이런 모듈로 구성할 때 사용하는 정보가 모듈 맵이다. 어떤 이름의 모듈에 어떤 헤더가 속하는지 등을 정의한다.

  5. 모듈 맵은 Swift나 Objective-C 코드가 아니라 Clang의 모듈 맵 문법으로 작성한다.

  6. Clang은 C, Objective-C 등의 코드를 처리하는 컴파일 도구이고, 모듈 맵은 그 도구가 읽는 구성 정보다.

  7. 앞서 예로 든 덧셈 함수의 선언이 Add.h에 들어 있다고 생각해 보자. 이를 SampleMath라는 모듈로 구성하는 모듈 맵은 다음처럼 작성할 수 있다.

    module SampleMath {
      header "Add.h"
      export *
    }
  8. 이 예시는 Add.h를 SampleMath 모듈에 포함하고, 그 헤더의 선언을 외부에 공개한다는 의미다.

  9. 모듈 맵이 검색 가능한 위치에 있고 나머지 빌드 조건도 갖춰지면, Swift에서는 import SampleMath처럼 모듈 이름으로 인터페이스를 가져올 수 있다.

  10. Swift 컴파일러에는 Clang Importer라는 구성 요소가 있다.

  11. Clang 모듈이 공개하는 C, Objective-C API를 Swift에서 사용할 수 있는 형태로 가져오는 역할을 한다.

  12. 모듈 맵은 이 과정에서 헤더와 모듈의 관계를 알려주는 정보다.

  13. 즉, Add.h에 함수 선언이 있는 것만으로는 부족하다. 컴파일러가 SampleMath라는 이름과 그 헤더의 관계를 알 수 있어야 한다.

  14. 이번 오류도 같은 관점에서 이해할 수 있었다. GoogleUtilities를 설치했는지만 확인할 것이 아니라, Swift Pod가 이를 가져오는 데 필요한 모듈 구성이 갖춰졌는지 살펴봐야 했다.

  15. use_frameworks!가 1️⃣의 오류를 해결하는 이유도 여기에 있다. Pod를 프레임워크로 구성하면 CocoaPods가 각 Pod의 모듈 맵을 생성하고, 그 타겟을 모듈을 정의하는 것으로 취급한다. 그래서 GoogleUtilities에 별도 설정을 하지 않아도 이 검사를 통과한다.

withNonModularIncludes.js 플러그인 파일을 만들어서 해결

  1. 정리하면, 이번 문제는 “GoogleUtilities가 모듈로 정의되어 있지 않아, Swift에서 모듈로 가져올 수 없었다는 오류”였다.
  2. 당시에는 useFrameworks: "static"과 함께 로그가 안내한 모듈 헤더 설정도 적용하기로 했다. 그래서 네이티브 코드에 직접 접근하기 시작했다. Podfile을 수정하는 Expo Config Plugin인 withNonModularIncludes.js 플러그인을 만들었고, 이후 발생한 문제의 설정도 이 파일에 함께 담았다.
  3. 이 파일을 app.json의 플러그인 목록에 등록해 네이티브 프로젝트 생성 과정에서 실행되도록 했다.
  4. 플러그인에는 GoogleUtilities의 모듈 헤더 설정을 변경하는 코드가 들어 있다.
  5. 먼저 이 파일이 언제 실행되고 어디에 설정을 반영하는지 살펴보자. 이어서 파일 안의 각 설정이 어떤 문제에 대응하는지 설명한다.

💡 Expo Prebuild와 Config Plugin: 설정을 적용하는 위치

  1. Expo의 Prebuild는 앱 설정과 Config Plugin 등을 바탕으로 ios, android 네이티브 프로젝트를 생성, 수정하는 과정이다.

  2. 앞서 expo-build-properties에 작성한 설정도 이 과정을 통해 네이티브 프로젝트에 반영된다.

  3. 생성된 Podfile을 직접 수정할 수도 있지만, npx expo prebuild --clean처럼 네이티브 디렉터리를 지우고 다시 생성하면 수동 수정 내용은 사라진다.

  4. 그래서 유지할 변경 사항을 생성 과정에 포함하는 방식이 필요하다.

  5. Config Plugin은 이런 변경을 코드로 정의하는 방식이다.

  6. 이번 파일에서는 withDangerousMod를 사용해 생성 과정의 Podfile을 직접 읽고 수정했다.

  7. 파일 안에는 JavaScript와 Ruby 코드가 함께 등장한다. JavaScript는 Podfile의 문자열을 수정하고, 삽입된 Ruby 코드는 이후 CocoaPods가 실행한다.

  8. 이때 post_install은 CocoaPods가 생성한 프로젝트의 빌드 설정 등을 조정할 수 있도록 제공하는 후처리 지점이다.

    실행 시점실행되는 내용역할
    Expo Prebuild플러그인의 JavaScript 코드Podfile에 Pod 선언과 post_install 코드를 삽입한다.
    CocoaPods의 pod installPodfile의 Ruby 코드의존성을 통합하고, post_install에서 Pod 타겟의 빌드 설정을 변경한다.
    이후 네이티브 빌드생성된 프로젝트의 빌드 설정변경된 설정을 사용해 컴파일과 링크를 진행한다.
  9. 즉, 이 파일은 네이티브 의존성과 빌드 구성을 준비하는 과정에 개입한다.

파일에 포함한 첫 번째 조치: GoogleUtilities의 모듈 헤더 설정

  1. 아까 설치 오류 메시지는 두 가지 방법을 안내했다.

    • use_modular_headers!로 전역 설정을 적용한다.
    • 특정 Pod에 :modular_headers => true를 지정한다.
  2. 최종 플러그인에는 로그에서 지목한 GoogleUtilities에만 모듈 헤더 설정을 적용하는 방식을 넣었다.

  3. 다음은 플러그인이 Podfile에 삽입하는 Ruby 선언이다.

    pod 'GoogleUtilities', :modular_headers => true
  4. 이는 해당 Pod에 모듈 헤더 처리를 적용해, CocoaPods가 모듈 맵을 생성하는 데 필요한 구성을 갖추도록 하는 설정이다.

  5. 이를 삽입하는 JavaScript 코드는 다음과 같다.

    const googleUtilitiesPatch = `  pod 'GoogleUtilities', :modular_headers => true`;
    
    if (!podfileContent.includes("pod 'GoogleUtilities', :modular_headers => true")) {
      if (podfileContent.includes('use_expo_modules!')) {
        podfileContent = podfileContent.replace(
          'use_expo_modules!',
          `use_expo_modules!\n${googleUtilitiesPatch}`
        );
      }
    }
  6. Podfile에 같은 선언이 없고 use_expo_modules!가 있으면, 그 문자열 뒤에 선언을 추가한다.

  7. 다만 이 글을 정리하면서 알게 된 점이 있다. :modular_headers는 Pod를 정적 라이브러리로 통합할 때 모듈 맵을 만들어 주는 설정이다. use_frameworks!가 적용되면 모든 Pod가 프레임워크로 빌드되어 모듈이 자동으로 정의되므로, 이 설정은 사실상 효과가 없다.

  8. 당시에는 로그가 안내한 설정과 문서가 안내한 설정을 함께 적용했고, 어느 쪽이 문제를 해결했는지 구분하지 않았다. 지금 돌아보면 useFrameworks: "static"만으로 1️⃣은 해결됐을 것이다.

  9. 여기까지 해결 과정을 정리하면

    • Firebase의 의존성인 GoogleUtilities에 모듈 정의가 없어 Swift Pod와 함께 정적 라이브러리로 통합할 수 없는 문제가 있어서
    • RNFirebase 문서대로 useFrameworks: "static"을 적용해 Pod를 프레임워크로 구성했고
    • 로그가 안내한 :modular_headers => true도 함께 넣었다. (프레임워크 구성에서는 효과가 없는 설정이었다.)

원인 파악 시도: New Architecture와 정적 프레임워크 설정의 호환성

  1. 이 설정으로 pod install은 통과했다. 그러나 이어진 네이티브 컴파일에서 새 오류가 났다.
  2. 당시 프로젝트에는 New Architecture가 활성화되어 있었고, Firebase를 도입하면서 네이티브 의존성과 useFrameworks: "static" 설정을 추가한 상태였다.
  3. 정적 프레임워크 설정은 같은 타겟의 다른 Pod 구성에도 영향을 줄 수 있었다. 그래서 새로 추가한 설정이 기존 RN의 네이티브 구성과 맞지 않는 것은 아닌지 의심했다.
  4. 이 과정에서 기존 환경의 특징인 New Architecture도 함께 의심하며, 문제를 “New Architecture와 Firebase의 빌드 설정 사이의 호환성 문제”로 바라봤다.
  5. 그러나 명확한 근거가 없었다.

2️⃣ 추가로 발생한 문제) 프레임워크 모듈 내부의 비모듈 헤더 포함

  1. 첫 번째 문제 상황을 해결하니 또 다른 문제가 생겼다.

  2. 네이티브 코드를 컴파일하는 과정에서 헤더 참조 문제를 다뤄야 했다.

  3. 앞서 헤더 파일이 존재하는 것과, 그 헤더를 모듈로 가져올 수 있도록 구성하는 것은 별개라고 설명했다. 현재 구성에서 모듈의 일부로 인식되지 않는 헤더는 비모듈 헤더로 취급될 수 있었다.

  4. 프레임워크 모듈을 구성하는 헤더에서 이러한 비모듈 헤더를 포함하면, 컴파일러가 이를 허용하지 않아 오류가 발생할 수 있다. 헤더 파일이 없어서가 아니라, 모듈 안에서 해당 헤더를 포함하는 방식이 문제가 되는 것이다.

    • 컴파일러가 허용하지 않는 이유는 모듈을 다른 코드에서도 일관되게 가져다 쓸 수 있어야 하기 때문이다.
    • 일반적인 헤더 포함은 파일 내용을 읽어 들이는 방식이라, 먼저 선언된 매크로나 헤더를 포함한 순서에 영향을 받을 수 있다.
    • 반면 모듈은 인터페이스를 독립적으로 컴파일해 재사용하는 방식이다. 그 모듈의 공개 인터페이스가 모듈로 구성되지 않은 외부 헤더에 의존하면, 그 외부 헤더까지 안정적으로 포함해 재사용할 수 있는지 문제가 생길 수 있다.
  5. 이 문제에 대응하기 위해 프레임워크 모듈 내부의 비모듈 헤더 포함을 허용하는 설정을 추가했다.

  6. 설정은 CocoaPods의 post_install에서 적용하도록 했다. 수정하려는 대상이 CocoaPods가 만들어 주는 Pod 타겟의 빌드 설정이었기 때문이다.

  7. post_install은 CocoaPods가 생성한 프로젝트의 빌드 설정 등을 조정할 수 있는 후처리 지점이다. 플러그인은 이 블록 안에 다음 Ruby 코드를 삽입한다.

    config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
  8. 이 설정은 기존 헤더 구성을 유지하면서, 프레임워크 모듈 내부에서 비모듈 헤더를 포함할 수 있도록 제한을 완화한다.

    • 지금 돌아보면 도저히 원인을 알 수 없어서 우회하는 방법을 찾아서 해결한 점이 아쉬웠다.
  9. 구현에서는 모든 Pod 타겟을 순회하며 각 빌드 구성에 이 값을 적용했다.

  10. 앞서 GoogleUtilities에 적용한 설정이 모듈로 가져오기 위한 구성을 추가하는 조치였다면, 이번 설정은 컴파일 과정에서 허용할 헤더 포함 방식을 조정하는 조치다.

  11. 여기까지 해결 과정은 다음과 같다.

    • 컴파일 과정에서는 프레임워크 모듈 내부에서 비모듈 헤더를 포함하는 문제가 생겨
    • 이를 우회하기 위해 CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES를 적용해 해당 헤더 포함을 허용했다.
      • Prebuild에서 JavaScript 플러그인이 Podfile에 Ruby 코드를 삽입한다.
      • pod install 중 CocoaPods가 Pod 타겟을 구성한다.
      • post_install의 Ruby 코드가 각 타겟의 컴파일 설정을 변경한다.
      • 이후 실제 빌드에서 변경된 설정을 사용한다.

3️⃣ 함께 적용한 우회) RNFB 타겟의 모듈 정의 설정 변경

  1. 앞서 비모듈 헤더 포함을 허용하는 설정을 적용하고 추가로 RNFirebase 쪽의 모듈 정의 설정도 함께 조정했다.

  2. 이 조치 역시 2번에서 다룬 헤더 모듈 관련 컴파일 문제에 대응하는 과정의 일부로 판단했다.

  3. 설정을 이해하려면 먼저 빌드 타겟과 RNFB 모듈을 구분해야 한다.

    • 빌드 타겟은 어떤 소스를 어떤 설정으로 빌드해 결과물을 만들지 정의한 대상이다. RNFBApp 같은 타겟은 RNFirebase의 iOS 네이티브 코드를 빌드한다.
    • 여기서 모듈은 라이브러리의 공개 인터페이스를 이름 있는 단위로 가져올 수 있도록 구성한 것이다. 앞서 설명한 GoogleUtilities의 모듈과 같은 맥락이다.
  4. RNFirebase는 JavaScript의 요청을 Firebase iOS SDK로 전달하는 연결 코드를 제공한다.

  5. 따라서 Firebase뿐 아니라 RN이 제공하는 인터페이스도 사용하며, 이 과정에서 RN의 헤더를 참조한다.

  6. RNFB를 프레임워크 모듈로 구성할 때, 공개 헤더가 가져오는 외부 헤더가 비모듈 헤더로 처리된다면 2번에서 설명한 포함 문제가 발생할 수 있다.

  7. 나는 RNFB 타겟의 모듈 구성도 관련된 지점으로 보고, 모듈 정의 빌드 설정을 변경하는 우회 조치를 함께 적용했다.

  8. 이를 위해 post_install에서 Pod 타겟들을 순회하면서, 이름이 RNFB로 시작하는 타겟의 각 빌드 구성에 다음 설정을 적용했다.

    if target.name.start_with?("RNFB")
      config.build_settings['DEFINES_MODULE'] = 'NO'
    end
  9. DEFINES_MODULE은 해당 타겟이 모듈을 정의하는지를 지정하는 빌드 설정이다. 이를 NO로 바꾸는 것은 RNFirebase의 기능이나 네이티브 코드 컴파일을 끄는 것이 아니라, “해당 타겟의 모듈 정의 설정을 비활성화”하는 것이다.

    • Build settings reference | Apple Developer Documentation
  10. 이 코드를 post_install에 넣은 이유는 CocoaPods가 구성한 Pod 타겟의 빌드 설정을 변경하기 위해서다.

  11. 플러그인은 Podfile에 Ruby 코드를 삽입하고, CocoaPods는 이를 실행해 이후 컴파일에 사용할 설정을 조정한다.

  12. 2번의 설정은 프레임워크 모듈 안에서 비모듈 헤더를 포함하도록 허용하고, 이번 설정은 RNFB 타겟의 모듈 정의 설정을 변경한다. 같은 헤더 모듈 관련 문제를 우회하려는 두 방향의 조치로 적용했다.

  13. 이 설정들을 플러그인으로 함께 적용한 뒤 iOS 빌드에 성공했다.

  14. 여기까지 해결 과정을 정리하면

    • 설치 과정에서 발생한 오류는 GoogleUtilities의 모듈 정의가 부족하다는 로그를 보고, RNFirebase 문서대로 useFrameworks: "static"을 적용했다. 로그가 안내한 modular_headers => true도 함께 넣었다.
    • 컴파일 과정에서 발생한 오류는 비모듈 헤더 포함을 허용하도록 CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES를 적용했다.
    • 추가 우회 설정인 RNFB 타겟의 DEFINES_MODULE을 NO로 변경했다.

최종 정리

  1. 설치 오류(1️⃣)는 expo-build-properties의 useFrameworks: "static"으로 해결했다. 이와 별도로 플러그인에 담은 설정을 정리하면 다음과 같다.

    적용 목적적용 대상설정바뀌는 동작
    Swift 의존성 통합에 필요한 모듈 구성 추가GoogleUtilities:modular_headers => true정적 라이브러리 구성에서 모듈 맵을 생성. use_frameworks! 적용 후에는 효과 없음
    비모듈 헤더 포함 제한 완화모든 Pod 타겟의 각 빌드 구성CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES프레임워크 모듈 내부의 비모듈 헤더 포함 허용
    헤더 모듈 관련 컴파일 문제에 대한 추가 우회이름이 RNFB로 시작하는 타겟의 각 빌드 구성DEFINES_MODULE = NORNFB 타겟의 모듈 정의 빌드 설정 비활성화
  2. 첫 번째 설정은 의존성을 구성할 때 사용하도록 Pod 선언에 넣었다.

  3. 나머지 두 설정은 CocoaPods가 구성한 Pod 타겟의 빌드 설정을 변경해야 하므로 post_install에서 적용하도록 했다.

  4. 이를 withNonModularIncludes.js에 담아 Prebuild 과정에서 Podfile에 삽입하도록 했다. 네이티브 프로젝트를 다시 생성할 때도 같은 변경을 적용하기 위해서다.

최종 파일: withNonModularIncludes.js

위의 설정을 적용하는 플러그인의 전체 코드는 다음과 같다.

const { withDangerousMod } = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');

/**
 * [Expo Config Plugin]
 * 이 프로젝트의 iOS 설치, 컴파일 문제에 대응한 설정을
 * Prebuild 과정에서 Podfile에 삽입합니다.
 *
 * 1. GoogleUtilities의 모듈 헤더 설정
 * 2. 비모듈 헤더 포함 허용 및 RNFB 타겟의 모듈 정의 설정 변경
 */
const withNonModularIncludes = (config) => {
  return withDangerousMod(config, [
    'ios',
    async (config) => {
      // 생성된 iOS 프로젝트의 Podfile을 읽습니다.
      const podfilePath = path.join(
        config.modRequest.platformProjectRoot,
        'Podfile'
      );
      let podfileContent = fs.readFileSync(podfilePath, 'utf-8');

      // [1] 설치 로그가 안내한 GoogleUtilities의 모듈 헤더 설정입니다.
      //     use_frameworks! 적용 후에는 사실상 효과가 없는 설정입니다.
      const googleUtilitiesPatch =
        `  pod 'GoogleUtilities', :modular_headers => true`;

      if (
        !podfileContent.includes(
          "pod 'GoogleUtilities', :modular_headers => true"
        )
      ) {
        if (podfileContent.includes('use_expo_modules!')) {
          podfileContent = podfileContent.replace(
            'use_expo_modules!',
            `use_expo_modules!\n${googleUtilitiesPatch}`
          );
        }
      }

      // [2] 헤더 모듈 관련 컴파일 문제에 대응한 두 가지 우회 설정입니다.
      // 삽입된 Ruby 코드는 CocoaPods의 post_install에서 실행됩니다.
      const fixCode = `
    installer.pods_project.targets.each do |target|
      target.build_configurations.each do |config|
        # 프레임워크 모듈 내부의 비모듈 헤더 포함을 허용합니다.
        config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'

        # RNFB 타겟의 모듈 정의 빌드 설정을 비활성화합니다.
        if target.name.start_with?("RNFB")
          config.build_settings['DEFINES_MODULE'] = 'NO'
        end
      end
    end
      `;

      // 설정 이름이 있으면 fixCode 전체의 삽입을 건너뜁니다.
      // 두 설정의 적용 여부와 값을 개별적으로 검사하지는 않습니다.
      if (
        !podfileContent.includes(
          'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'
        )
      ) {
        const postInstallPattern =
          /post_install\s+do\s+\|installer\|/;

        if (postInstallPattern.test(podfileContent)) {
          // 일치하는 기존 post_install 블록의 시작 부분에 삽입합니다.
          podfileContent = podfileContent.replace(
            postInstallPattern,
            `post_install do |installer|\n${fixCode}`
          );
        } else {
          // 정규식에 맞는 블록을 찾지 못하면 새 블록을 추가합니다.
          podfileContent +=
            `\n\npost_install do |installer|\n${fixCode}\nend\n`;
        }
      }

      fs.writeFileSync(podfilePath, podfileContent);
      return config;
    },
  ]);
};

module.exports = withNonModularIncludes;

Analytics 연동을 성공적으로 마친 뒤 얻을 수 있었던 것들

  1. 빌드 이후에는 원래 목표였던 Analytics 기능 구현을 이어갔다.
  2. 화면 이동에 따른 조회 이벤트를 기록하고, 방문 이력과 기능별 행동 점수를 바탕으로 사용자 속성을 계산하는 로직을 추가했다.
  3. 게시글, 댓글, 약 리뷰 작성이 성공한 시점에도 이벤트를 연결했다.
  4. 사용자 ID 연결과 로그아웃 시 식별 정보 해제 처리도 보완했다.
  5. 이렇게 iOS 빌드를 복구하고, 설정 변경을 네이티브 프로젝트 생성 과정에 반영했으며, 사용자 행동을 분석할 이벤트와 속성을 앱 코드에 연결했다.

회고: React Native를 이해한 경험

  1. 이전까지는 RN을 단순히 JavaScript로 화면과 동작을 구현하는 데 사용했다.
  2. 네이티브 라이브러리를 설치할 때도 안내된 설정을 적용하는 데 집중했고, 그 설정이 내부에서 무엇을 바꾸는지까지 살펴보지는 않았다.
  3. Firebase Analytics를 도입하면서 이러한 접근만으로 해결하기 어려운 iOS 설치, 컴파일 문제를 만났다.
  4. JavaScript 코드를 수정하는 것만으로는 아무것도 할 수 없었고, 내가 사용하는 도구가 네이티브 기능을 어떻게 연결하고 빌드하는지 이해할 필요가 생겼다.
  5. 이를 계기로 JSI와 TurboModules가 JavaScript와 네이티브 코드를 연결하는 방식, RNFirebase가 Firebase SDK를 호출하는 역할, CocoaPods가 iOS 의존성을 구성하는 과정을 살펴봤다.
  6. 헤더와 모듈 맵, 프레임워크와 링크 방식도 함께 공부하며 네이티브를 이해하려고 노력했다.
  7. RN을 바라보는 관점이 달라졌다.
  8. JavaScript로 사용하는 API 뒤에 네이티브 구현과 의존성, 컴파일과 링크 과정이 있다는 것을 이해했다.
  9. RN의 내부를 모두 이해하게 된 것은 아니지만, 익숙한 JavaScript 영역 밖에서 문제가 발생했을 때도 로그와 의존성 관계를 따라 필요한 계층까지 내려가 살펴볼 수 있다는 경험을 얻었다.
  10. 앞으로도 도구의 사용법에 머무르지 않고, 문제를 해결하는 데 필요한 내부 원리까지 확인하는 개발을 이어가고 싶다.

댓글