↓ 본문으로 건너뛰기

React Native Expo Sentry

SDK는 Sentry, 서버는 우리 것: Django 약 650줄로 만든 React Native 앱 크래시 리포팅

우리 앱에는 크래시를 알려 주는 장치가 하나도 없었습니다. sentry.io에 가입하는 대신 Sentry의 오픈소스 SDK는 그대로 두고, DSN만 우리 Django API의 엔드포인트로 돌렸습니다. envelope 형식, 소스 맵 없이 minify된 크래시를 묶는 방법, Discord를 도배하지 않는 알림, 개인정보 제거, iOS 네이티브 SIGABRT가 실제로 도착하는지 확인한 과정, 그리고 결국 개인정보 처리방침을 통째로 다시 쓰게 된 이야기입니다.

이번 주 전까지 우리 앱이 크래시 났다는 걸 아는 방법은 누군가 알려 주는 것뿐이었습니다. 그런데 대부분은 알려 주지 않습니다. 앱을 다시 열고, 또 꺼지면 그냥 안 쓰게 되죠. API 쪽은 대비가 돼 있었습니다(ERROR 로그는 전부 Discord 채널로 갑니다). 하지만 휴대폰에서 일어난 크래시는 제가 볼 수 있는 어디에도 흔적을 남기지 않았습니다.

사실 앱에 리포터가 하나 있긴 했습니다. 앱이 사용자에게 오류 메시지를 보여 주면 그 메시지를 /client-errors/로 보내고, 서버가 이를 ERROR로 기록하니 Discord에도 올라옵니다. 삭제가 실제로는 성공했는데 목록에 행이 그대로 남아 있었고, 그걸 다시 탭하자 “No Item matches the given query"가 뜬 일이 있었습니다. 그때 만든 장치입니다. 서버는 평범한 404 두 개만 기록하고 아무 말도 하지 않았거든요. 이건 앱이 잡아서 보여 주는 오류만 다룹니다. 크래시는 다릅니다. 리포트를 보내야 할 JavaScript 자체가 방금 죽어 버린 거니까요.

뻔한 해법은 Sentry입니다. 저는 Sentry SDK를 꽤 좋아합니다. React Native에서 크래시 리포팅이 이미 해결된 문제처럼 느껴지는 건 이 SDK 덕분이죠. 다만 사용자 데이터를 쥐고 있는 업체가 하나 더 늘고, 개인정보 처리방침에 한 줄이 더 붙고, 우리 규모로는 하루에 많아야 몇 건인 일에 청구서가 또 하나 생기는 건 원하지 않았습니다. 그래서 SDK는 남기고 서비스만 바꿨습니다. 앱의 DSN은 우리 API를 가리키고, Django 코드 약 650줄이 SDK가 보내는 걸 받아서 묶고, 알리고, 보관합니다.

이 글은 그 구조와, 실제 네이티브 크래시가 정말 끝까지 도착하는지 확인한 과정에 대한 이야기입니다.

DSN 트릭
#

Sentry DSN은 https://<public-key>@<host>/<project-id> 형태입니다. SDK는 이를 https://<host>/api/<project-id>/envelope/로 가는 POST로 바꾸고, 키는 X-Sentry-Auth 헤더에 넣습니다. sentry.io에만 해당하는 부분은 하나도 없습니다. 그래서 앱에 박아 둔 DSN은 이렇습니다.

https://<CRASH_REPORT_KEY>@api.example.com/1

그리고 Django에는 SDK가 호출할 주소와 똑같은 라우트를 둡니다.

path("api/<int:project_id>/envelope/", crash_envelope, name="crash_envelope"),

키는 모든 바이너리에 들어 있으니 비밀이 아닙니다. 엔드포인트에 쓰레기가 몰리기 시작하면 바꿀 수 있게, 그리고 서버에 키가 설정되지 않았을 때 아무거나 다 받는 대신 전부 거절하게 하려고 있는 겁니다.

