본문 바로가기
Mobile2026년 9월 13일20분 읽기

모바일 앱 크래시·ANR 잡기 — Sentry로 크래시프리율·심볼리케이션·릴리스 헬스 관리

YS
김영삼
조회 89
모바일 앱 크래시·ANR 잡기 — Sentry로 크래시프리율·심볼리케이션·릴리스 헬스 관리

크래시 관리의 핵심은 단 하나, "읽을 수 있는 스택트레이스를 크래시프리율에 연결하는 것"이다. 심볼리케이션(dSYM/mapping)이 안 되면 대시보드의 숫자는 그냥 장식이고, 릴리스 헬스가 없으면 어느 배포가 앱을 죽였는지 영원히 모른다.

이 글은 Sentry를 기준으로 iOS·Android 앱의 크래시와 ANR을 실제로 잡아내는 과정을 정리한다. 크래시프리율을 어떤 지표로 봐야 하는지, 심볼 파일을 CI에서 어떻게 자동 업로드하는지, ANR과 메인스레드 블로킹을 구분해 추적하는 법, 그리고 재현 안 되는 크래시를 breadcrumb으로 역추적하는 실전 요령까지 다룬다.

앱을 몇 개 운영하다 보면 결국 같은 문제에 부딪힌다. 리뷰에는 "자꾸 튕겨요" 별 하나가 달리는데, 정작 내 폰에서는 멀쩡하다. 재현이 안 되니 고칠 수도 없다. 크래시 리포팅 도구를 붙이는 이유가 여기 있다. 그런데 도구만 붙인다고 끝이 아니다. 나도 처음엔 Crashlytics를 대충 붙여놓고 "크래시프리 99.2%"라는 숫자만 보며 안심했었는데, 정작 스택트레이스가 0x00012a4c 같은 주소 덩어리라 아무것도 못 고쳤던 적이 있다. 이 글은 그 삽질을 안 하도록 쓴다.

지표실무 목표 감각비고
Crash-free sessions99.5% 이상세션 단위, 노출 많은 앱의 기본선
Crash-free users99.0% 이상사용자 단위, 항상 세션보다 낮게 나옴
ANR rate (Android)0.47% 미만Google Play '나쁨' 임계치 기준
심볼리케이션 성공률100% 목표하나라도 실패하면 그 이슈는 못 고침
릴리스 채택률 대비 크래시배포 후 24~48h 관찰신규 릴리스 헬스의 핵심 관찰 구간

크래시프리율, 세션 기준과 사용자 기준 중 뭘 봐야 하나

둘 다 봐야 하지만, 우선순위를 정한다면 배포 판단은 crash-free sessions로, 사용자 영향 판단은 crash-free users로 본다. 세션 기준은 앱이 실행된 세션 중 크래시 없이 끝난 비율이고, 사용자 기준은 크래시를 한 번이라도 겪은 사람을 뺀 비율이다. 그래서 사용자 기준이 항상 더 낮게 나온다. 한 명이 하루에 세션을 20번 열고 그중 한 번만 튕겨도, 세션 기준으로는 95%지만 그 사용자는 "크래시 겪은 유저"로 100% 카운트되기 때문이다.

내가 실무에서 쓰는 감각은 이렇다. 릴리스 게이트(배포를 막을지 말지)는 세션 기준으로 잡는다. 세션 기준 99.5%가 무너지면 롤백을 검토한다. 반면 "이 크래시를 이번 스프린트에 고칠 가치가 있나"를 판단할 때는 사용자 수를 본다. 세션 100건을 잡아먹는 크래시라도 실제 영향받은 유저가 3명이면 우선순위가 낮다. 반대로 세션 30건인데 유저 30명이면 각각 다른 사람이 한 번씩 겪는 거라 첫인상을 망치는 크래시일 확률이 높다.

참고 크래시프리율은 "세션이 Sentry에 정상적으로 보고됐다"는 전제 위에서만 정확하다. 앱이 죽으면서 이벤트를 못 보내는 경우(특히 시작 직후 크래시)가 있어서, 콜드 스타트 크래시는 실제보다 과소집계되기 쉽다. 그래서 부팅 크래시는 별도로 스토어 콘솔(Play Console / App Store Connect)과 교차 확인한다.

심볼리케이션이 뭐고 왜 이게 전부인가

