Project

[iOS/Tuist 4] Firebase Crashlytics 설정

차코.. 2025. 6. 15. 22:41

 

상황

기존에 프로젝트에서 Sentry를 사용하고 있었으나, 무료 플랜이 만료되어 사용하지 못하는 상황이었습니다. 이에 Sentry에 대한 의존성을 제거하고, Firebase Crashlytics를 적용했습니다.

 

* 저희 프로젝트는 Tuist, SPM을 사용해 모듈 및 의존성을 관리하고 있습니다.

 

 


 

Firebase Crashlytics란

Crashlytics Firebase에서 제공하는 크래시 분석 도구입니다. 앱이 어떠한 이유로 크래시가 났을 때, 어떤 코드에서 문제가 발생했는지 스택 트레이스를 기록하고 보고해줍니다.

 

 


1. Crashlytics SDK 추가

우선, Firebase Apple 플랫폼 SDK 저장소를 추가합니다.

 

https://github.com/firebase/firebase-ios-sdk.git

 

Tuist를 사용하고 있기 때문에, Package의 dependancy에 해당 패키지에 대한 의존성을 추가합니다.

 

 


2. Xcode를 설정해서 dSYM 파일 자동 업로드

 

1) Build Settings

 

Target.target(...)은 Tuist의 Target 구조체 내에 정의된 정적 함수로, 특정 유형의 빌드 타겟을 생성할 때 사용됩니다. 여기에서 타겟 이름, 소스 및 리소스 파일 위치, 번들 ID, 의존성, 빌드 설정 등을 지정할 수 있는데요. 

 

(Xcode에서 살펴보면, 문서의 설명대로 Targets > Build Settings > Build Options에서 해당 인자를 확인할 수 있습니다.)

 

이를 Tuist에서 자동으로 설정할 수 있도록, 아래와 같이 SettingDictionary 타입의 배열 내에 원하는 설정값들을 지정하는 함수를 만들고, 빌드 설정에 추가해줍니다.

 

func setCrashlyticsSettings() -> SettingsDictionary {
    merging([
        "DEBUG_INFORMATION_FORMAT": SettingValue(stringLiteral: "dwarf-with-dsym")
    ])
}

 

특히 문서에서 나오는 DEBUG_INFORMATION_FORMAT에서의 DWARF with dSYM File 은 Xcode가 빌드할 때마다 dSYM 파일을 생성하게 하는 설정값입니다.

 

 

 

2) Run Scripts 추가 (Build Phases)

 

dSYM 파일을 빌드할 때마다 생성하면, 해당 파일을 Crashlytics에 자동으로 업로드 해주어야 하기에, Scrpits를 추가해줘야 합니다.

Tuist에서는 해당 Script 또한 target 함수에 넘겨주어야 합니다.

 

static let uploadDSYMToFirebaseScript = TargetScript.post(
  script: """
    "$SRCROOT/../../Tuist/Dependencies/SwiftPackageManager/.build/checkouts/firebase-ios-sdk/Crashlytics/run"
  """,
  name: "Upload dSYM to Firebase",
  inputPaths: [
    "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}",
    "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}",
    "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist",
    "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist",
    "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)",
    "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}.debug.dylib"
  ]
)

 

이 때, TargetScript에는 pre(), post() 두 개의 함수가 존재하는데요. 각각 빌드 단계 전에 실행할지, 단계 후에 실행할지의 여부를 결정해주는 것입니다. dSYM 파일은 빌드가 끝난 이후에 생성되기 때문에, 끝난 이후에 실행해주어야 정상적으로 동작합니다.

 

 

 

 

 

 

3) Prod, Dev 앱을 따로 관리하는 경우

 

적용하고자 하는 프로젝트에서 Prod, Dev 타겟이 나뉘어진다면 GoogleService-Info.plist도 두 개가 존재할 것입니다.

때문에 타겟에 따라서 info 파일의 경로를 구분해 빌드해주는 스크립트를 짜야 합니다.

 

static let googleServiceInfo = TargetScript.pre(
script: """
case "${TARGET_NAME}" in
"YOUR-TARGET-NAME" )
  cp -r "$SRCROOT/Resources/GoogleService-Info.plist" \\
        "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist" // 실제 경로 설정
  ;;
"YOUR-TARGET-NAME 2" )
  cp -r "$SRCROOT/../Demo/Resources/GoogleService-Info.plist" \\
        "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist" // 실제 경로 설정
  ;;
esac
""",
name: "Copy GoogleService-Info.plist"
)

 

 


3. 테스트 비정상 종료를 강제로 적용하여 설정 완료

 

설정을 완료하면, Firebase Console에 Crashlytics 대시보드에 초기 데이터를 표시해주기 위해서 첫번째 크래시를 강제로 발생시켜주어야 합니다. 공식 문서에 아래와 같이 예시 코드가 제공되어 있습니다.

 