def crash_envelope(request, project_id):
    expected = settings.CRASH_REPORT_KEY
    if not expected:
        return Response({"detail": "Crash reporting is not configured"}, status=503)
    if not hmac.compare_digest(_key(request).encode(), expected.encode()):
        return Response({"detail": "Unknown key"}, status=401)
    ...

_key는 X-Sentry-Auth 헤더에서 sentry_key=를 읽고, 없으면 쿼리 스트링을 봅니다. 일부 SDK는 쿼리 스트링을 쓰기 때문입니다.

SDK가 보내는 것: envelope
#

Sentry의 전송 형식은 envelope입니다. JSON 헤더 한 줄 뒤에 아이템이 여러 개 이어지고, 각 아이템은 JSON 헤더 한 줄과 페이로드로 이루어집니다. 아이템 헤더에 length가 있으면 페이로드는 정확히 그 바이트 수만큼이고, 없으면 다음 줄바꿈까지입니다.

{"event_id":"9ec79c33...","dsn":"https://[email protected]/1"}
{"type":"event","length":4123}
{ ...the event JSON, 4123 bytes... }
{"type":"client_report"}
{"timestamp":1791400000.5,"discarded_events":[]}

여기서 두 가지에 걸려 넘어졌습니다.

  • 네이티브 SDK는 본문을 gzip으로 압축하고, JavaScript SDK는 하지 않습니다. @sentry/react-native가 잡은 JS 오류는 평문으로 오지만, sentry-cocoa나 sentry-android가 보낸 네이티브 크래시는 Content-Encoding: gzip으로 옵니다. 디코더는 gzip, zlib로 감싼 deflate, raw deflate를 모두 처리하고, 압축 해제는 1MB에서 멈춥니다. 크래시 이벤트는 몇 KB 수준이라 그보다 크면 크래시 리포트가 아니니까요.
  • 길이가 붙은 페이로드에는 줄바꿈이 들어 있을 수 있습니다. \n으로 쪼개면 네이티브 SDK가 보내는 이벤트를 전부 망가뜨립니다. length가 있으면 반드시 그걸 따라야 합니다.

파서는 40줄 정도입니다. event 아이템만 남기고, 세션·트랜잭션·첨부 파일·클라이언트 리포트는 받기만 하고 버립니다.

SDK가 알아듣는 방식으로 응답하기
#

생각보다 중요한 부분이었습니다. SDK는 디스크에 큐를 두고, 전달됐는지 확신이 없는 건 다시 보냅니다. 그러니 서버의 응답이 SDK가 계속 재시도할지를 결정합니다.

  • 200: 저장했을 때, 또는 일부러 버렸을 때. 세션 아이템에 4xx로 답하면 SDK는 그걸 붙들고 영원히 재시도합니다.
  • 429와 Retry-After: 스로틀에 걸렸을 때. 엔드포인트에는 IP당 스로틀(시간당 120건)이 있습니다. 크래시 루프에 빠진 폰은 실행할 때마다 리포트를 하나씩 보내기 때문이고, SDK는 429를 받으면 기대한 대로 물러납니다.
  • 400: envelope가 깨졌을 때. 이건 에러가 아니라 경고로 기록합니다. 깨진 리포트는 클라이언트가 고장 났다는 뜻이고, 다음에 오는 정상 리포트가 무엇이 크래시 났는지 알려 줄 테니까요.

SDK는 첫 전송이 도착했는지 확신이 없으면 같은 이벤트를 다시 보내기도 합니다. 그래서 event_id에 unique 제약을 걸었고, 중복은 조용히 무시됩니다.

소스 맵 없이 묶기
#

직접 만든 수신기라면 Sentry가 대신 해 주던 선택을 직접 해야 하는 지점입니다. Sentry는 스택 트레이스로 크래시를 묶습니다. 하지만 릴리스용 Hermes 번들은 minify돼 있어서 프레임 이름은 한 글자이고, 줄·열 위치는 빌드할 때마다 바뀝니다. 프레임으로 묶으면 같은 버그가 릴리스마다 새 이슈로 열리죠.