심볼리케이션은 주소·오프셋 덩어리인 스택트레이스를 사람이 읽는 함수명·파일·라인으로 되돌리는 과정이다. 릴리스 빌드는 최적화와 난독화 때문에 심볼 정보가 바이너리에서 떨어져 나간다. iOS는 그 정보가 dSYM 파일에, Android는 R8/ProGuard의 mapping.txt에 들어간다. 이 파일을 Sentry에 올려두지 않으면, 크래시는 잡히는데 어디서 터졌는지는 모른다. 대시보드에 이슈는 쌓이는데 못 고치는 상태가 딱 이거다.

여기서 사람들이 제일 많이 데는 지점이 iOS의 Bitcodehidden dSYM이다. App Store에 Bitcode로 올리면 애플이 서버에서 다시 컴파일하기 때문에, 내가 로컬에서 가진 dSYM은 실제 배포 바이너리와 UUID가 안 맞는다. 이 경우 App Store Connect에서 dSYM을 내려받아 올려야 한다. 다행히 Bitcode는 Xcode 14부터 deprecated라 신규 앱은 이 함정을 거의 안 밟지만, 오래된 파이프라인에는 아직 남아있다.

Sentry는 dSYM 대신 debug ID(UUID) 매칭으로 심볼을 찾는다. 그래서 빌드마다 dSYM UUID가 이벤트의 debug_meta와 일치해야 한다. CI에서 자동 업로드하는 게 정답이다. Fastlane이라면 이렇게 한다.

# Fastfile — iOS 릴리스 빌드 후 dSYM 업로드
lane :release do
  build_app(scheme: "MyApp")  # .xcarchive 생성

  # sentry-cli로 dSYM 업로드 (UUID 기준으로 매칭됨)
  sentry_upload_dif(
    auth_token: ENV["SENTRY_AUTH_TOKEN"],
    org_slug: "my-org",
    project_slug: "ios-app",
    include_sources: true  # 소스 컨텍스트까지 올려 스택트레이스에 코드 표시
  )

  upload_to_app_store
end

Android는 Gradle 플러그인이 훨씬 편하다. io.sentry.android.gradle을 적용하면 릴리스 빌드 시 mapping 파일을 자동으로 올려준다. R8이 켜져 있어야(minifyEnabled true) 난독화가 되고, 그래야 mapping이 의미가 있다.

// app/build.gradle.kts
plugins {
    id("io.sentry.android.gradle") version "4.14.1"
}

sentry {
    // ProGuard/R8 mapping 자동 업로드
    includeProguardMapping.set(true)
    autoUploadProguardMapping.set(true)

    // NDK 네이티브 크래시까지 심볼리케이트하려면
    uploadNativeSymbols.set(true)

    // 소스 컨텍스트(스택트레이스에 코드 라인 표시)
    includeSourceContext.set(true)

    org.set("my-org")
    projectName.set("android-app")
}
주의 mapping 업로드가 성공해도 versionCode/versionName이 이벤트와 안 맞으면 매칭이 안 된다. 로컬에서 수동으로 릴리스 빌드를 뽑아 테스트 배포한 뒤, CI가 같은 versionCode로 다시 빌드해 스토어에 올리면 mapping이 두 번 생성되면서 꼬인다. 릴리스 빌드는 반드시 CI에서 한 번만 만들고, 그 아티팩트를 그대로 배포하라. 이거 하루 날렸다.

ANR과 메인스레드 블로킹은 크래시와 뭐가 다른가

ANR(Application Not Responding)은 앱이 죽는 게 아니라 멈추는 것이다. Android에서 메인(UI) 스레드가 일정 시간(입력 이벤트 기준 약 5초) 응답하지 않으면 시스템이 ANR로 판정하고 "앱이 응답하지 않습니다" 다이얼로그를 띄운다. 크래시는 스택트레이스가 명확한데, ANR은 "그 순간 메인 스레드가 뭘 하고 있었나"를 봐야 해서 추적 감각이 다르다.

iOS에는 ANR이라는 공식 용어는 없지만 대응물이 있다. 앱이 실행 상태인데 응답이 없으면 워치독이 죽이는 게 있고(대표적으로 시작 시 20초 넘으면 0x8badf00d, 읽으면 "ate bad food"), Sentry는 이걸 App Hang으로 잡는다. 둘 다 원인은 대개 같다. 메인 스레드에서 하면 안 되는 걸 하는 것.

