<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Sentry · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/sentry/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/sentry/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Fly.io 위 Django 모니터링: 남긴 DevOps 도구와 버린 도구</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>Fly.io 위의 작은 Django 앱 두 개를 한 달 2달러 정도로 모니터링하는 법. 에러는 채팅으로, 로그는 S3에 두고 DuckDB로 조회, Fly 메트릭, 크래시 리포트, 그리고 버린 것들.</description>
      <content:encoded><![CDATA[<p>제가 만드는 마켓플레이스 겸 커뮤니티 앱과 현장 기술자용 견적 앱, 이렇게 작은 프로덕트 두 개를 둘 다 Fly.io의 가장 싼 머신에서 돌리고 있습니다. 제 DevOps 모니터링은 결국 다섯 가지로 정리됩니다. ERROR 로그를 올리는 채팅 채널, 오브젝트 스토리지에 쌓아 두고 DuckDB로 조회하는 전체 로그, Fly에 기본으로 딸린 Prometheus 메트릭, Sentry의 오픈소스 SDK로 받아 제 API로 보내는 앱 크래시 리포트, 그리고 매일 아침 올라오는 요약 글 하나입니다.</p>
<p>호스팅 APM도, 프로덕션 트레이싱도, 일부러 기억해서 열어야 하는 대시보드도 없습니다. 따로 돈이 드는 건 로그 아카이브 하나로, 한 달에 2달러 정도입니다. 아래는 전체 목록과, 버렸거나 더는 믿지 않게 된 것들입니다. 그중 하나 때문에 저는 사용자가 한 명도 겪지 않은 장애를 며칠이나 쫓아다녔습니다.</p>

<h2 class="relative group">여기서 말하는 모니터링, 그리고 이게 얼마나 작은 규모인지
    <div id="여기서-말하는-모니터링-그리고-이게-얼마나-작은-규모인지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%97%ac%ea%b8%b0%ec%84%9c-%eb%a7%90%ed%95%98%eb%8a%94-%eb%aa%a8%eb%8b%88%ed%84%b0%eb%a7%81-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ec%9d%b4%ea%b2%8c-%ec%96%bc%eb%a7%88%eb%82%98-%ec%9e%91%ec%9d%80-%ea%b7%9c%eb%aa%a8%ec%9d%b8%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제가 말하는 DevOps 모니터링은 두 가지입니다. 누가 알려주기 전에 뭔가 고장났다는 걸 먼저 아는 것, 그리고 짐작하지 않고 원인을 찾아낼 수 있는 것. 아래 도구는 전부 이 두 기준으로 평가했습니다.</p>
<p>작년에 <a href="https://jared.lynskey.co.nz/ko/posts/2025/2025-10-13-devops-monitoring/" >개발자가 진짜로 쓰는 모니터링 만들기</a>라는 글을 썼는데, 그때는 주로 팀을 염두에 뒀습니다. 작은 팀이라면 클라우드 업체가 기본으로 주는 모니터링, 서비스가 수백 개라면 호스팅 APM, 이런 식이었죠. 이번 글은 같은 생각의 가장 작은 쪽 끝입니다. 두 프로덕트 모두 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/" >요즘 쓰는 기술 스택</a>에서 설명한 대로 Fly의 shared-cpu-1x 머신 위의 Django이고, 개발자는 둘 다 저 혼자입니다. 모니터링이 하나의 프로덕트이고 개발자가 그 사용자라면, 제 모니터링의 사용자는 딱 한 명인 셈이죠. 9월에 Fly 메트릭으로 기준치를 뽑아 봤더니 마켓플레이스 API의 평균 CPU 사용률이 1%였습니다. 엔터프라이즈 스택이 아니고, 그런 척할 생각도 없습니다.</p>
<p>메트릭, 로그, 트레이스라는 세 기둥 중 제게 있는 건 둘입니다. 두 프로덕트 모두 분산 트레이싱이 없습니다. 각각 Django 코드베이스 하나에 데이터베이스 하나라서, 트레이스가 넘나들 서비스 경계가 없기 때문입니다. 제가 보는 트레이스는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >제 워크스테이션</a>에서 돌리는 리버스 프록시의 트레이스가 전부입니다.</p>

<h2 class="relative group">목록
    <div id="목록" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%aa%a9%eb%a1%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>도구</th>
					<th>무엇을 보는가</th>
					<th>남겼나 버렸나, 그리고 이유</th>
					<th>비용</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Django 로깅 핸들러가 채우는 팀 채팅 채널</td>
					<td>API, WebSocket 서버, 워커의 모든 ERROR, 배포, 잘못된 로그인 코드가 몰리는 경우</td>
					<td>남김. 누가 시키지 않아도 들여다보는 유일한 곳</td>
					<td>무료</td>
			</tr>
			<tr>
					<td>Fly 로그 시퍼 → S3 호환 버킷, DuckDB로 조회</td>
					<td>마켓플레이스 앱들의 모든 로그 줄</td>
					<td>남김. Fly 자체 버퍼는 몇 분, 이건 전부, 그것도 SQL로</td>
					<td>월 약 2달러 + 스토리지</td>
			</tr>
			<tr>
					<td>Fly 기본 메트릭(Prometheus API)</td>
					<td>CPU, 메모리, 버스트 크레딧, 응답 수, 매니지드 Postgres 익스포터</td>
					<td>남김. DB 멈춤이 제 코드 탓이 아니라는 걸 보여줌</td>
					<td>Fly에 포함</td>
			</tr>
			<tr>
					<td><code>/api/health/</code> Fly 헬스 체크 (두 앱 모두)</td>
					<td>머신에 트래픽을 보내도 되는지</td>
					<td>라우팅용으로는 남김. 사용자가 괜찮다는 증거로는 버림</td>
					<td>Fly에 포함</td>
			</tr>
			<tr>
					<td>DSN을 제 API로 돌린 Sentry SDK</td>
					<td>폰에서 나는 앱 크래시</td>
					<td>SDK는 남김. sentry.io는 가입하지 않음</td>
					<td>API 외 추가 비용 없음</td>
			</tr>
			<tr>
					<td>팀 채팅의 아침 요약</td>
					<td>전날 에러, 사용량, 검색, 채팅, 보드</td>
					<td>남김. CI의 cron 작업에서 제 Mac으로 옮김</td>
					<td>무료</td>
			</tr>
			<tr>
					<td>앱 분석 SDK(두 앱 모두), 검색 엔진의 웹마스터 콘솔</td>
					<td>웹·iOS·Android 사용량, 검색 엔진이 사이트를 어떻게 보는지</td>
					<td>남김. API로 읽음</td>
					<td>무료</td>
			</tr>
			<tr>
					<td>Cloudflare GraphQL 분석</td>
					<td>특정 상태 코드나 경로 뒤에 누가 있는지</td>
					<td>남김. 단, 믿기 전에 user agent로 묶어 봄</td>
					<td>무료 플랜, 쿼리당 하루</td>
			</tr>
			<tr>
					<td>호스팅 OTA 업데이트 서비스 대시보드</td>
					<td>폰들이 어떤 OTA 릴리스를 돌리는지, 크래시 롤백</td>
					<td>아무것도 알려주지 못하던 관리자 목록에서 옮겨 옴</td>
					<td>업데이트 서비스에 포함</td>
			</tr>
			<tr>
					<td>작은 프로브 스크립트</td>
					<td>배포나 재시작 중 실패한 요청</td>
					<td>남김. 오리진을 직접 치니 캐시가 공백을 가리지 못함</td>
					<td>무료</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>앱 출력의 최근 몇 분</td>
					<td>실시간 확인용으로는 남김. 기록용으로는 버림</td>
					<td>Fly에 포함</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>아무것도 안 봄. 속도용 기능</td>
					<td>오리진이 <code>Link</code> 헤더를 보내게 한 뒤로는 계속 씀. 이 기능의 캐시 미스가 존 전체 504의 전부였음</td>
					<td>무료</td>
			</tr>
	</tbody>
</table>
<p>표에서 &ldquo;두 앱 모두&quot;라고 적지 않은 건 전부 마켓플레이스 얘기입니다. 견적 앱은 이보다 적습니다. Fly의 헬스 체크, 로그, 메트릭, iOS 앱 분석, 그리고 데이터베이스를 매일 Parquet으로 내보내 DuckDB로 조회하는 것 정도죠. 크래시 리포팅도 알림 채널도 없고, 이 글에서 가장 큰 빈틈이 바로 이겁니다. (업데이트 서비스 행에 대해 밝혀 둘 것: 이 서비스를 만든 회사는 그쪽 블로그에 글을 쓰는 대가로 제게 돈을 지급합니다.)</p>

<h2 class="relative group">에러: 채팅으로 올리는 로깅 핸들러
    <div id="에러-채팅으로-올리는-로깅-핸들러" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%97%90%eb%9f%ac-%ec%b1%84%ed%8c%85%ec%9c%bc%eb%a1%9c-%ec%98%ac%eb%a6%ac%eb%8a%94-%eb%a1%9c%ea%b9%85-%ed%95%b8%eb%93%a4%eb%9f%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>9월 전까지는 처리되지 않은 500, 실패한 푸시 알림, 깨진 WebSocket 프레임이 나타나는 곳이 <code>flyctl logs</code>뿐이었고, 그 스트림을 누가 시키지도 않았는데 읽는 사람은 없습니다. 지금은 로깅 핸들러가 ERROR 이상인 모든 레코드를 API, WebSocket 서버, 백그라운드 워커 어디서 나든 채팅 채널로 보냅니다. 경고(WARNING)는 일부러 알리지 않습니다.</p>
<p>핸들러는 백그라운드 스레드에서 전송하니 요청을 느리게 만들지 않습니다. 핸들러 안에서는 아무것도 로그를 남기지 않으니 재귀에 빠질 일도 없습니다. 그리고 같은 에러는 10분에 한 번까지만, 시간당 최대 30건까지만 보내서, 장애가 나도 메시지 천 개가 아니라 하나로 읽힙니다.</p>
<p>이 채널에는 에러와 나란히 놓고 읽고 싶은 것들도 올라옵니다. 배포는 시작할 때, 올라왔을 때, 실패한 지점에서 커밋과 실행한 사람을 함께 올리고, OTA 업데이트와 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-14-web-releases-without-redeploying/" >웹 릴리스</a>도 마찬가지입니다. 한 계정에 잘못된 로그인 코드가 몰리면 그것도 ERROR로 남기 때문에, 누가 비밀번호를 찍어 보고 있으면 장애와 같은 자리에 나타납니다.</p>
<p>10월에는 어느 로그에도 대응하는 줄이 없는 알림이 하나 왔습니다. 그 뒤로 알림마다 짧은 랜덤 id, 호스트명, 프로세스 시작 명령을 붙이고, 같은 줄을 그 프로세스의 로그에도 씁니다. 그래서 id를 어디서도 못 찾으면, 그 알림은 그곳에 로그를 남기지 않는 프로세스, 예컨대 SSH 셸에서 온 겁니다. 채널에 올라오는 건 전부, 제가 같이 일하는 코딩 에이전트가 읽을 수 있는 피드에도 들어갑니다. 코드를 만지는 에이전트도 제가 보는 시점에 같은 에러를 보는 거죠.</p>

<h2 class="relative group">로그: Fly의 로그 쉬퍼로 S3에 쌓고 DuckDB로 조회
    <div id="로그-fly의-로그-쉬퍼로-s3에-쌓고-duckdb로-조회" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a1%9c%ea%b7%b8-fly%ec%9d%98-%eb%a1%9c%ea%b7%b8-%ec%89%ac%ed%8d%bc%eb%a1%9c-s3%ec%97%90-%ec%8c%93%ea%b3%a0-duckdb%eb%a1%9c-%ec%a1%b0%ed%9a%8c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Fly 자체 버퍼는 몇 분치 출력, 대략 마지막 백 줄 정도를 갖고 있습니다. 배포를 지켜보기엔 충분하지만 어제 일에는 쓸모가 없죠. 그래서 세 번째 Fly 앱에서 Fly의 로그 시퍼를 돌립니다. 조직의 로그 스트림을 구독하는 Vector인데, S3 싱크 하나만 바꿨습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>마켓플레이스 앱들의 모든 로그 줄이 gzip으로 압축된 JSON으로 S3 호환 버킷에 쌓입니다. 시퍼는 256MB짜리 shared-cpu-1x 머신 한 대로, 한 달에 2달러 정도에 스토리지는 푼돈입니다. DuckDB는 매크로 하나로 파일을 제자리에서 읽고, 하루치가 1초 안팎이면 나옵니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p><a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >0으로 스케일 다운 글</a>에서 콜드 스타트를 잰 것도 이 매크로였습니다. 시작 스크립트가 단계마다 알리고, 줄마다 머신 id가 붙어 있으니, 나머지는 <code>GROUP BY</code> 한 번이면 됩니다.</p>
<p>정리하면 하나의 스트림을 읽는 사람에 따라 세 단계로 나눈 셈입니다. 아카이브는 INFO 이상을 전부 보관하고(프로덕션 앱이 INFO로 로그를 남기는 이유이기도 합니다), 실시간 확인 스크립트는 경고와 에러만 보여주고, 채팅 채널은 ERROR를 받습니다. 다만 셋 다 호출한 쪽은 모릅니다. 액세스 로그에 클라이언트 IP도 user agent도 없어서, 그건 Cloudflare에서 봅니다.</p>

<h2 class="relative group">메트릭: Fly에 기본으로 딸린 Prometheus
    <div id="메트릭-fly에-기본으로-딸린-prometheus" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a9%94%ed%8a%b8%eb%a6%ad-fly%ec%97%90-%ea%b8%b0%eb%b3%b8%ec%9c%bc%eb%a1%9c-%eb%94%b8%eb%a6%b0-prometheus" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Fly는 조직마다 매니지드 Prometheus를 돌려주기 때문에 저는 메트릭 스택을 따로 운영하지 않습니다. 궁금한 게 생기면 API를 칩니다. 머신별 메모리, CPU, 버스트 크레딧(공유 CPU에서는 <code>fly_instance_cpu_balance</code>가 중요합니다), 앱별 응답 수와 동시 요청 수 같은 것들이죠. 처음부터 알았으면 좋았을 게 두 가지 있습니다. <code>fly tokens create</code>로 만든 토큰은 <code>Authorization: FlyV1 &lt;token&gt;</code>으로 보내야 합니다. <code>Bearer</code>는 <code>flyctl auth token</code>의 개인 토큰에만 통하고, 읽기 전용 토큰은 &ldquo;something went wrong resolving organization&quot;을 돌려줍니다. 그리고 보관 기간이 2주 정도입니다(Fly 설명으로는 15일쯤).</p>
<p>이게 제 몫을 한 건 9월이었습니다. 로그에 &ldquo;Could not open a database connection&quot;이 하루에도 몇 번씩 찍혔고, 딱 앱 버그처럼 보였습니다. 매니지드 Postgres 클러스터도 같은 Prometheus에 들어 있었는데, 그 에러가 난 매 분마다 프라이머리의 스크레이프 시간이 80밀리초 남짓에서 1~10초로 뛰었고, 레플리카는 멀쩡했고, Postgres 시작 시각은 그대로였습니다. Postgres가 재시작된 게 아니라 그 아래 호스트가 멈췄던 겁니다. 타임스탬프를 Fly 지원팀에 보냈고, 10월 초 점검 재시작으로 프라이머리가 다른 호스트로 옮겨진 뒤로 매일 나던 멈춤은 사라졌습니다.</p>

<h2 class="relative group">크래시 리포트: 제 API를 바라보는 Sentry SDK
    <div id="크래시-리포트-제-api를-바라보는-sentry-sdk" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%eb%9e%98%ec%8b%9c-%eb%a6%ac%ed%8f%ac%ed%8a%b8-%ec%a0%9c-api%eb%a5%bc-%eb%b0%94%eb%9d%bc%eb%b3%b4%eb%8a%94-sentry-sdk" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>예전에는 폰에서 앱이 죽어도 제가 볼 수 있는 곳 어디에도 흔적이 남지 않았습니다. 뻔한 답은 Sentry였고, 저는 그 절반만 가져왔습니다. 앱에는 Sentry의 오픈소스 SDK가 들어 있지만 DSN은 제 Django API의 엔드포인트를 가리킵니다. 그 엔드포인트가 Sentry의 envelope 형식을 받아서 크래시를 묶고, 같은 채팅 핸들러로 알립니다. 트레이싱, 세션, 리플레이는 꺼 두었고 에러만 받습니다.</p>
<p>sentry.io에 가입하지 않은 건, 하루 몇 건 수준의 리포트를 위해 사용자 데이터를 쥔 업체 하나, 개인정보 처리방침 한 줄, 청구서 하나를 더 늘리고 싶지 않았기 때문입니다. 그 대가는 심볼리케이션이고, 이건 아직 대체하지 못했습니다. 스택은 minify된 JavaScript거나 날것의 네이티브 주소라서, 이슈를 보면 무엇이 어느 릴리스와 기기에서 죽었는지, 직전에 사용자가 뭘 했는지는 알지만 소스의 어디인지는 모릅니다.</p>

<h2 class="relative group">대시보드는 메시지 하나
    <div id="대시보드는-메시지-하나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8c%80%ec%8b%9c%eb%b3%b4%eb%93%9c%eb%8a%94-%eb%a9%94%ec%8b%9c%ec%a7%80-%ed%95%98%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>두 앱 모두 대시보드를 따로 만들지 않았습니다. 작년 글에 대시보드를 가리는 기준을 하나 적었는데, 아무도 알아서 열지 않는 대시보드라면 존재 가치를 증명하지 못한 거라는 겁니다. 알아서 도착하는 메시지는 열 필요가 없으니, 제게 가장 대시보드에 가까운 건 매일 아침 팀 채팅에 올라오는 글 하나입니다.</p>
<p>맨 앞은 지난 24시간의 프로덕션 에러입니다. 채팅 채널이 아니라 로그 아카이브에서 읽는데, 알림은 횟수가 제한돼 있어서 무언가가 몇 번 일어났는지 말해 주지 못하기 때문입니다. 숫자, id, 주소를 가린 뒤 묶기 때문에 같은 실패 백 건은 횟수가 붙은 한 줄이 되고, 이메일 주소가 채널에 흘러들 일도 없습니다.</p>
<p>그다음이 실패한 헬스 체크, 전날 사용량을 일주일 전 같은 요일과 비교한 것(일요일은 월요일과 절대 같지 않으니까요), 최근 일주일 검색 유입, 답장을 기다리는 채팅, 작업 보드, 그리고 지출입니다. 불러오지 못한 섹션이 있으면 그렇다고 적고, 글은 그래도 올라갑니다.</p>
<p>앱 분석 데이터를 API로 읽다 보면 숫자를 조금은 의심하게 됩니다. 8월에는 아제르바이잔에서 Android 첫 실행이 121건 찍히고 그 뒤로 삭제가 119건 따라왔는데, 그달 Android 합계는 이걸 빼고 읽습니다.</p>
<p>릴리스에는 따로 보는 화면이 있습니다. OTA 업데이트를 제 서버에서 내보내던 시절, 릴리스를 보는 방법은 &ldquo;is active&rdquo; 체크박스가 달린 관리자 목록이 전부였고, 업데이트를 받은 폰이 몇 대인지 전혀 몰랐습니다. 지금 쓰는 서비스의 대시보드는 기기들이 실제로 어떤 릴리스를 돌리는지, 설치가 어떻게 됐는지, 그리고 잘못된 번들이 죽어서 SDK가 이전 번들로 되돌렸을 때의 크래시 롤백까지 보여줍니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/" >호스팅 업데이트 서비스로 옮긴 이야기</a>는 따로 정리해 두었습니다.</p>

<h2 class="relative group">버린 것, 또는 더는 믿지 않게 된 것
    <div id="버린-것-또는-더는-믿지-않게-된-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b2%84%eb%a6%b0-%ea%b2%83-%eb%98%90%eb%8a%94-%eb%8d%94%eb%8a%94-%eb%af%bf%ec%a7%80-%ec%95%8a%ea%b2%8c-%eb%90%9c-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 중 유료 도구는 하나도 없습니다. 대부분은 신호인 줄 알았는데 신호가 아니었던 것들이고, 하나는 잘못된 곳에서 돌던 작업입니다.</p>

<h3 class="relative group">user agent로 묶어 보기 전까지의 Cloudflare 504 비율
    <div id="user-agent로-묶어-보기-전까지의-cloudflare-504-비율" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#user-agent%eb%a1%9c-%eb%ac%b6%ec%96%b4-%eb%b3%b4%ea%b8%b0-%ec%a0%84%ea%b9%8c%ec%a7%80%ec%9d%98-cloudflare-504-%eb%b9%84%ec%9c%a8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>9월에 Cloudflare 대시보드를 보니 하루 요청의 22%가 504로 실패하는 것처럼 나왔습니다. 앱이 30초마다 폴링하는 엔드포인트에 몰려 있었는데, 헬스 체크는 전부 정상이었고 앱 자체 로그로는 그 요청들이 빨랐습니다. 며칠을 매달렸습니다. 머신이 콜드 스타트에 25~33초가 걸리고 그걸 하루 92번 한다는 걸 찾아내 고쳤고, <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >0으로 스케일 다운 글</a>에 그게 원인이라고 썼습니다.</p>
<p>다음 날 아침 같은 504를 user agent로 묶어 봤습니다. 9월 5일부터 9일 사이의 7,344건이 전부 Cloudflare 자체의 Early Hints 캐시 조회(user agent는 <code>bastion early hints</code>, <code>nginx-ssl early hints</code>)였습니다. 이 조회는 오리진에 아예 가지 않습니다. 그 줄의 504는 타임아웃이 아니라 캐시 미스를 기록한 것이죠. 실제 요청 경로를 따라 하기 때문에 가장 바쁜 엔드포인트에 떨어졌고, 정말로 사용자가 실패하는 것처럼 보였습니다. 앱, 브라우저, curl, 검색 엔진 크롤러에서는 한 건도 없었습니다. 오리진이 <code>Link</code> 헤더를 보내지 않으니 Early Hints는 아무 일도 하고 있지 않았고, 그래서 일단 껐습니다. 나중에는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/cloudflare-504-early-hints/" >제대로 동작하게 만들었습니다</a>.</p>
<p>콜드 스타트 작업은 그래도 남는 게 있었습니다. 배포도 깨어나는 것도 빨라졌으니까요. 하지만 고친다고 생각했던 장애는 사용자에게 닿은 적이 없었습니다.</p>
<p>그래서 꼭 전하고 싶은 규칙이 이겁니다. CDN의 에러율을 믿기 전에 user agent로 묶어서, 그게 CDN 자기 장치에서 나온 게 아닌지 확인하세요. Cloudflare 무료 플랜에서는 <code>httpRequestsAdaptiveGroups</code>에 대한 GraphQL 쿼리로, 하루씩 끊어서 볼 수 있습니다. 다음 소동도 같은 쿼리로 정리됐습니다. 폴링 엔드포인트 하나가 하루 70~260건에서 2,880건으로 뛰었는데, 스크레이퍼가 아니었습니다. 테스트하고 나서 iOS 시뮬레이터에 앱을 켜 둔 채로 둔 제 Mac이었습니다.</p>

<h3 class="relative group">초록불 헬스 체크
    <div id="초록불-헬스-체크" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b4%88%eb%a1%9d%eb%b6%88-%ed%97%ac%ec%8a%a4-%ec%b2%b4%ed%81%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Fly는 두 앱의 <code>/api/health/</code>를 30초마다 확인하고, 그 결과로 어느 머신에 트래픽을 보낼지 정합니다. 그 일은 잘합니다. 하지만 헬스 체크는 이미 떠 있는 머신을 상대로 돌기 때문에, 머신이 뜨기 전에 일어나는 일은 보지 못합니다. 콜드 스타트, 프록시 앞의 대기열, 끝내 도착하지 않는 요청 같은 것들이죠. 지금은 라우팅 말고는 바라는 게 없습니다.</p>
<p>배포 때는 대신 프로브 스크립트를 씁니다. 배포 내내 오리진을 치다가 하나라도 실패하면 1로 종료합니다. 이 스크립트가 평범한 <code>flyctl deploy</code>에서 7.3초짜리 공백을 잡아냈고, 예비 머신을 먼저 깨우는 배포 스크립트로 바꾼 뒤에는 2,514건 중 실패가 0건이었습니다.</p>
<p>헬스 체크는 스스로 장애를 두 번 일으키기도 했습니다. 7월에는 Django의 <code>SECURE_SSL_REDIRECT</code>가 Fly의 평문 HTTP 프로브에 301로 답해서, 모든 머신이 체크에 실패했고 모두가 503을 받았습니다. 8월에는 헬스 뷰의 <code>except Exception</code>을 좁혔더니 &ldquo;connection already closed&rdquo; 에러가 500으로 새어 나갔고, 뷰의 재시도가 바로 그 경우를 위해 있었는데도 Fly가 머신을 빼 버렸습니다. 지금 체크는 이 프로세스가 요청을 처리할 수 있는지만 묻고, DB가 멈춰도 두 앱이 라우팅에서 빠지지 않습니다.</p>
<p>아침 요약도 세는 법에서 같은 교훈을 얻었습니다. 마켓플레이스는 놀고 있는 머신을 서스펜드하기 때문에, 요청에 깨어난 머신은 시작 후 몇백 밀리초 만에 첫 체크에 실패하고 다음 체크는 통과합니다. 이걸 세다 보니 매일 아침이 장애처럼 읽혔고, 그래서 1분 안에 다시 통과한 실패는 이제 머신 기상과 배포로 따로 적습니다.</p>

<h3 class="relative group">CI의 예약 작업
    <div id="ci의-예약-작업" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci%ec%9d%98-%ec%98%88%ec%95%bd-%ec%9e%91%ec%97%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>아침 요약은 cron 일정으로 도는 CI 워크플로로 시작했다가 하루 만에 옮겼습니다. 앱 분석과 검색 데이터를 읽게 되자 필요한 키가 분석 프로젝트의 서비스 계정 키였는데, 그 프로젝트에서 뭐든 할 수 있는 키라 CI 시크릿에 두고 싶지 않았습니다. 지금은 제 Mac의 스케줄러가 20분마다 실행하고, 오클랜드 시간으로 아침 8시가 지났는데 오늘 글이 아직 안 올라갔을 때만 올립니다. 그러니 8시에 잠들어 있던 Mac은 깨어나면서 올리죠. 워크플로는 앱 분석과 검색 섹션을 뺀 수동 대비책으로 남겨 두었습니다.</p>

<h2 class="relative group">다시 시작한다면
    <div id="다시-시작한다면" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8b%a4%ec%8b%9c-%ec%8b%9c%ec%9e%91%ed%95%9c%eb%8b%a4%eb%a9%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>첫 장애가 난 뒤가 아니라 첫 사용자가 오기 전에 세 가지를 깔겠습니다. 팀이 이미 읽는 채팅 채널로 에러 보내기(횟수 제한 포함), SQL로 조회할 수 있는 오브젝트 스토리지 로그 아카이브, 그리고 플랫폼이 이미 모으고 있는 메트릭. 한 달에 몇 달러면 됩니다. 크래시 리포팅은 앱이 폰에 올라가는 주에 붙입니다. 그러지 않으면 폰에서 난 크래시는 아무에게도 아무것도 알려주지 않으니까요. 아침 요약은 확인할 곳이 너무 많아져서 결국 아무 데도 확인하지 않게 될 즈음에 붙이면 됩니다.</p>
<p>트레이싱은 요청이 넘나들 두 번째 서비스가 생길 때까지, 호스팅 APM은 공유 대시보드와 온콜 순번, 남의 청구서를 감당할 예산이 필요한 팀이 생길 때까지 미루겠습니다. 작년 글의 도구들이 제값을 하기 시작하는 건 그때부터입니다. 그 전까지 가장 쓸모 있는 습관은 공짜입니다. 숫자가 무서워 보이면, 무엇이 고장났는지 정하기 전에 그 숫자를 무엇이 만들었는지부터 확인하세요. 이 글에 나온 소동 중 여럿은 프로덕트가 아니라 모니터링에서 나왔습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Sentry 셀프 호스팅 없이: 오픈소스 SDK로 React Native 크래시 리포트를 우리 서버에서 받기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>크래시 리포팅</category>
      <category>셀프 호스팅</category>
      <category>iOS</category>
      <category>Android</category>
      <category>개인정보</category>
      <description>Sentry를 셀프 호스팅하지도, sentry.io 비용을 내지도 않고 React Native 앱의 크래시 리포트를 받는 방법입니다. Sentry 오픈소스 SDK는 그대로 두고 DSN만 우리 Django API로 돌려, 묶기와 알림, 개인정보 제거까지 직접 합니다.</description>
      <content:encoded><![CDATA[<p>React Native 앱의 크래시 리포트를 직접 받고 싶다면 Sentry 서버를 셀프 호스팅할 필요도, sentry.io에 비용을 낼 필요도 없습니다. Sentry SDK는 오픈소스이고, DSN은 어떤 호스트든 가리킬 수 있습니다. 저는 우리 Django API의 엔드포인트로 돌려서 그곳에서 리포트를 받고, 묶고, 알림을 보내고, 보관합니다. 원래 돌던 API 말고는 따로 드는 비용이 없습니다. 아직 대체하지 못한 건 심볼리케이션이라, 스택 트레이스만으로는 소스의 어느 줄인지 알 수 없습니다.</p>
<p>이번 주 전까지 우리 앱이 크래시 났다는 걸 아는 방법은 누군가 알려 주는 것뿐이었습니다. 그런데 대부분은 알려 주지 않습니다. 앱을 다시 열고, 또 꺼지면 그냥 안 쓰게 되죠. API 쪽은 대비가 돼 있었습니다(ERROR 로그는 전부 팀 채팅 채널로 갑니다). 하지만 휴대폰에서 일어난 크래시는 제가 볼 수 있는 어디에도 흔적을 남기지 않았습니다.</p>
<p>사실 앱에 리포터가 하나 있긴 했습니다. 앱이 사용자에게 오류 메시지를 보여 주면 그 메시지를 API의 작은 엔드포인트로 보내고, 서버가 이를 ERROR로 기록하니 채팅 채널에도 올라옵니다. 삭제가 실제로는 <em>성공</em>했는데 목록에 행이 그대로 남아 있었고, 그걸 다시 탭하자 &ldquo;No Item matches the given query&quot;가 뜬 일이 있었습니다. 그때 만든 장치입니다. 서버는 평범한 404 두 개만 기록하고 아무 말도 하지 않았거든요. 이건 앱이 잡아서 보여 주는 오류만 다룹니다. 크래시는 다릅니다. 리포트를 보내야 할 JavaScript 자체가 방금 죽어 버린 거니까요.</p>
<p>뻔한 해법은 Sentry입니다. 저는 Sentry SDK를 꽤 좋아합니다. React Native에서 크래시 리포팅이 이미 해결된 문제처럼 느껴지는 건 이 SDK 덕분이죠. 다만 사용자 데이터를 쥐고 있는 업체가 하나 더 늘고, 개인정보 처리방침에 한 줄이 더 붙고, 우리 규모로는 하루에 많아야 몇 건인 일에 청구서가 또 하나 생기는 건 원하지 않았습니다. 그래서 SDK는 남기고 서비스만 바꿨습니다. 앱의 DSN은 우리 API를 가리키고, 그 Django 앱의 작은 한 부분이 SDK가 보내는 걸 받아서 묶고, 알리고, 보관합니다.</p>
<p>이 글은 그 구조와, 실제 네이티브 크래시가 정말 끝까지 도착하는지 확인한 과정에 대한 이야기입니다.</p>

<h2 class="relative group">DSN 트릭
    <div id="dsn-트릭" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dsn-%ed%8a%b8%eb%a6%ad" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Sentry DSN은 <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code> 형태입니다. SDK는 이를 <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code>로 가는 POST로 바꾸고, 키는 <code>X-Sentry-Auth</code> 헤더에 넣습니다. sentry.io에만 해당하는 부분은 하나도 없습니다. 그래서 앱에 박아 둔 DSN은 이렇습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>그리고 Django에는 SDK가 호출할 주소와 똑같은 라우트를 둡니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<p>키는 모든 바이너리에 들어 있으니 비밀이 아닙니다. 엔드포인트에 쓰레기가 몰리기 시작하면 바꿀 수 있게, 그리고 서버에 키가 설정되지 않았을 때 아무거나 다 받는 대신 전부 거절하게 하려고 있는 겁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p><code>_key</code>는 <code>X-Sentry-Auth</code> 헤더에서 <code>sentry_key=</code>를 읽고, 없으면 쿼리 스트링을 봅니다. 일부 SDK는 쿼리 스트링을 쓰기 때문입니다.</p>

<h2 class="relative group">SDK가 보내는 것: envelope
    <div id="sdk가-보내는-것-envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk%ea%b0%80-%eb%b3%b4%eb%82%b4%eb%8a%94-%ea%b2%83-envelope" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Sentry의 전송 형식은 <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>입니다. JSON 헤더 한 줄 뒤에 아이템이 여러 개 이어지고, 각 아이템은 JSON 헤더 한 줄과 페이로드로 이루어집니다. 아이템 헤더에 <code>length</code>가 있으면 페이로드는 정확히 그 바이트 수만큼이고, 없으면 다음 줄바꿈까지입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>여기서 두 가지에 걸려 넘어졌습니다.</p>
<ul>
<li><strong>네이티브 SDK는 본문을 gzip으로 압축하고, JavaScript SDK는 하지 않습니다.</strong> <code>@sentry/react-native</code>가 잡은 JS 오류는 평문으로 오지만, sentry-cocoa나 sentry-android가 보낸 네이티브 크래시는 <code>Content-Encoding: gzip</code>으로 옵니다. 디코더는 gzip, zlib로 감싼 deflate, raw deflate를 모두 처리하고, 압축 해제는 1MB에서 멈춥니다. 크래시 이벤트는 몇 KB 수준이라 그보다 크면 크래시 리포트가 아니니까요.</li>
<li><strong>길이가 붙은 페이로드에는 줄바꿈이 들어 있을 수 있습니다.</strong> <code>\n</code>으로 쪼개면 네이티브 SDK가 보내는 이벤트를 전부 망가뜨립니다. <code>length</code>가 있으면 반드시 그걸 따라야 합니다.</li>
</ul>
<p>파서는 짧습니다. <code>event</code> 아이템만 남기고, 세션·트랜잭션·첨부 파일·클라이언트 리포트는 받기만 하고 버립니다.</p>

<h2 class="relative group">SDK가 알아듣는 방식으로 응답하기
    <div id="sdk가-알아듣는-방식으로-응답하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk%ea%b0%80-%ec%95%8c%ec%95%84%eb%93%a3%eb%8a%94-%eb%b0%a9%ec%8b%9d%ec%9c%bc%eb%a1%9c-%ec%9d%91%eb%8b%b5%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>생각보다 중요한 부분이었습니다. SDK는 디스크에 큐를 두고, 전달됐는지 확신이 없는 건 다시 보냅니다. 그러니 서버의 응답이 SDK가 계속 재시도할지를 결정합니다.</p>
<ul>
<li><strong>200</strong>: 저장했을 때, <em>또는 일부러 버렸을 때</em>. 세션 아이템에 4xx로 답하면 SDK는 그걸 붙들고 영원히 재시도합니다.</li>
<li><strong>429와 <code>Retry-After</code></strong>: 스로틀에 걸렸을 때. 엔드포인트에는 IP당 스로틀(시간당 120건)이 있습니다. 크래시 루프에 빠진 폰은 실행할 때마다 리포트를 하나씩 보내기 때문이고, SDK는 429를 받으면 기대한 대로 물러납니다.</li>
<li><strong>400</strong>: envelope가 깨졌을 때. 이건 에러가 아니라 경고로 기록합니다. 깨진 리포트는 클라이언트가 고장 났다는 뜻이고, 다음에 오는 정상 리포트가 무엇이 크래시 났는지 알려 줄 테니까요.</li>
</ul>
<p>SDK는 첫 전송이 도착했는지 확신이 없으면 같은 이벤트를 다시 보내기도 합니다. 그래서 <code>event_id</code>에 unique 제약을 걸었고, 중복은 조용히 무시됩니다.</p>

<h2 class="relative group">소스 맵 없이 묶기
    <div id="소스-맵-없이-묶기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%86%8c%ec%8a%a4-%eb%a7%b5-%ec%97%86%ec%9d%b4-%eb%ac%b6%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>직접 만든 수신기라면 Sentry가 대신 해 주던 선택을 직접 해야 하는 지점입니다. Sentry는 스택 트레이스로 크래시를 묶습니다. 하지만 릴리스용 Hermes 번들은 minify돼 있어서 프레임 이름은 한 글자이고, 줄·열 위치는 빌드할 때마다 바뀝니다. 프레임으로 묶으면 같은 버그가 릴리스마다 새 이슈로 열리죠.</p>
<p>소스 맵이 없는 상태에서(이건 마지막에 다시 얘기합니다) JavaScript 오류에서 가장 안정적인 건 타입과 메시지입니다. 그래서 핑거프린트는 예외 타입과 메시지로 만들고, 발생할 때마다 달라지는 부분은 가립니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div>
<p><code>Cannot read property 'title' of undefined (reading index 3)</code>와 <code>(reading index 17)</code>은 같은 이슈가 됩니다. SDK가 <code>{{ default }}</code>가 들어 있지 않은 자체 <code>fingerprint</code>를 보내면 그게 우선이라, 기본 묶음이 틀렸을 때는 클라이언트에서 강제로 묶을 수 있습니다. 예외가 없는 이벤트는 가린 로그 메시지로, 둘 다 없으면 앱 코드의 맨 위 프레임으로 묶습니다. 네이티브 시그널 크래시는 메시지가 <code>Signal 6, Code 0</code> 같은 식이라 시그널별로 묶입니다. 거칠긴 하지만, 심볼리케이션이 생기기 전까지는 정직한 방식입니다.</p>

<h2 class="relative group">채널을 도배하지 않는 알림
    <div id="채널을-도배하지-않는-알림" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b1%84%eb%84%90%ec%9d%84-%eb%8f%84%eb%b0%b0%ed%95%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%95%8c%eb%a6%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>핑거프린트 하나가 이슈 하나가 되고, 리포트 하나하나는 그 아래 이벤트로 저장됩니다. 이슈는 정확히 다음 경우에만 ERROR로 기록합니다(채팅 알림이 듣는 게 이 레벨입니다).</p>
<ul>
<li><strong>새</strong> 이슈</li>
<li><strong>해결됨</strong>으로 표시한 이슈가 <strong>다시 나타났을 때</strong>. 이때 이슈가 다시 열립니다.</li>
<li><strong>10번째, 100번째, 1,000번째, 10,000번째</strong> 발생</li>
</ul>
<p>평범한 반복은 카운터만 올립니다. <em>무시</em>로 표시한 이슈는 계속 세기만 하고 알림은 보내지 않습니다. 여기에 채팅 핸들러 자체의 속도 제한까지 겹치니, 잘못된 릴리스로 크래시 루프가 돌아도 메시지 벽이 아니라 몇 줄로 끝납니다.</p>
<p>저장 공간도 같은 식으로 제한합니다. 이슈마다 <strong>최근 50개</strong> 이벤트만 남기고, 새 이벤트가 들어오면 오래된 건 지웁니다. 그런데 이것 때문에 첫 차트가 망가졌습니다. 이벤트 50개로는 지난주에 얼마나 자주 일어났는지 알 수 없으니까요. 그래서 이슈마다 90일치 일별 횟수를 따로 두고, 카운터를 올리는 것과 같은 트랜잭션 안에서 갱신합니다. 차트는 이 횟수로 그리고, 이벤트는 읽는 용도로만 씁니다.</p>
<p>이런 걸 직접 만든다면 작은 경쟁 상태 두 개는 챙겨 둘 만합니다. 완전히 새로운 크래시가 여러 폰에서 동시에 나면 두 리포트가 동시에 이슈를 만들려고 할 수 있으니, 핑거프린트에 unique 제약을 걸고 진 쪽이 기존 행을 다시 읽게 하세요. 그리고 90일 범위는 가장 최근 리포트가 아니라 서버 기준 오늘부터 재야 합니다. 시계가 잘못 맞춰진 폰은 리포트 날짜를 아무 날로나, 심지어 내년으로도 찍을 수 있거든요.</p>
<p>페이로드는 128KB로 제한합니다. 이보다 큰 이벤트는 breadcrumb과 디버그 메타데이터부터 버리고, 그래도 크면 예외, 메시지, 릴리스, 플랫폼만 남긴 뒤 잘렸다는 표시를 붙입니다.</p>

<h2 class="relative group">개인정보 걷어 내기
    <div id="개인정보-걷어-내기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%9c%ec%9d%b8%ec%a0%95%eb%b3%b4-%ea%b1%b7%ec%96%b4-%eb%82%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>SDK는 <code>sendDefaultPii: false</code>로 돌기 때문에, 원칙적으로는 IP 주소, 쿠키, 요청 본문이 오지 않습니다. 서버는 거기에 기대지 않습니다. 저장하기 전에 <code>user</code>에서는 <code>id</code>와 <code>username</code>만 남기고, <code>request</code>는 통째로 버립니다. 나중에 SDK 버전이나 설정이 바뀌어서 이런 걸 보내기 시작할 수도 있으니까요.</p>
<p>실제 기기로 테스트해 보니 걷어 내야 할 게 두 가지 더 나왔습니다.</p>
<ul>
<li><strong>breadcrumb은 쿼리 스트링까지 포함한 전체 URL을 기록합니다.</strong> 앱의 검색 요청은 검색어를 쿼리 스트링에 담아 보내기 때문에, 검색 직후에 크래시가 나면 그 사람이 뭘 검색했는지가 저장됐을 겁니다. 이제 서버가 모든 breadcrumb URL을 <code>?</code>에서 자릅니다.</li>
<li><strong>콘솔 breadcrumb에는 <code>console.log</code>에 넘긴 인자가 그대로 들어 있습니다.</strong> 앱이 마침 로그로 찍은 객체라면 무엇이든 될 수 있죠. 메시지 텍스트는 쓸모가 있으니 남기고, 인자는 버립니다.</li>
</ul>
<p>이 둘은 실제 리포트를 들여다보고 나서야 발견했습니다. 뒤에 나올 섹션이 필요한 가장 큰 이유가 이겁니다.</p>

<h2 class="relative group">클라이언트 쪽
    <div id="클라이언트-쪽" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8-%ec%aa%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱에서는 모든 게 작은 모듈 하나를 거칩니다. OTA 업데이트 SDK를 감싸 둔 것과 같은 이유입니다. OTA 번들은 네이티브 모듈이 생기기 전에 빌드된 바이너리에서도 돌아가거든요. <code>RNSentry</code>가 없는 바이너리에서 최상단에 <code>import * as Sentry from &quot;@sentry/react-native&quot;</code>를 쓰면 import 시점에 예외가 나고, 그러면 빈 화면입니다. 그래서 SDK는 지연 로딩하고, 그것도 네이티브 모듈이 실제로 있는지 확인한 다음에만 불러옵니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</span><span class="p">;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>오류만 보냅니다. 서버가 트레이스, 세션, 프로파일, 리플레이를 저장하지 않으니 그중 아무것도 켜지 않았습니다. DSN이 없으면(개발 빌드, E2E 빌드) SDK는 아예 시작하지 않습니다. 빌드 프로필에서 DSN과 environment(<code>staging</code> 또는 <code>production</code>)를 지정하기 때문에, 두 종류의 빌드가 같은 엔드포인트로 보내면서도 서로 구분됩니다. 로그인한 사용자는 계정 ID로만 지정하고, 이름이나 이메일은 절대 넣지 않습니다.</p>
<p>에러 바운더리에는 명시적인 호출이 필요했습니다. Expo Router의 루트 <code>ErrorBoundary</code>는 렌더링 오류를 잡아서 자기 화면을 보여 주기 때문에, 그 오류는 SDK가 걸어 둔 전역 핸들러까지 가지 않습니다. 해결책은 작은 래퍼입니다. <code>useEffect</code> 안에서 <code>captureException</code>을 호출한 다음, 예전처럼 라우터의 바운더리를 렌더링하고, 루트와 라우트별 바운더리에 똑같이 씁니다. 렌더링 중이 아니라 effect 안에서 부르는 건, 다시 렌더링될 때마다가 아니라 한 번만 보고하게 하려는 겁니다. 루트 컴포넌트는 <code>Sentry.wrap</code>으로도 감싸는데, 이게 예외를 던지면 감싸지 않은 컴포넌트를 그대로 씁니다.</p>
<p>웹용으로는 모든 함수를 꺼 둔 <code>.web.ts</code> 버전의 모듈이 있습니다. Metro가 플랫폼별 파일을 고르기 때문에 SDK는 웹 번들에 아예 들어가지 않습니다. 웹 앱에는 돌릴 네이티브 모듈이 없으니까요.</p>

<h2 class="relative group">네이티브 크래시가 정말 도착하는지 확인하기
    <div id="네이티브-크래시가-정말-도착하는지-확인하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ed%81%ac%eb%9e%98%ec%8b%9c%ea%b0%80-%ec%a0%95%eb%a7%90-%eb%8f%84%ec%b0%a9%ed%95%98%eb%8a%94%ec%a7%80-%ed%99%95%ec%9d%b8%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>단위 테스트는 손으로 쓴 envelope를 보냅니다. 파서가 SDK가 보낼 거라고 <em>제가 생각하는</em> 걸 처리한다는 것까지만 증명하죠. 출시 이틀 뒤, 프로덕션에 들어온 크래시 리포트는 0건이었습니다. 업데이트한 사람이 아직 세 명뿐이라 0건이어도 이상하지는 않았지만, 서버 입장에서는 &ldquo;아무도 크래시가 안 났다&quot;와 &ldquo;크래시가 도착하지 않는다&quot;가 똑같아 보입니다. 진짜 크래시를 하나 보고 싶었습니다.</p>
<p>그래서 DSN을 8000번 포트의 로컬 Django와 임시 키로 바꾼 Release 시뮬레이터 바이너리를 빌드했습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>(Debug가 아니라 Release입니다. Debug 빌드는 dev launcher가 링크돼서 동작이 다르고, 사람들이 실제로 쓰는 건 Release니까요.) 그다음 설치하고, 실행하고, 몇 초 기다렸다가, 실제 결함이 하듯이 죽였습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>시뮬레이터 앱은 평범한 macOS 프로세스라서, 시그널은 기기에서와 똑같이 SentryCrash의 핸들러에 도달합니다. 핸들러는 리포트를 디스크에 쓰기만 하고, 프로세스가 죽어 가는 동안에는 아무것도 보내지 않습니다. <strong>다음 실행</strong> 때 SDK가 그걸 찾아서 보냅니다. 다시 실행하자 1초 뒤 시뮬레이터 네트워크 로그에 <code>:8000</code>로의 연결이 찍혔습니다.</p>
<p>그런데 Django 로그에는 아무것도 없어서 잠깐 헷갈렸습니다. 제가 설정해 둔 레벨에서는 개발 서버가 2xx가 아닌 응답만 기록하더군요. 데이터베이스에는 들어와 있었습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>크래시부터 저장까지 15초였고, 대부분은 제가 앱을 다시 켜는 데 걸린 시간입니다. 그리고 실제 페이로드를 들여다보니 테스트로는 알 수 없던 것들이 보였습니다.</p>
<ul>
<li><strong>사용자 ID가 계정 ID가 아니었습니다.</strong> 테스트 빌드는 실제로 로그인한 상태가 아니라서, sentry-cocoa가 자체 설치별 ID로 대신 채웠습니다. 알아 둘 만한 점이고, 개인정보 안내에도 적어야 할 점입니다. 로그인했다면 리포트에는 계정 ID가, 아니라면 무작위 설치 ID가 담깁니다.</li>
<li><strong>시간대가 없습니다.</strong> culture 컨텍스트가 있을 줄 알았는데 없었습니다. device 컨텍스트에는 <code>locale</code>, 메모리, 화면 크기, 저전력 모드, 커널 버전은 있지만 시간대는 없습니다.</li>
<li><strong>네이티브 크래시는 <code>beforeSend</code>를 거치지 않습니다.</strong> JS 쪽 <code>beforeSend</code>가 실행 중인 OTA 번들로 <code>ota</code> 태그를 붙이는데, 네이티브 크래시는 다음 실행 때 네이티브 SDK가 조립하기 때문에 JS 훅을 전혀 지나지 않습니다. 네이티브 크래시에 태그를 붙이려면, 번들을 알게 된 시점에 <code>setTag</code>로 스코프에 태그를 걸어서 네이티브 레이어까지 동기화되게 해야 합니다.</li>
<li><strong>breadcrumb을 보면 앱이 뭘 하는지 보입니다.</strong> 콘솔 경고, 뷰 컨트롤러 생명주기, HTTP 호출이 있었는데, 그중에는 분석 SDK가 자기 수집 엔드포인트로 보내는 요청도 있었습니다. 앱이 그 밖에 어디와 통신하는지 다시 상기하게 되더군요. 그리고 URL에 쿼리 스트링이 붙어 있었고, 앞에서 말한 검색어 문제를 찾은 게 바로 여기서였습니다.</li>
</ul>

<h2 class="relative group">리포트 읽기
    <div id="리포트-읽기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a6%ac%ed%8f%ac%ed%8a%b8-%ec%9d%bd%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>리포트는 Django 관리자에서 읽습니다. Sentry를 느슨하게 본뜬 이슈 페이지인데, 상단에 횟수와 Resolve / Ignore / Reopen 버튼, 일별 막대, 릴리스·OS·기기·OTA 번들별 비율이 있고, 그 아래로 이벤트를 하나씩 예외, 프레임(앱 프레임은 강조, 라이브러리 프레임은 접힘), breadcrumb, 기기 컨텍스트와 함께 보여 줍니다. 새 의존성은 필요 없었습니다.</p>
<p>만들다가 고친 게 하나 있습니다. 네이티브 크래시에서 &ldquo;culprit&rdquo; 프레임이 기기 안의 바이너리 경로로 나왔는데, 여기에 설치마다 다른 UUID가 들어 있어서 같은 크래시가 폰마다 다르게 보였습니다. 이제는 <code>0x1000a1b2c (MyApp)</code>처럼 주소와 바이너리 이름만 나옵니다.</p>

<h2 class="relative group">개인정보 처리방침, 전부 다
    <div id="개인정보-처리방침-전부-다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%9c%ec%9d%b8%ec%a0%95%eb%b3%b4-%ec%b2%98%eb%a6%ac%eb%b0%a9%ec%b9%a8-%ec%a0%84%eb%b6%80-%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크래시 리포트는 사실상 개인정보입니다. 계정 ID, 휴대폰 모델, 그리고 앱이 죽기 직전 1분 동안 그 사람이 한 일이 들어 있으니까요. 그래서 개인정보 처리방침에 한 단락이 필요했습니다. 제대로 쓰려면 리포트에 정확히 뭐가 담기는지 말해야 했고, 방금 들여다본 페이로드를 보면서 목록을 만들었습니다. 오류, 앱 버전과 OTA 업데이트, 휴대폰 모델, OS와 언어, 남은 메모리와 화면 크기, 직전 수십 단계(열어 본 화면, <code>?</code> 뒤를 지운 웹 주소, 앱 자체의 로그 메시지), 그리고 계정 ID 또는 무작위 설치 ID입니다. 리포트는 우리 서버로 오고 Sentry나 다른 크래시 리포팅 회사로는 가지 않는다는 것도 처리방침에 적었습니다. 보관 기간은 표에 따로 한 줄을 뒀습니다. 문제별로 최근 50건의 리포트, 그리고 90일치 일별 횟수입니다.</p>
<p>한 단락을 코드와 대조하기 시작하니 나머지를 안 볼 수가 없었습니다. 기존 처리방침은 버티지 못했습니다. 계정 삭제 페이지에는 계정을 닫으면 푸시 알림 토큰과 패스키가 즉시 삭제되고, 서버 로그는 90일 안에 사라진다고 적혀 있었습니다. 셋 다 사실이 아니었습니다. 계정을 닫으면 로그인 수단의 연결이 끊기고 프로필이 익명화되지만, 푸시 토큰과 패스키는 누군가 손으로 지울 때까지 남아 있고, 로그 보관소에는 아직 만료 기간이 없습니다. 그래서 처리방침 전체를 코드가 실제로 하는 일의 목록을 바탕으로 다시 썼고, 그 줄들은 이제 &ldquo;아직 자동으로 삭제되지 않음, 이메일로 요청해 주세요&quot;라고 적혀 있습니다. 덜 안심되지만, 정확합니다.</p>
<p>이제 저장소에 이에 대한 규칙이 있습니다. 처리방침은 우리가 하려는 일이 아니라 코드가 하는 일을 설명하고, 개인정보를 새로운 곳에서 수집하거나 보내거나 보관하는 변경은 같은 커밋에서 처리방침도 고친다는 규칙입니다. 앱 안의 요약 화면, 계정 삭제 페이지, 그리고 스토어 콘솔에만 있는 앱 스토어 개인정보 양식까지 포함해서요. 이걸 글로 적어 두게 만든 변경이 크래시 리포팅이었습니다.</p>

<h2 class="relative group">아직 없는 것
    <div id="아직-없는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%95%84%ec%a7%81-%ec%97%86%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>심볼리케이션.</strong> 이게 진짜 빈자리이고, sentry.io가 해 주는 일 중 제가 대체하지 못한 단 하나입니다. JavaScript 스택은 minify돼 있고, 네이티브 스택은 심볼리케이션이 안 돼 있습니다.</p>
<ul>
<li><strong>JavaScript:</strong> 프레임이 Hermes 번들 안을 가리킵니다. 고치려면 모든 스토어 빌드와 모든 OTA 번들의 합성된 Hermes 소스 맵을 릴리스와 번들별로 보관해 두고, 페이지를 렌더링할 때 JS 프레임에 적용해야 합니다. 우리 크래시는 대부분 JavaScript일 테니 이게 다음 작업입니다.</li>
<li><strong>iOS 네이티브:</strong> 명령어 주소를 풀려면 해당 빌드의 dSYM이 필요합니다.</li>
<li><strong>Android:</strong> minify를 켜면 Java 프레임은 R8로 난독화되고(<code>mapping.txt</code>가 필요합니다), C++ 프레임은 NDK 심볼이 필요합니다.</li>
</ul>
<p>위에서 발견한 더 작은 빈자리도 있습니다. 네이티브 크래시에는 아직 OTA 태그가 붙지 않습니다. <code>beforeSend</code>에서만 붙이고 있기 때문이죠. 실행 중인 번들이 확정되면 스코프에 태그를 거는 건 몇 줄이면 되는데, 아직 쓰지 않았습니다.</p>
<p>지금으로서는 이슈를 보면 <em>무엇이</em> 크래시 났는지, 어떤 릴리스와 OTA 번들과 기기에서였는지, 그 사람이 직전에 뭘 하고 있었는지는 알 수 있습니다. 소스의 <em>어디에서</em>인지는 알 수 없습니다. 일주일에 몇 번 배포하는 작은 앱에서는 지금까지 타입, 메시지, breadcrumb만으로도 원인을 찾기에 충분했습니다.</p>
<p>수신기와 관리자 페이지, 테스트를 합쳐도 코드는 많지 않고, 이미 돌아가던 API 위에 추가로 드는 비용은 없습니다. 어려운 일은 SDK가 합니다. 휴대폰에서 크래시를 잡고, 다음 실행 때 그 리포트를 밖으로 내보내는 일이죠.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