소스 맵이 없는 상태에서(이건 마지막에 다시 얘기합니다) JavaScript 오류에서 가장 안정적인 건 타입과 메시지입니다. 그래서 핑거프린트는 예외 타입과 메시지로 만들고, 발생할 때마다 달라지는 부분은 가립니다.

_MASKS = [
    (re.compile(r"0x[0-9a-fA-F]+"), "<addr>"),
    (re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<uuid>"),
    (re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
    (re.compile(r"\d+"), "<n>"),
]

Cannot read property 'title' of undefined (reading index 3)와 (reading index 17)은 같은 이슈가 됩니다. SDK가 {{ default }}가 들어 있지 않은 자체 fingerprint를 보내면 그게 우선이라, 기본 묶음이 틀렸을 때는 클라이언트에서 강제로 묶을 수 있습니다. 예외가 없는 이벤트는 가린 로그 메시지로, 둘 다 없으면 앱 코드의 맨 위 프레임으로 묶습니다. 네이티브 시그널 크래시는 메시지가 Signal 6, Code 0 같은 식이라 시그널별로 묶입니다. 거칠긴 하지만, 심볼리케이션이 생기기 전까지는 정직한 방식입니다.

채널을 도배하지 않는 알림
#

핑거프린트 하나가 CrashIssue 행 하나이고, 리포트 하나하나는 그 아래 CrashEvent가 됩니다. 이슈는 정확히 다음 경우에만 ERROR로 기록합니다(Discord 핸들러가 듣는 게 이 레벨입니다).

  • 새 이슈
  • 해결됨으로 표시한 이슈가 다시 나타났을 때. 이때 이슈가 다시 열립니다.
  • 10번째, 100번째, 1,000번째, 10,000번째 발생

평범한 반복은 카운터만 올립니다. 무시로 표시한 이슈는 계속 세기만 하고 알림은 보내지 않습니다. 여기에 Discord 핸들러 자체의 속도 제한(같은 메시지 반복은 10분에 한 번, 시간당 알림 30개)까지 겹치니, 잘못된 릴리스로 크래시 루프가 돌아도 메시지 벽이 아니라 몇 줄로 끝납니다.

저장 공간도 같은 식으로 제한합니다. 이슈마다 최근 50개 이벤트만 남기고, 새 이벤트가 들어오면 나머지는 지웁니다. 그런데 이것 때문에 관리자 차트 첫 버전이 망가졌습니다. 이벤트 50개로는 지난주에 얼마나 자주 일어났는지 알 수 없으니까요. 그래서 이슈 행에 {"2026-10-09": 3, ...} 같은 작은 daily_counts 딕셔너리를 따로 두고, 90일로 잘라서, 카운터를 올리는 것과 같은 행 잠금 안에서 갱신합니다. 차트는 이걸로 그리고, 이벤트는 읽는 용도로만 씁니다.

작은 경쟁 상태 두 개도 처리해야 했습니다. 완전히 새로운 크래시가 여러 폰에서 동시에 나면, 두 리포트가 동시에 이슈를 만들려고 할 수 있습니다. 진 쪽은 unique 핑거프린트 때문에 IntegrityError를 받고, select_for_update로 그 행을 다시 읽습니다. 90일 범위는 딕셔너리의 가장 최근 키가 아니라 서버 기준 오늘부터 잽니다. 시계가 잘못 맞춰진 폰은 리포트 날짜를 아무 날로나, 심지어 내년으로도 찍을 수 있거든요.

저장되는 페이로드는 128KB로 제한합니다. 이벤트가 이보다 크면 breadcrumb과 debug_meta부터 버리고, 그래도 크면 예외, 메시지, 릴리스, 플랫폼만 남긴 뒤 _truncated 플래그를 붙여서 잘렸다는 걸 알 수 있게 합니다.

개인정보 걷어 내기
#

SDK는 sendDefaultPii: false로 돌기 때문에, 원칙적으로는 IP 주소, 쿠키, 요청 본문이 오지 않습니다. 서버는 거기에 기대지 않습니다. 저장하기 전에 user에서는 id와 username만 남기고, request는 통째로 버립니다. 나중에 SDK 버전이나 설정이 바뀌어서 이런 걸 보내기 시작할 수도 있으니까요.

실제 기기로 테스트해 보니 걷어 내야 할 게 두 가지 더 나왔습니다.

  • breadcrumb은 쿼리 스트링까지 포함한 전체 URL을 기록합니다. 앱의 검색 호출은 /api/v1/items/?search=... 형태라서, 검색 직후에 크래시가 나면 그 사람이 뭘 검색했는지가 저장됐을 겁니다. 이제 서버가 모든 breadcrumb URL을 ?에서 자릅니다.
  • 콘솔 breadcrumb에는 console.log에 넘긴 인자가 그대로 들어 있습니다. 앱이 마침 로그로 찍은 객체라면 무엇이든 될 수 있죠. 메시지 텍스트는 쓸모가 있으니 남기고, 인자는 버립니다.

이 둘은 실제 리포트를 들여다보고 나서야 발견했습니다. 뒤에 나올 섹션이 필요한 가장 큰 이유가 이겁니다.

클라이언트 쪽
#

앱에서는 모든 게 services/crashReporting.ts 파일 하나를 거칩니다. OTA SDK를 파일 하나로만 접근하게 한 것과 같은 이유입니다. OTA 번들은 네이티브 모듈이 생기기 전에 빌드된 바이너리에서도 돌아가거든요. RNSentry가 없는 바이너리에서 최상단에 import * as Sentry from "@sentry/react-native"를 쓰면 import 시점에 예외가 나고, 그러면 빈 화면입니다. 그래서 SDK는 지연 로딩하고, 그것도 네이티브 모듈이 실제로 있는지 확인한 다음에만 불러옵니다.

function nativeModulePresent(): boolean {
  try {
    return Boolean(TurboModuleRegistry.get("RNSentry") ?? NativeModules.RNSentry);
  } catch {
    return false;
  }
}

export function initCrashReporting(): boolean {
  if (!DSN || Platform.OS === "web" || !nativeModulePresent()) return false;
  const Sentry = require("@sentry/react-native");
  Sentry.init({
    dsn: DSN,
    release: Constants.expoConfig?.version ?? "unknown",
    sendDefaultPii: false,
    tracesSampleRate: 0,
    enableAutoSessionTracking: false,
    enableAutoPerformanceTracing: false,
    enableAppStartTracking: false,
    enableNativeFramesTracking: false,
    enableStallTracking: false,
    enableUserInteractionTracing: false,
    maxBreadcrumbs: 50,
    beforeSend(event) { /* tag the OTA bundle */ return event; },
  });
  return true;
}

오류만 보냅니다. 서버가 트레이스, 세션, 프로파일, 리플레이를 저장하지 않으니 그중 아무것도 켜지 않았습니다. DSN이 없으면(개발 빌드, E2E 빌드) SDK는 아예 시작하지 않습니다. EAS 빌드 프로필에서 DSN과 environment(staging 또는 production)를 지정하기 때문에, 두 종류의 빌드가 같은 엔드포인트로 보내면서도 관리자 화면에서는 구분됩니다. 로그인한 사용자는 Firebase UID로만 지정하고, 이름이나 이메일은 절대 넣지 않습니다.

에러 바운더리에는 명시적인 호출이 필요했습니다. Expo Router의 루트 ErrorBoundary는 렌더링 오류를 잡아서 자기 화면을 보여 주기 때문에, 그 오류는 SDK가 걸어 둔 전역 핸들러까지 가지 않습니다. 그래서 레이아웃에서 작은 래퍼를 export하도록 바꿨습니다. useEffect 안에서 captureException을 호출한 다음, 예전처럼 라우터의 바운더리를 렌더링합니다. 라우트별 바운더리도 똑같이 합니다. 렌더링 중이 아니라 effect 안에서 부르는 건, 다시 렌더링될 때마다가 아니라 한 번만 보고하게 하려는 겁니다. 루트 컴포넌트는 Sentry.wrap으로도 감싸는데, 이게 예외를 던지면 감싸지 않은 컴포넌트를 그대로 씁니다.

웹용으로는 옆에 모든 함수를 꺼 둔 crashReporting.web.ts가 있습니다. Metro가 플랫폼별 파일을 고르기 때문에 SDK는 웹 번들에 아예 들어가지 않습니다. 웹 앱에는 돌릴 네이티브 모듈이 없으니까요.

예전 /client-errors/ 리포터는 그대로 둡니다. 2.5.9보다 오래된 바이너리에서는 여전히 유일한 리포터이고, 크래시가 아닌 오류도 이쪽이 다룹니다.

sentry-cli도, 빌드 단계도, expo prebuild도 없습니다. 이 프로젝트의 ios/와 android/ 디렉터리는 손으로 고쳐서 커밋하는 방식이고, 네이티브 모듈은 autolinking이 알아서 잡았습니다. 2.5.9에 들어갔습니다.

네이티브 크래시가 정말 도착하는지 확인하기
#

단위 테스트는 손으로 쓴 envelope를 보냅니다. 파서가 SDK가 보낼 거라고 제가 생각하는 걸 처리한다는 것까지만 증명하죠. 출시 이틀 뒤, 프로덕션에 들어온 크래시 리포트는 0건이었습니다. 2.5.9를 쓰는 사람이 아직 세 명뿐이라 0건이어도 이상하지는 않았지만, 서버 입장에서는 “아무도 크래시가 안 났다"와 “크래시가 도착하지 않는다"가 똑같아 보입니다. 진짜 크래시를 하나 보고 싶었습니다.

그래서 DSN을 8002번 포트의 로컬 Django와 임시 키로 바꾼 Release 시뮬레이터 바이너리를 빌드했습니다.

EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8002/1 \
EXPO_PUBLIC_CRASH_ENVIRONMENT=local-verify \
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release \
  -destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath /tmp/iosbuild

(Debug가 아니라 Release입니다. Debug 빌드는 dev launcher가 링크돼서 동작이 다르고, 사람들이 실제로 쓰는 건 Release니까요.) 그다음 설치하고, 실행하고, 몇 초 기다렸다가, 실제 결함이 하듯이 죽였습니다.

xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
xcrun simctl launch booted com.example.myapp      # prints the pid
kill -ABRT <pid>

시뮬레이터 앱은 평범한 macOS 프로세스라서, 시그널은 기기에서와 똑같이 SentryCrash의 핸들러에 도달합니다. 핸들러는 리포트를 디스크에 쓰기만 하고, 프로세스가 죽어 가는 동안에는 아무것도 보내지 않습니다. 다음 실행 때 SDK가 그걸 찾아서 보냅니다. 다시 실행하자 1초 뒤 시뮬레이터 네트워크 로그에 :8002로의 연결이 찍혔습니다.

그런데 Django 로그에는 아무것도 없어서 잠깐 헷갈렸습니다. 제가 설정해 둔 레벨에서는 개발 서버가 2xx가 아닌 응답만 기록하더군요. 데이터베이스에는 들어와 있었습니다.

SIGABRT: Signal 6, Code 0 | cocoa 2.5.9 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbs

크래시부터 저장까지 15초였고, 대부분은 제가 앱을 다시 켜는 데 걸린 시간입니다. 그리고 실제 페이로드를 들여다보니 테스트로는 알 수 없던 것들이 보였습니다.

  • 사용자 ID가 Firebase UID가 아니었습니다. 테스트 빌드는 실제로 로그인한 상태가 아니라서, sentry-cocoa가 자체 설치별 ID로 대신 채웠습니다. 알아 둘 만한 점이고, 개인정보 안내에도 적어야 할 점입니다. 로그인했다면 리포트에는 계정 ID가, 아니라면 무작위 설치 ID가 담깁니다.
  • 시간대가 없습니다. culture 컨텍스트가 있을 줄 알았는데 없었습니다. device 컨텍스트에는 locale, 메모리, 화면 크기, 저전력 모드, 커널 버전은 있지만 시간대는 없습니다.
  • 네이티브 크래시는 beforeSend를 거치지 않습니다. JS 쪽 beforeSend가 실행 중인 OTA 번들로 ota 태그를 붙이는데, 네이티브 크래시는 다음 실행 때 네이티브 SDK가 조립하기 때문에 JS 훅을 전혀 지나지 않습니다. 네이티브 크래시에 태그를 붙이려면, 번들을 알게 된 시점에 setTag로 스코프에 태그를 걸어서 네이티브 레이어까지 동기화되게 해야 합니다.
  • breadcrumb을 보면 앱이 뭘 하는지 보입니다. 콘솔 경고, 뷰 컨트롤러 생명주기, HTTP 호출이 있었는데, 그중에는 Firebase Analytics가 app-analytics-services.com으로 보내는 요청도 있었습니다. 앱이 그 밖에 어디와 통신하는지 다시 상기하게 되더군요. 그리고 URL에 쿼리 스트링이 붙어 있었고, 앞에서 말한 검색어 문제를 찾은 게 바로 여기서였습니다.

리포트 읽기
#

Django 관리자에는 Sentry를 본뜬 이슈 페이지가 생겼습니다. 상단에는 횟수, 보관 중인 이벤트 기준 고유 사용자 수, Resolve / Ignore / Reopen 버튼이 있고, daily_counts로 그린 30일치 일별 막대, 보관된 이벤트를 릴리스·OS·기기·OTA 번들·environment·치명적 여부(fatal/handled)별로 나눈 비율(각각 상위 4개 값, 나머지는 “other"로 묶음), 그리고 이벤트를 하나씩 보여 주는 영역이 있습니다. 이벤트마다 예외, 프레임(앱 프레임은 강조, 라이브러리 프레임은 접힘), breadcrumb 타임라인, 기기 컨텍스트가 나오고 맨 아래에 원본 JSON이 붙습니다. 이슈 목록에는 행마다 14일짜리 스파크라인이 있습니다. 템플릿 하나와 저장된 페이로드를 읽는 모듈 하나로 끝났고, 새 의존성은 필요 없었습니다.

만들다가 고친 게 하나 있습니다. 네이티브 크래시에서 저장된 “culprit” 프레임이 ? (/private/var/containers/Bundle/Application/<uuid>/MyApp.app/MyApp)로 나왔습니다. 기기 안의 바이너리 경로인데, 여기에 설치마다 다른 UUID가 들어 있습니다. 이제는 0x1000a1b2c (MyApp)로 나옵니다. 리포트 하나의 관리자 URL은 해당 이벤트가 선택된 상태로 이슈 페이지로 리다이렉트되기 때문에, 크래시를 읽는 곳이 두 군데가 아니라 한 군데입니다.

개인정보 처리방침, 전부 다
#

크래시 리포트는 사실상 개인정보입니다. 계정 ID, 휴대폰 모델, 그리고 앱이 죽기 직전 1분 동안 그 사람이 한 일이 들어 있으니까요. 그래서 개인정보 처리방침에 한 단락이 필요했습니다. 제대로 쓰려면 리포트에 정확히 뭐가 담기는지 말해야 했고, 방금 들여다본 페이로드를 보면서 목록을 만들었습니다. 오류, 앱 버전과 OTA 업데이트, 휴대폰 모델, OS와 언어, 남은 메모리와 화면 크기, 직전 수십 단계(열어 본 화면, ? 뒤를 지운 웹 주소, 앱 자체의 로그 메시지), 그리고 계정 ID 또는 무작위 설치 ID입니다. 리포트는 우리 서버로 오고 Sentry나 다른 크래시 리포팅 회사로는 가지 않는다는 것도 처리방침에 적었습니다. 보관 기간은 표에 따로 한 줄을 뒀습니다. 문제별로 최근 50건의 리포트, 그리고 90일치 일별 횟수입니다.

한 단락을 코드와 대조하기 시작하니 나머지를 안 볼 수가 없었습니다. 기존 처리방침은 버티지 못했습니다. 계정 삭제 페이지에는 계정을 닫으면 푸시 알림 토큰과 패스키가 즉시 삭제되고, 서버 로그는 90일 안에 사라진다고 적혀 있었습니다. 셋 다 사실이 아니었습니다. 계정을 닫으면 로그인 수단의 연결이 끊기고 프로필이 익명화되지만, 토큰 행과 패스키 기록은 누군가 손으로 지울 때까지 남아 있고, 로그 보관소에는 아직 만료 기간이 없습니다. 그래서 처리방침 전체를 코드가 실제로 하는 일의 목록을 바탕으로 다시 썼고, 그 줄들은 이제 “아직 자동으로 삭제되지 않음, 이메일로 요청해 주세요"라고 적혀 있습니다. 덜 안심되지만, 정확합니다.

이제 저장소에 이에 대한 규칙이 있습니다. 처리방침은 우리가 하려는 일이 아니라 코드가 하는 일을 설명하고, 개인정보를 새로운 곳에서 수집하거나 보내거나 보관하는 변경은 같은 커밋에서 처리방침도 고친다는 규칙입니다. 앱 안의 요약 화면, 계정 삭제 페이지, 그리고 스토어 콘솔에만 있는 App Store와 Play의 개인정보 양식까지 포함해서요. 이걸 글로 적어 두게 만든 변경이 크래시 리포팅이었습니다.

아직 없는 것
#

심볼리케이션. 이게 진짜 빈자리이고, sentry.io가 해 주는 일 중 제가 대체하지 못한 단 하나입니다. JavaScript 스택은 minify돼 있고, 네이티브 스택은 심볼리케이션이 안 돼 있습니다.

  • JavaScript: 프레임이 Hermes 번들 안을 가리킵니다. 고치려면 모든 스토어 빌드와 모든 OTA 번들의 합성된 Hermes 소스 맵을 릴리스와 번들별로 보관해 두고, 페이지를 렌더링할 때 JS 프레임에 적용해야 합니다. 우리 크래시는 대부분 JavaScript일 테니 이게 다음 작업입니다.
  • iOS 네이티브: 명령어 주소를 풀려면 해당 빌드의 dSYM이 필요합니다.
  • Android: minify를 켜면 Java 프레임은 R8로 난독화되고(mapping.txt가 필요합니다), C++ 프레임은 NDK 심볼이 필요합니다.

위에서 발견한 더 작은 빈자리도 있습니다. 네이티브 크래시에는 아직 OTA 태그가 붙지 않습니다. beforeSend에서만 붙이고 있기 때문이죠. 실행 중인 번들이 확정되면 스코프에 태그를 거는 건 몇 줄이면 되는데, 아직 쓰지 않았습니다.

지금으로서는 이슈를 보면 무엇이 크래시 났는지, 어떤 릴리스와 OTA 번들과 기기에서였는지, 그 사람이 직전에 뭘 하고 있었는지는 알 수 있습니다. 소스의 어디에서인지는 알 수 없습니다. 일주일에 몇 번 배포하는 작은 앱에서는 지금까지 타입, 메시지, breadcrumb만으로도 원인을 찾기에 충분했습니다.

수신기는 약 650줄(파서, 묶기, 알림, 모델, 뷰), 관리자 페이지는 700줄 남짓, 테스트는 약 400줄이고, 이미 돌아가던 API 위에 추가로 드는 비용은 없습니다. 어려운 일은 SDK가 합니다. 휴대폰에서 크래시를 잡고, 다음 실행 때 그 리포트를 밖으로 내보내는 일이죠.