구분크래시ANR / App Hang
증상앱이 종료됨앱이 멈춤(→ 사용자가 강제종료)
전형적 원인null 참조, 인덱스 초과, 예외 미처리메인 스레드 I/O, 무거운 파싱, 락 경합
핵심 단서예외 발생 지점 스택멈춘 순간 메인 스레드 스택
Sentry 감지자동(uncaught)옵션 활성화 필요

Sentry Android SDK는 ANR 감지가 기본 켜져 있지만, 멈춘 순간의 스택을 제대로 받으려면 최신 SDK의 ANR v2(Android 11+의 ApplicationExitInfo 기반)를 쓰는 게 훨씬 정확하다. iOS App Hang은 명시적으로 켜준다.

// iOS — SentrySDK 초기화
SentrySDK.start { options in
    options.dsn = "https://examplePublicKey@o0.ingest.sentry.io/0"
    options.enableAppHangTracking = true
    options.appHangTimeoutInterval = 2.0  // 2초 이상 멈추면 App Hang으로 보고
    options.enableAutoSessionTracking = true  // 릴리스 헬스용 세션
    options.tracesSampleRate = 0.2
}

실무 팁 하나. ANR이 많이 잡히는 앱을 열어보면 십중팔구 onCreateApplication 초기화에서 SharedPreferences를 동기로 읽거나, JSON을 메인에서 파싱하거나, DB 마이그레이션을 UI 스레드에서 돌린다. 나는 App Hang timeout을 일부러 2초로 낮게 잡아둔다. 기본값(iOS 2초, 하지만 관대하게 쓰는 팀 많음)보다 공격적으로 잡아야 "사용자는 느리다고 느끼는데 워치독은 안 죽이는" 구간이 보인다.

재현 안 되는 크래시, breadcrumb으로 역추적하기

breadcrumb은 크래시 직전까지 앱에서 일어난 일들의 타임라인이다. 화면 전환, 네트워크 요청, 버튼 탭, 로그 출력 같은 게 시간순으로 쌓여서 크래시 이벤트에 붙는다. 스택트레이스가 "어디서"를 알려준다면 breadcrumb은 "어쩌다가"를 알려준다. 재현이 안 되는 크래시는 이 "어쩌다가"가 유일한 단서인 경우가 많다.

SDK가 자동으로 남기는 것(네비게이션, HTTP, 시스템 이벤트)만으로도 상당히 유용하지만, 진짜 힘을 발휘하는 건 도메인 이벤트를 직접 심을 때다. 예를 들어 결제 플로우라면 "장바구니 진입 → 쿠폰 적용 → 결제수단 선택 → 결제 요청" 같은 상태를 breadcrumb으로 남긴다. 그러면 크래시 유저의 90%가 "쿠폰 적용 직후"에 몰려 있는 게 보이고, 재현 시나리오가 순식간에 좁혀진다.

// Kotlin — 도메인 breadcrumb + 사용자 컨텍스트
Sentry.addBreadcrumb(
    Breadcrumb().apply {
        category = "checkout"
        message = "coupon applied"
        level = SentryLevel.INFO
        setData("couponId", coupon.id)
        setData("cartTotal", cart.total)
    }
)

// 크래시를 특정 조건으로 검색 가능하게 태그/컨텍스트 추가
Sentry.configureScope { scope ->
    scope.setTag("membership", user.grade)   // 검색/필터용
    scope.setContexts("experiment", mapOf("checkout_v2" to true))
}
주의 breadcrumb과 컨텍스트에 개인정보(전화번호, 이메일 원문, 결제 토큰)를 그대로 넣지 마라. 서버로 전송되는 데이터다. Sentry의 beforeBreadcrumb/beforeSend 훅에서 민감 필드를 마스킹하거나, 처음부터 식별자(couponId 같은)만 넣는다. "cartTotal은 되고 카드번호는 안 된다"는 감각을 팀 규칙으로 못박아두는 게 좋다.

릴리스 헬스 — 어느 배포가 앱을 죽였는지 아는 법

