<?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>Monitoring · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/monitoring/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/monitoring/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>프로덕트 두 개에 쓰는 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 위의 작은 프로덕트 두 개를 위한 제 DevOps 모니터링은 Django 로깅이 채우는 채팅 채널, DuckDB로 조회하는 오브젝트 스토리지 로그 아카이브, Fly 기본 메트릭, Sentry SDK로 받아 제 서버에 쌓는 앱 크래시 리포트, 그리고 매일 아침 올라오는 요약 글 하나입니다. 도구마다 무엇을 보는지, 비용은 얼마인지, 그리고 무엇을 버리거나 더는 믿지 않게 됐는지 정리했습니다. 알고 보니 Cloudflare 자체 프로브였던 22% 에러율 이야기도 포함해서요.</description>
      <content:encoded><![CDATA[<p>제가 만드는 마켓플레이스 겸 커뮤니티 앱과 현장 기술자용 견적 앱, 이렇게 작은 프로덕트 두 개를 둘 다 Fly.io의 가장 싼 머신에서 돌리고 있습니다. 제 DevOps 모니터링은 결국 다섯 가지로 정리됩니다. ERROR 로그를 올리는 채팅 채널, 오브젝트 스토리지에 쌓아 두고 DuckDB로 조회하는 전체 로그, Fly에 기본으로 딸린 Prometheus 메트릭, Sentry의 오픈소스 SDK로 받아 제 API로 보내는 앱 크래시 리포트, 그리고 매일 아침 올라오는 요약 글 하나입니다. 호스팅 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>끔. 이 기능의 프로브가 존 전체 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%eb%8a%94-%ec%a0%9c%ea%b0%80-%ec%9d%b4%eb%af%b8-%ec%9e%88%eb%8a%94-%ea%b3%b3%ec%9c%bc%eb%a1%9c-%ec%98%a8%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>9월 전까지는 처리되지 않은 500, 실패한 푸시 알림, 깨진 WebSocket 프레임이 나타나는 곳이 <code>flyctl logs</code>뿐이었고, 그 스트림을 누가 시키지도 않았는데 읽는 사람은 없습니다. 지금은 로깅 핸들러가 ERROR 이상인 모든 레코드를 API, WebSocket 서버, 백그라운드 워커 어디서 나든 채팅 채널로 보냅니다. 경고(WARNING)는 일부러 알리지 않습니다.</p>
<p>핸들러는 백그라운드 스레드에서 전송하니 요청을 느리게 만들지 않습니다. 핸들러 안에서는 아무것도 로그를 남기지 않으니 재귀에 빠질 일도 없습니다. 그리고 같은 에러는 10분에 한 번까지만, 시간당 최대 30건까지만 보내서, 장애가 나도 메시지 천 개가 아니라 하나로 읽힙니다. 이 채널에는 에러와 나란히 놓고 읽고 싶은 것들도 올라옵니다. 배포는 시작할 때, 올라왔을 때, 실패한 지점에서 커밋과 실행한 사람을 함께 올리고, 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는 몇 분, 아카이브는 전부
    <div id="로그-fly는-몇-분-아카이브는-전부" 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%eb%8a%94-%eb%aa%87-%eb%b6%84-%ec%95%84%ec%b9%b4%ec%9d%b4%eb%b8%8c%eb%8a%94-%ec%a0%84%eb%b6%80" 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">직접 만들 필요가 없었던 메트릭
    <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%a7%81%ec%a0%91-%eb%a7%8c%eb%93%a4-%ed%95%84%ec%9a%94%ea%b0%80-%ec%97%86%ec%97%88%eb%8d%98-%eb%a9%94%ed%8a%b8%eb%a6%ad" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Fly는 조직마다 매니지드 Prometheus를 돌려주기 때문에 저는 메트릭 스택을 따로 운영하지 않습니다. 궁금한 게 생기면 API를 칩니다. 머신별 메모리, CPU, 버스트 크레딧(공유 CPU에서는 <code>fly_instance_cpu_balance</code>가 중요합니다), 앱별 응답 수와 동시 요청 수 같은 것들이죠. 처음부터 알았으면 좋았을 게 두 가지 있습니다. <code>Authorization: FlyV1 &lt;token&gt;</code>이어야 하고, Bearer 헤더나 읽기 전용 토큰은 둘 다 &ldquo;something went wrong resolving organization&quot;을 돌려줍니다. 그리고 보관 기간이 12일 정도입니다.</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">폰에서 나는 크래시
    <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%8f%b0%ec%97%90%ec%84%9c-%eb%82%98%eb%8a%94-%ed%81%ac%eb%9e%98%ec%8b%9c" 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>앱 분석 데이터를 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 프로브(<code>bastion early hints</code>, <code>nginx-ssl early hints</code>)였습니다. 오리진에서 응답을 받지 못해 504로 기록된 거였죠. 실제 요청 경로를 따라 하기 때문에 가장 바쁜 엔드포인트에 떨어졌고, 정말로 사용자가 실패하는 것처럼 보였습니다. 앱, 브라우저, curl, 검색 엔진 크롤러에서는 한 건도 없었습니다. 오리진이 preload 헤더를 보내지 않으니 Early Hints는 애초에 아무 일도 하고 있지 않았고, 그래서 껐습니다.</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초마다 확인하고, 그 결과로 어느 머신에 트래픽을 보낼지 정합니다. 그 일은 잘합니다. 하지만 헬스 체크는 이미 떠 있는 머신을 상대로 돌기 때문에, 머신이 뜨기 전에 일어나는 일은 보지 못합니다. 콜드 스타트, 프록시 앞의 대기열, 끝내 도착하지 않는 요청 같은 것들이죠. 지금은 라우팅 말고는 바라는 게 없습니다. 배포 때는 대신 프로브 스크립트를 씁니다. 배포 내내 오리진을 치다가 하나라도 실패하면 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>
  </channel>
</rss>