import UIKit

class ViewController: UIViewController {
  override func viewDidLoad() {
      super.viewDidLoad()

      // Do any additional setup after loading the view, typically from a nib.

      let button = UIButton(type: .roundedRect)
      button.frame = CGRect(x: 20, y: 50, width: 100, height: 30)
      button.setTitle("Test Crash", for: [])
      button.addTarget(self, action: #selector(self.crashButtonTapped(_:)), for: .touchUpInside)
      view.addSubview(button)
  }

  @IBAction func crashButtonTapped(_ sender: AnyObject) {
      let numbers = [0]
      let _ = numbers[1]
  }
}

 

이 때, 디버거랑 연결되어 있으면 크래시 리포트가 Crashlystics에 전송되지 않습니다. 그래서

 

  1. 빌드를 한 뒤
  2. 디버거와의 연결을 끊고 (실행 중단 버튼)
  3. 앱을 시뮬/실기기에서 켠 후 크래시 발생
  4. 디버거와 연결해서 재빌드

의 과정을 거쳐주어야 합니다. 4번 과정을 생략할 경우, dSYM 파일을 Firbase 서버에 전송하지 못합니다. 그 이유는 크래시가 난 이후, 다음 빌드에서 해당 리포트가 전송되기 때문입니다.

 

 

 

Product > Scheme > Edit scheme에서 Arguments Passed on Launch(실행 인수 전달) 섹션에 -FIRDebugEnabled를 추가해주세요. 해당 인수는 Firebase SDK 내부의 디버그 로그 출력 기능을 켜주는 런타임 설정입니다.

 

 


4. 콘솔에 뜨지 않는 경우

 

저의 경우, 로그에는 정상적으로 Completed report submission가 떴지만, 콘솔 초기화가 되지 않는 문제가 있었습니다.

Crashlystics를 연결하는 과정은 문서에서도 볼 수 있듯, 간단한 과정이지만 콘솔 초기화가 되지 않는다면... 여러가지 방법들을 시도해보며 이유를 유추해보아야 했습니다. 아래는 제가 시도해보았던 내용들입니다.

 

빌드 시 간혹 dSYM 파일이 비어있다는 경고가 뜨는 상황도 있었습니다.
empty dSYM file detected, dSYM was created with an executable with no debug info. 관련 논의 링크

 

 

 

1. Build Settings 설정값 추가

 

 

func setCrashlyticsSettings() -> SettingsDictionary {
    merging([
        "DEBUG_INFORMATION_FORMAT": SettingValue(stringLiteral: "dwarf-with-dsym"),
        "ENABLE_BITCODE": SettingValue(stringLiteral: "NO")
    ])
}

 

- ENABLE_BITCODE: Bitcode 비활성화 관련 링크

 

 

 

2. Debug executable == NO

 

Debug 실행일 때는 인식을 하지 못하기 때문에, 해당 설정도 Off 합니다.

 

 

 

3. 스크립트 추가해서 분석

 

해당 링크를 읽고 스크립트를 추가로 만들어서, Report Navigator > Build Report에서 어느 부분이 문제인지 더 자세히 추적해 볼 수 있습니다. 저의 경우, 필요한 info 파일이나 dSYM이 정상적으로 잘 만들어졌는지 하나씩 확인하는 스크립트를 짜서 디버깅했습니다.

 

 

 

4. dSYM 파일이 잘 생성되었는지 확인

 

dSYM 파일이 제대로 생성되었는지는 Archives 폴더 내에서 직접 확인해 볼 수 있습니다.

 

아카이브 이후 아래의 경로로 들어가서 파인더를 열어보면,

 

open ~/Library/Developer/Xcode/Archives/{ YOUR-APP }.xcarchive/dSYMs

 

 

 

위와 같이 dSYM 파일들이 잘 생성된 것을 알 수 있습니다. (이 부분이 제대로 생성이 안되고 있으면 설정값들을 다시 한 번 검토해보아야 합니다.)

 

 

 

5. packageSettings > Firebase 내부 의존성 모듈 추가

 

let packageSettings = PackageSettings(
    productTypes: [
        ...
        "Firebase": .framework,
        "FirebaseRemoteConfig": .framework,
        "GULEnvironment": .framework,
        "GULLogger": .framework,
        "GULNSData": .framework,
        "GULNetwork": .framework,
        "GULUserDefaults": .framework,
        "GULReachability": .framework,
    ],
    baseSettings: .settings(
        configurations: [
        	...
        ]
    )
)

 

Firebase의 내부 유틸리티 모듈들까지 명시해주었습니다.

 

 


결과

 

정상적으로 크래시를 잡아 동작하기 시작합니다.