릴리스 헬스는 버전별로 세션·크래시·채택률을 묶어서 보여주는 기능이다. 이게 없으면 크래시가 늘었을 때 "새 버전 때문인지, 원래 있던 문제가 노출이 늘어서 그런지"를 구분 못 한다. 릴리스 헬스가 켜져 있으면 대시보드에서 "4.2.0은 crash-free 99.6%인데 4.3.0은 배포 6시간 만에 98.1%로 떨어짐"이 한눈에 보인다. 그게 롤백 신호다.

핵심은 세 가지가 일치해야 한다는 것. ① SDK가 세션 트래킹을 켜고(enableAutoSessionTracking), ② 이벤트의 release 식별자가 실제 배포 버전과 같고, ③ CI에서 sentry-cli releases로 릴리스를 등록하며 커밋을 연결한다. release 이름 컨벤션은 보통 com.myapp@4.3.0+412처럼 패키지@버전+빌드번호로 잡는다. 이렇게 해야 같은 버전명이라도 빌드가 다르면 구분된다.

# CI — 릴리스 등록 + 커밋 연결 + 배포 마킹
VERSION="com.myapp@4.3.0+412"

sentry-cli releases new "$VERSION"
# 커밋 연결 → 어떤 커밋이 이 크래시를 유발했는지 suspect commit 추적
sentry-cli releases set-commits "$VERSION" --auto
sentry-cli releases finalize "$VERSION"

# 실제 스토어 배포 시점 기록 (채택률 곡선의 기준점)
sentry-cli releases deploys "$VERSION" new -e production

커밋을 연결해두면 Sentry가 스택트레이스와 커밋 이력을 대조해 suspect commit(이 이슈를 유발했을 가능성이 큰 커밋)을 짚어준다. 완벽하진 않지만 "이 파일 최근에 누가 만졌지"를 수동으로 git blame 하는 시간을 많이 줄여준다. 개인적으로는 이 기능 하나 때문에 릴리스에 커밋 연결하는 걸 필수로 친다.

Sentry vs Firebase Crashlytics, 언제 뭘 쓰나

결론부터. 크래시만 볼 거면 Crashlytics로 충분하고 무료다. 크래시 + 성능(트레이싱) + 웹/백엔드까지 한 곳에서 보고 싶고 릴리스 헬스·소스맵 관리를 세밀하게 하고 싶으면 Sentry다. 두 개를 같이 붙이는 팀도 있는데(Crashlytics는 스토어 콘솔 연동이 자연스럽고, Sentry는 breadcrumb·성능이 강함), 이벤트가 중복 집계되니 크래시 카운트를 볼 때는 기준 도구를 하나로 정해야 헷갈리지 않는다.

항목SentryFirebase Crashlytics
비용이벤트 쿼터 기반 유료(무료 티어 있음)무료
커버리지모바일+웹+백엔드 통합모바일 중심
성능 트레이싱강함(분산 트레이싱)Firebase Performance 별도
릴리스 헬스세밀(세션·suspect commit)버전별 크래시프리 제공
스토어 콘솔 연동간접Play/Google 생태계와 자연스러움

RN·Flutter 같은 크로스플랫폼이면 얘기가 조금 다르다. JS 스택은 소스맵, Dart는 debug symbols가 추가로 필요한데, 이 심볼 업로드 파이프라인은 Sentry 쪽 문서와 CLI가 더 정리가 잘 돼 있다는 게 개인적 경험이다. 순수 네이티브 앱에 크래시만 보면 되는 스타트업 초기라면 굳이 유료 쓰지 말고 Crashlytics로 시작해도 된다.

대시보드 숫자에 속지 않기 — 그룹핑과 노이즈

크래시 리포팅을 붙이고 나면 새로운 함정이 하나 생긴다. 이슈 그룹핑이다. Sentry는 스택트레이스의 지문(fingerprint)으로 같은 크래시를 하나의 이슈로 묶는데, 이게 가끔 너무 잘게 쪼개지거나(같은 버그가 5개 이슈로) 너무 뭉뚱그려진다(다른 버그가 한 이슈로). 심볼리케이션이 실패하면 그룹핑 품질이 급격히 나빠진다. 주소값으로 묶으니 빌드마다 다른 이슈가 되는 것. 심볼리케이션이 왜 "전부"라고 했는지가 여기서도 드러난다.

노이즈도 관리 대상이다. OS 버전 하나에서만 나는 서드파티 SDK 크래시, 루팅 기기의 이상한 크래시, 이미 알고 있는데 못 고치는 하드웨어 이슈 같은 건 fingerprint 커스터마이징이나 inbound filter로 묶거나 걸러서, 진짜 고칠 수 있는 크래시가 상단에 보이게 한다. 대시보드가 노이즈로 가득하면 아무도 안 본다. 안 보는 대시보드는 없는 것과 같다.

참고 "이 크래시는 지금 안 고침"도 명시적으로 관리하는 게 낫다. Sentry의 이슈를 Ignore/Archive 하되 "이벤트가 100건 더 쌓이면 다시 알림" 조건을 걸어두면, 방치가 아니라 유예가 된다. 무조건 무시하면 나중에 그게 폭증했을 때 못 잡는다.

자주 묻는 질문

크래시프리율 99%면 좋은 건가요?

맥락에 따라 다르다. 세션 기준 99%는 세션 100건당 1건이 튕긴다는 뜻이라, 노출이 큰 앱이면 절대 크래시 수가 많다는 의미다. 노출 많은 소비자 앱은 세션 기준 99.5% 이상을 기본선으로 본다. 반대로 초기 단계 앱이면 99%도 나쁘지 않은 출발점이다. 숫자 자체보다 추세와 릴리스별 변화가 더 중요하다.

심볼리케이션이 안 되는데 왜 그런가요?

가장 흔한 원인은 dSYM/mapping의 식별자가 이벤트와 안 맞는 것이다. iOS는 dSYM UUID가 배포 바이너리와 다른 경우(특히 Bitcode 재컴파일), Android는 versionCode 불일치나 mapping 미업로드가 대부분이다. Sentry의 프로젝트 설정 > Debug Files에서 어떤 UUID가 필요한지(missing) 확인하고, 그 UUID를 가진 파일을 올리면 된다.

ANR과 크래시는 같은 도구로 다 잡히나요?

Sentry·Crashlytics 모두 ANR과 크래시를 함께 잡는다. 단 ANR은 별도 감지 로직이라 SDK 버전과 옵션에 영향을 많이 받는다. Android는 최신 SDK의 ANR v2(ApplicationExitInfo 기반)를 쓰면 멈춘 순간의 스택 정확도가 올라간다. iOS는 App Hang 감지를 명시적으로 켜야 잡힌다.

breadcrumb에 로그를 다 남기면 성능에 영향이 있나요?

breadcrumb은 메모리에 최근 N개(기본 100개)만 링버퍼로 들고 있다가 크래시 시점에만 전송하므로, 평상시 오버헤드는 무시할 수준이다. 다만 매 프레임마다 breadcrumb을 남기는 식으로 남발하면 정작 중요한 이벤트가 버퍼에서 밀려난다. 도메인 이벤트 위주로 의미 있는 것만 남기는 게 좋다.

릴리스 헬스를 켰는데 채택률(adoption)이 안 뜹니다.

세션 데이터가 도착해야 채택률이 계산된다. enableAutoSessionTracking이 켜져 있는지, 그리고 이벤트의 release 이름이 CI에서 등록한 릴리스 이름과 정확히 같은지 확인하라. 대소문자·빌드번호 포맷이 하나만 달라도 별개 릴리스로 잡혀서 채택률이 0으로 보인다. 이게 릴리스 헬스가 안 보이는 사유의 대부분이다.

Sentry 이벤트 쿼터를 아끼려면 어떻게 하나요?

트랜잭션(성능) 샘플링 레이트를 낮추는 게 제일 효과가 크다. tracesSampleRate를 0.1~0.2 수준으로 두고, 크래시 같은 에러 이벤트는 100% 받되 노이즈성 이슈는 inbound filter로 서버 도착 전에 거른다. 개발/QA 빌드는 별도 프로젝트나 environment로 분리해서 프로덕션 쿼터를 안 먹게 하는 것도 기본이다.

크래시가 하나도 안 올라오는데 정상인가요?

진짜 크래시가 없을 수도 있지만, 대개는 DSN 설정 오류나 릴리스 빌드에서 SDK 초기화가 조건부로 빠진 경우다. 일부러 테스트 크래시(SentrySDK.crash() 같은)를 한 번 발생시켜 대시보드에 뜨는지 확인하라. 심볼리케이션까지 정상인지 이 테스트로 함께 검증하는 걸 릴리스 체크리스트에 넣어두면 좋다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록