<?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>Django · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/django/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/django/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>클라우드플레어 터널로 로컬 서버 외부 접속: 노트북에서 무료로 셀프 호스팅하기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>포트를 열지 않고 로컬 서버를 외부에서 접속하게 만드는 법. 클라우드플레어 터널(Cloudflare Tunnel)로 진짜 HTTPS 주소를 붙이고, ingress 허용 목록으로 공개할 경로만 정하고, 비용은 0원인 셀프 호스팅 과정을 Docker Compose 예제로 정리했습니다.</description>
      <content:encoded><![CDATA[<p>제 아이디어는 늘 같은 식으로 시작합니다. 노트북에서 돌아가고, 잘 되고, 그래서 다른 사람에게 보여주고 싶어집니다. 한동안 그다음 단계는 언제나 클라우드 요금이었습니다. 머신 하나, 데이터베이스 하나, 버킷 하나, 거기에 연결한 도메인, 그리고 세 명이 들여다볼 물건에 매달 나가는 돈. 사이드 프로젝트나 진짜 프로젝트의 첫 달이라면 순서가 거꾸로입니다. 프로덕션이 생길지도 모르는데 프로덕션 값부터 내는 셈이니까요.</p>
<p>이 글은 반대 순서입니다. 서비스는 만들어진 그 머신에서 계속 돌고, Cloudflare Tunnel이 거기에 공개 HTTPS 호스트명을 붙여 줍니다. 노트북에서 인터넷을 향해 열려 있는 건 아무것도 없습니다. 포트 포워딩도, 공유기 설정도, 노출된 IP 주소도 없습니다. 머신이 Cloudflare 쪽으로 먼저 연결을 걸고, Cloudflare는 그 연결을 타고 요청을 내려보냅니다. 터널은 무료이고, TLS 인증서와 DDoS 방어, 캐싱은 아마 이미 갖고 있을 Cloudflare 존에 딸려 옵니다.</p>
<p>예시는 실제 사례입니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/" >제 제품 중 하나</a>는 Fly.io에서 돌고, 모바일 앱은 백엔드에서 OTA(over-the-air) JavaScript 업데이트를 받습니다(셀프 호스팅한 Expo Updates). 프로덕션은 그대로 둔 채, 개발 빌드용으로 그 업데이트 서버의 두 번째 사본을 제 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="#%ed%81%b4%eb%9d%bc%ec%9a%b0%eb%93%9c%ed%94%8c%eb%a0%88%ec%96%b4-%ed%84%b0%eb%84%90%ec%9d%b4%eb%9e%80-%ec%8b%a4%ec%a0%9c%eb%a1%9c-%eb%ac%b4%ec%97%87%ec%9d%b8%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>보통 서버는 연결을 기다립니다. 인터넷에서 노트북에 닿으려면 공유기에서 포트를 열고, 그걸 노트북으로 포워딩하고, ISP가 공인 IPv4 주소를 주기를 바라야 합니다(제가 사는 곳을 포함해 많은 곳에서 주지 않습니다). 그러고 나면 그 포트에서 듣고 있는 모든 것이 인터넷의 모든 스캐너에게 하루 종일 노출됩니다.</p>
<p>터널은 이걸 뒤집습니다. <code>cloudflared</code>가 앱 옆에서 돌면서 Cloudflare 엣지로 나가는 연결을 엽니다. 가까운 데이터센터 두 곳에 네 개입니다. 존의 DNS 레코드가 호스트명을 터널로 향하게 합니다. 그 호스트명으로 요청이 오면 Cloudflare가 TLS를 종료하고 그 연결 중 하나로 요청을 내려보냅니다. 그러면 <code>cloudflared</code>가 설정에 적힌 로컬 서비스에 요청을 넘깁니다.</p>
<figure class="diagram">
  <div role="img" aria-label="요청이 HTTPS로 Cloudflare에 도착해 규칙들을 거쳐 터널 엔드포인트로 가고, cloudflared가 내 머신에서 열어 둔 아웃바운드 연결을 타고 내려옵니다. cloudflared는 호스트명과 경로에 따라 API, 엣지, 또는 404로 보냅니다"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">인터넷</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">내 머신</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">휴대폰 / 브라우저</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="22">TLS, 리다이렉트 규칙, WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">터널 엔드포인트</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">아웃바운드 QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">(열린 포트 없음)</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress 규칙: 호스트명 + 경로 허용 목록</text>
</svg>
</div><figcaption>머신은 오직 밖으로만 연결합니다. Cloudflare의 존 규칙이 터널보다 먼저 돌고, 무엇에 닿을 수 있는지는 ingress 규칙이 정합니다.</figcaption>
</figure>

<p>보안 측면에서 중요한 건, 공유기에 인바운드 규칙이 하나도 없고 IP 주소가 DNS에 절대 나타나지 않는다는 점입니다. 누가 집 회선을 스캔해도 아무것도 찾지 못합니다. 듣고 있는 게 없으니까요.</p>

<h2 class="relative group">처음부터 끝까지 설정하기 (cloudflared + Docker Compose)
    <div id="처음부터-끝까지-설정하기-cloudflared--docker-compose" 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%b2%98%ec%9d%8c%eb%b6%80%ed%84%b0-%eb%81%9d%ea%b9%8c%ec%a7%80-%ec%84%a4%ec%a0%95%ed%95%98%ea%b8%b0-cloudflared--docker-compose" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare에 올린 도메인(무료 플랜이면 충분합니다)과 설치된 <code>cloudflared</code>(Mac이라면 <code>brew install cloudflared</code>)가 필요합니다.</p>
<p><strong>1. 한 번 로그인합니다.</strong> 브라우저가 열리면 존을 고르고, 이 머신이 그 존에 터널과 DNS 레코드를 만들 수 있게 해 주는 인증서가 저장됩니다.</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">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. 터널을 만듭니다.</strong> 저는 이 프로젝트에 관한 건 전부 전용 폴더에 둡니다. 자격 증명 파일이 곧 터널의 신원이라, 그 파일을 가진 사람은 누구든 내 호스트명으로 트래픽을 서빙할 수 있기 때문입니다.</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">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<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">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. 호스트명을 터널로 향하게 합니다.</strong> 호스트명마다 명령 하나로 <code>&lt;tunnel-id&gt;.cfargotunnel.com</code>을 가리키는 프록시된 CNAME이 만들어집니다.</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">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. ingress 규칙을 씁니다.</strong> 보안에서 핵심이 되는 부분이라 아래에 따로 절을 두었습니다.</p>
<p><strong>5. 실행합니다.</strong> 제 앱은 이미 Docker Compose로 돌고 있어서, <code>cloudflared</code>는 같은 네트워크에 컨테이너 하나를 더 얹는 것뿐입니다. 서비스 이름으로 앱에 닿으니, 앱은 호스트에 포트를 공개할 필요조차 없습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<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">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<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">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>서울로 연결 네 개, 이제 노트북은 바깥에서 안으로 인터넷에 올라와 있습니다. <code>restart: unless-stopped</code> 덕분에 재부팅해도 Docker가 뜨자마자 다시 살아납니다.</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%84%9c%eb%b2%84%ea%b0%80-%ec%95%84%eb%8b%88%eb%9d%bc-%ea%b2%bd%eb%a1%9c%eb%a5%bc-%eb%85%b8%ec%b6%9c%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제가 하마터면 저지를 뻔한 실수이고, 아마 가장 흔한 실수일 겁니다. 가장 쉬운 설정은 한 줄입니다. <code>hostname → http://localhost:8000</code>. 이러면 <strong>개발 서버 전체</strong>가 공개됩니다.</p>
<p>개발 서버는 그러라고 만든 게 아닙니다. 제 개발 서버는 <code>DEBUG=True</code>로 돌아서, 에러가 나면 전체 트레이스백과 설정을 보여줍니다. 인증도 꺼 둔 채로(<code>AUTH_MODE=disabled</code>) 돌아서, 아무 bearer 토큰이나 테스트 사용자로 로그인됩니다. <code>localhost</code>에서는 딱 맞는 설정이고 공개 호스트명에서는 재앙입니다. 여러분 쪽에도 비슷한 게 있을 겁니다. 기본 비밀번호가 걸린 관리자 화면, 시드 데이터 엔드포인트, 디버그 툴바, 아니면 호출하는 사람이 나뿐이라 호출자를 믿어 버리는 API 같은 것들요.</p>
<p>그래서 터널 설정은 허용 목록입니다. <code>cloudflared</code>는 요청마다 ingress 규칙을 순서대로, 호스트명과 경로에 대한 정규식으로 대조합니다. 처음 맞는 규칙이 이기고, 마지막 규칙이 나머지 전부를 받습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</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="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>배포(publish) 엔드포인트는 같은 서버의 같은 접두사 아래(<code>/api/expo-updates/import-update/</code>)에 있지만 목록에 없습니다. 관리자 화면도, 나머지 API도 없습니다. 그런 요청은 Cloudflare를 벗어나지 못합니다.</p>
<p>정규식을 믿을 필요는 없습니다. URL이 어느 규칙에 걸리는지 <code>cloudflared</code>가 알려 줍니다.</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">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<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://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>저라면 이 검사를 스크립트로 만들어 설정을 바꿀 때마다 돌리겠습니다. 몇 초면 끝나고, 의도보다 넓게 매칭되는 정규식은 남이 알려 줘야 비로소 알게 되는 종류의 실수입니다.</p>
<p>경험칙 두 가지를 더 덧붙이면 이렇습니다.</p>
<ul>
<li><strong>노출하는 것은 스스로 인증해야 합니다.</strong> OTA 엔드포인트는 공개된 읽기 전용 데이터를 서빙합니다. 디바이스 체크인은 디바이스별 키로 서명됩니다. 어느 쪽도 안전을 네트워크에 기대지 않습니다. 로그인이 없는 페이지를 누군가에게 보여주고 싶다면, 로그인을 직접 만들지 말고 그 호스트명 앞에 <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a>를 두세요(사용자 50명까지 무료).</li>
<li><strong>터널을 개발 워크플로의 일부로 백그라운드에 상시 띄워 두지 마세요.</strong> 의도한 호스트명을 위해 일부러 켜고, 다 쓰면 끄세요. <code>docker compose stop cloudflared</code> 명령 하나로 전부 인터넷에서 내려갑니다.</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="#%ed%98%b8%ec%8a%a4%ed%8a%b8%eb%aa%85%ec%9d%84-%ec%a7%91%ec%96%b4%ec%82%bc%ed%82%a8-%eb%a6%ac%eb%8b%a4%ec%9d%b4%eb%a0%89%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>터널이 연결되고 규칙도 검증했으니, 바깥에서 시험해 봤습니다.</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://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>모든 경로가 똑같은 301을 돌려줬습니다. 터널이 404로 답했어야 할 경로까지요. 요청이 <code>cloudflared</code>에 아예 닿지 않았다는 뜻입니다. <code>Location</code> 헤더는 <code>https://www.example.com/</code>이었고, <code>server: cloudflare</code> 헤더로 확실해졌습니다. Cloudflare가 스스로 답하고 있었습니다.</p>
<p>제가 자초한 일이었습니다. 몇 달 전 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >존재하지 않는 서브도메인을 훑고 다니는 스캐너를 빨아들이는 싱크홀</a>로 &ldquo;그 밖의 모든 서브도메인 → www&quot;라는 존 단위 리다이렉트 규칙을 추가해 뒀습니다. 리다이렉트 규칙은 요청이 오리진으로 가기 전에 Cloudflare의 요청 파이프라인에서 돌고, 터널은 그저 오리진 하나일 뿐입니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >같은 규칙이 프리뷰 환경도 잡아먹었습니다</a>. Workers가 보기도 전에요. 해결책도 그때와 같습니다. 규칙의 표현식에 예외 하나를 더 넣는 겁니다.</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">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>터널 호스트명이 앱 대신 Cloudflare의 리다이렉트나 에러 페이지로 답한다면, 터널을 디버깅하기 전에 그 존의 <strong>Rules → Redirect Rules</strong>, Page Rules, WAF 커스텀 규칙부터 확인하세요. <code>cloudflared</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="#%ed%95%a0-%ec%88%98-%ec%97%86%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li><strong>공개 인터넷에서 오는 UDP.</strong> 공개 터널 호스트명은 HTTP(S)(그리고 WebSocket과 gRPC)를 실어 나릅니다. 제 제품의 디바이스는 보통 UDP 위의 CoAP로 체크인하는데, 그건 터널을 통과할 수 없습니다. 로컬 사본에 붙은 디바이스는 HTTPS 체크인으로 폴백하고, 그 대가로 체크인마다 몇 초씩 더 듭니다. 아이디어가 게임 서버나 VoIP라면 터널은 맞는 도구가 아닙니다.</li>
<li><strong>가동 시간.</strong> 결국 노트북입니다. 덮개를 닫으면 사이트가 내려가고, Cloudflare가 530 에러 페이지를 보여줍니다. 데모나 열 명짜리 베타, 스테이징 사본이라면 괜찮습니다. 누군가 의존하는 것이라면 괜찮지 않습니다.</li>
<li><strong>빌드에 박혀 들어간 것.</strong> 모바일 앱의 업데이트 URL은 네이티브 설정이라, 터널 URL로 만든 빌드만 그걸 씁니다. 스토어 빌드는 계속 프로덕션과 통신하고, 그래서 이 구성을 안심하고 시도해 볼 수 있었습니다.</li>
</ul>

<h2 class="relative group">비용: Cloudflare Tunnel은 무료인가
    <div id="비용-cloudflare-tunnel은-무료인가" 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%b9%84%ec%9a%a9-cloudflare-tunnel%ec%9d%80-%eb%ac%b4%eb%a3%8c%ec%9d%b8%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>무료 플랜의 Cloudflare 존, 터널, 인증서, DNS 레코드: $0. 머신은 원래 켜져 있었습니다. 진짜 비용은 이제 내가 가동 시간 담당 팀이 된다는 것 하나뿐입니다.</p>
<p>대신 아껴 주는 건 &ldquo;내 머신에서는 잘 돼요&quot;와 &ldquo;돈 내고 호스팅할 가치가 있다&rdquo; 사이의 구간입니다. 테스터나 디자이너, 첫 고객에게 같은 날 오후에 링크를 보낼 수 있습니다. 아무도 관심이 없으면 잃은 게 없습니다. 관심이 있다면, 진짜 호스트로 옮기는 건 변경 하나입니다. CNAME을 다른 곳으로 향하게 하면 됩니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>S3에 직접 업로드하면서 이미지 압축도 놓치지 않기: Tigris 객체 알림과 Django</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/tigris-object-notifications/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/tigris-object-notifications/</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Tigris</category>
      <category>S3</category>
      <category>S3 Event Notifications</category>
      <category>Presigned URLs</category>
      <category>Image Uploads</category>
      <category>Image Compression</category>
      <category>Object Storage</category>
      <category>Webhooks</category>
      <category>Django</category>
      <category>Fly.io</category>
      <category>Discord</category>
      <description>presigned URL로 S3에 직접 업로드하면 빠르지만, 파일이 도착했다는 걸 백엔드는 알 길이 없어서 이미지 압축과 썸네일 처리가 애매해집니다. Tigris 객체 알림이 그 빈틈을 메워 줍니다. 버킷이 Django 웹훅으로 POST를 보내고, 사용자가 아직 글을 쓰는 동안 사진이 처리되죠. 같은 신호로 로그 파일을 Discord 알림으로 바꾸기도 했습니다.</description>
      <content:encoded><![CDATA[<p>제가 만들어 본 이미지 업로드에는 늘 같은 긴장이 있었습니다. 올리는 사람은 사진이 빨리 올라가길 바랍니다. 저는 서버가 그 사진을 압축하고, 썸네일을 만들고, blurhash와 색상 몇 개를 뽑아내고, 있어서는 안 될 메타데이터를 떼어 내고, 앱의 나머지 부분이 기대하는 모양으로 저장해 주길 바라고요. 제 목록을 전부 채우는 가장 쉬운 길은 파일을 API로 보내는 것인데, 바로 그 길이 올리는 사람의 목록을 느리게 만듭니다.</p>
<p>빠른 쪽은 presigned URL(미리 서명된 URL)입니다. API가 URL에 서명해 주면 클라이언트가 파일을 버킷에 바로 PUT하고, 바이트는 API를 전혀 거치지 않습니다. 표준적인 답이고, 그럴 만한 이유가 있습니다. 그런데 이렇게 하는 순간 백엔드는 흐름에서 빠져 버립니다. URL을 하나 내주고 나서는 아무 소식도 못 듣죠. 파일이 도착했는지, 예상한 크기인지, 애초에 JPEG이 맞기는 한지. 누군가는 백엔드에 알려 줘야 하는데, 흔한 선택지는 하나같이 좀 서글픕니다. 클라이언트가 &ldquo;다 됐어요&quot;라고 다시 호출해 주길 믿거나(클라이언트는 거짓말하고, 죽고, 신호가 끊깁니다), 버킷을 주기적으로 폴링하거나(느리고 낭비죠), 첫 읽기 때 느긋하게 처리하거나(첫 방문자가 그 비용을 치르고, 파일을 언제부터 참조해도 안전한지는 여전히 모릅니다). S3 직접 업로드가 실제보다 어려워 보이는 이유가 이겁니다. 업로드 자체는 쉽습니다. 빠진 건 신호입니다.</p>
<p>객체 알림이 바로 그 신호입니다. 제가 Fly.io에서 쓰는 S3 호환 스토리지인 Tigris는 버킷에서 객체가 생성되거나 삭제될 때마다 작은 JSON 본문을 여러분의 앱에 POST해 줍니다. AWS에서 이에 해당하는 것이 S3 이벤트 알림인데, 보통은 Lambda나 SQS 큐, EventBridge를 먼저 엮어야 뭔가가 코드에 닿습니다(SNS가 HTTPS 엔드포인트로 POST를 보내 줄 수는 있지만, 그러면 구독 확인과 서명 검증부터 해야 하죠). Tigris는 이걸 여러분이 소유한 URL로 가는 HTTP 요청 하나로 줄였습니다. 스토리지에서 백엔드로 신호를 보내는 방법으로는 이보다 단순하기 어렵죠. 버킷이 백엔드와 이야기할 수 있도록, 여러분이 그 사이에 다리를 놓지 않아도 되도록 있는 기능입니다.</p>
<p>이 글은 그 흐름 전체를 다룹니다. 설정, 이벤트를 받는 Django 엔드포인트, 서버 쪽 처리를 하나도 포기하지 않는 presigned 업로드, 그리고 함정들(CORS, 크기 제한, 만료, 여러 파일)까지요. 마지막에는 같은 신호로 백오피스 일을 시킨 이야기도 있습니다. 버킷에 떨어지는 로그 파일이 Discord 알림이 됐고, 덕분에 몇 달 동안 달래 가며 쓰던 cron 작업 하나를 지울 수 있었습니다.</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%a7%ec%9d%80-%eb%8b%b5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Tigris 대시보드에서 버킷을 열고 Settings, 그다음 Object notifications로 들어갑니다. 앱의 HTTPS URL을 넣고 Basic 인증을 고릅니다(Tigris가 <code>Authorization</code> 헤더에 담아 보내는 사용자 이름과 비밀번호입니다. 다른 선택지는 bearer 토큰이고요). 관심 있는 키만 듣도록 필터도 하나 겁니다. 필터는 SQL 비슷한 표현식이라서, &ldquo;이 글에서 다루는 프리픽스 세 개만&quot;은 이렇게 씁니다.</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">WHERE</span><span class="w"> </span><span class="o">`</span><span class="k">key</span><span class="o">`</span><span class="w"> </span><span class="n">REGEXP</span><span class="w"> </span><span class="s2">&#34;^(uploads/raw|logs/api|media)/&#34;</span></span></span></code></pre></div></div>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigris 버킷 설정의 Notifications 탭: 객체 알림 켜짐, 웹훅 URL, 사용자 이름과 비밀번호를 쓰는 Basic 인증, SQL 비슷한 키 필터"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp"></span><figcaption>설정은 이게 전부입니다. 앱의 HTTPS URL, Basic 인증, 그리고 키 필터. 여기 보이는 버킷 이름과 URL은 예시로 바꿔 둔 것입니다.</figcaption></figure>
</p>
<p>그러면 Tigris가 변경이 있을 때마다 이런 배치를 보냅니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;events&#34;</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="nt">&#34;eventName&#34;</span><span class="p">:</span> <span class="s2">&#34;OBJECT_CREATED_PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventTime&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-11-07T09:12:44Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;example-bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;uploads/raw/1f3c.jpg&#34;</span><span class="p">,</span> <span class="nt">&#34;size&#34;</span><span class="p">:</span> <span class="mi">48213</span><span class="p">,</span> <span class="nt">&#34;eTag&#34;</span><span class="p">:</span> <span class="s2">&#34;9b2c…&#34;</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 class="p">}</span></span></span></code></pre></div></div>
<p>마주치게 될 이벤트 이름은 <code>OBJECT_CREATED_PUT</code>, <code>OBJECT_CREATED_MULTIPART</code>, <code>OBJECT_CREATED_COPY</code>, <code>OBJECT_DELETED</code>이고, 이름 변경과 스토리지 계층 전환 이벤트도 있습니다. 엔드포인트는 인증을 확인하고, 이벤트마다 키 프리픽스로 라우팅한 뒤, 10초 안에 2xx를 돌려주면 됩니다. 처음부터 염두에 둘 것이 세 가지 있습니다. 전달은 최소 한 번(at least once)이라 같은 이벤트를 다시 보게 되고, 이벤트는 배치로 오기 때문에 요청 하나에 여러 개가 실릴 수 있으며, 순서가 뒤바뀌어 도착할 수도 있으니 도착한 순서로 &ldquo;최신&quot;을 판단하면 안 됩니다.</p>

<h2 class="relative group">버킷 이벤트를 받는 Django 웹훅
    <div id="버킷-이벤트를-받는-django-웹훅" 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%ed%82%b7-%ec%9d%b4%eb%b2%a4%ed%8a%b8%eb%a5%bc-%eb%b0%9b%eb%8a%94-django-%ec%9b%b9%ed%9b%85" aria-label="앵커">#</a>
    </span>
    
</h2>
<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="kn">import</span> <span class="nn">base64</span><span class="o">,</span> <span class="nn">hmac</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.http</span> <span class="kn">import</span> <span class="n">JsonResponse</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.csrf</span> <span class="kn">import</span> <span class="n">csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.http</span> <span class="kn">import</span> <span class="n">require_POST</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">.bucket_events</span> <span class="kn">import</span> <span class="n">handle</span><span class="p">,</span> <span class="n">parse</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">header</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Authorization&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</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">header</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;Basic &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</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="n">user</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">password</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="mi">6</span><span class="p">:])</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">user</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="sa">b</span><span class="s2">&#34;tigris&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">password</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">secret</span><span class="o">.</span><span class="n">encode</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></span><span class="line"><span class="cl"><span class="nd">@csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="nd">@require_POST</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket_webhook</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">secret</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BUCKET_WEBHOOK_SECRET</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;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">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;unauthorised&#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="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">body</span> <span class="ow">or</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;invalid json&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">400</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">event</span> <span class="ow">in</span> <span class="n">parse</span><span class="p">(</span><span class="n">body</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="n">handle</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;bucket event failed: </span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">})</span></span></span></code></pre></div></div>
<p><code>compare_digest</code>를 쓴 건 무엇을 보내든 확인에 걸리는 시간이 같게 하려는 겁니다(바이트로 비교하는 이유는, 문자열로 비교하면 ASCII가 아닌 입력에서 예외가 나서 조작된 헤더가 401 대신 500을 받기 때문입니다). 갓 만든 스테이징 서버처럼 시크릿이 설정돼 있지 않으면, 엔드포인트는 전부 받아들이는 대신 전부 거부합니다. 그리고 핸들러가 예외를 던져도 로그만 남기고 200을 돌려줍니다. Tigris는 2xx가 아니면 전부 재시도하는데, 재시도해 봐야 같은 버그에 부딪힐 테고, 그때쯤이면 이벤트는 이미 선점돼 있어서(두 섹션 아래에 나옵니다) 재시도가 아무 일도 하지 않을 테니까요. 고치게 만드는 건 그 로그 한 줄입니다. 다만 이건 실패가 매번 똑같이 재현된다는 가정 위에 서 있습니다. 스토리지가 잠깐 타임아웃된 거라면 그 이벤트는 영영 소비돼 버립니다. 사진은 상관없습니다. 저장 요청이 직접 처리할 수 있으니까요. 하지만 로그 파일 쪽 알림은 그대로 잃어버리게 됩니다. 그게 중요하다면 <code>except</code>에서 선점을 풀고 500을 돌려줘 Tigris가 재시도하게 하세요. 대신 진짜 버그는 고칠 때까지 계속 재시도된다는 건 감수해야 하고요.</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%94%84%eb%a6%ac%ed%94%bd%ec%8a%a4%eb%a1%9c-%eb%9d%bc%ec%9a%b0%ed%8c%85%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<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="n">HANDLERS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;uploads/raw/&#34;</span><span class="p">,</span>   <span class="n">on_raw_photo</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;logs/api/&#34;</span><span class="p">,</span>      <span class="n">on_api_log_file</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;media/&#34;</span><span class="p">,</span>         <span class="n">on_media_change</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></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">HANDLERS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">prefix</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">claim</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>  <span class="c1"># 아래 참고</span>
</span></span><span class="line"><span class="cl">                <span class="n">fn</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span></span></span></code></pre></div></div>
<p>나중에 네 번째 용도를 추가하려면 함수 하나와 행 하나만 더하면 됩니다. 이게 생각보다 마음에 들더군요.</p>

<h2 class="relative group">최소 한 번은 &ldquo;두 번일 수도 있다&quot;는 뜻
    <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%b5%9c%ec%86%8c-%ed%95%9c-%eb%b2%88%ec%9d%80-%eb%91%90-%eb%b2%88%ec%9d%bc-%ec%88%98%eb%8f%84-%ec%9e%88%eb%8b%a4%eb%8a%94-%eb%9c%bb" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>엔드포인트가 느리거나 에러를 돌려주면 Tigris는 재시도합니다. 그리고 대부분의 웹훅 발신자가 그렇듯, 이벤트를 딱 한 번만 받는다는 보장은 해 주지 않습니다. 그래서 핸들러가 돌기 전에 <code>(key, eTag, eventName)</code> 기준으로 이벤트를 선점합니다. 여기서 eTag가 중요합니다. 파일이 덮어써지면 eTag가 바뀐 새 버전이니 다시 처리해야 합니다. 같은 버전이 반복해서 온 거라면 처리하면 안 되고요.</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="kn">from</span> <span class="nn">django.core.cache</span> <span class="kn">import</span> <span class="n">cache</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">k</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;bucket-event:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">etag</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">ttl</span><span class="p">)</span>  <span class="c1"># 이미 누가 선점했으면 False</span></span></span></code></pre></div></div>
<p><code>cache.add</code>는 키가 없을 때만 값을 넣고, Redis에서는 이게 원자적으로 처리됩니다. 그래서 같은 재시도를 받은 머신 두 대가 둘 다 이길 수는 없습니다. 실제 코드에서는 Redis가 비워져도 살아남게 하고 싶어서 데이터베이스 기반 리스(lease)를 쓰지만, 아이디어는 똑같습니다.</p>

<h2 class="relative group">이미지는 S3에 직접 업로드, 파이프라인은 그대로
    <div id="이미지는-s3에-직접-업로드-파이프라인은-그대로" 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%9d%b4%eb%af%b8%ec%a7%80%eb%8a%94-s3%ec%97%90-%ec%a7%81%ec%a0%91-%ec%97%85%eb%a1%9c%eb%93%9c-%ed%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8%ec%9d%80-%ea%b7%b8%eb%8c%80%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 배관 작업이 제값을 하는 부분이 여기입니다.</p>
<p>예전에는 게시글에 넣을 사진을 고르면 앱이 들고 있다가, &lsquo;게시&rsquo;를 누르는 순간 전부 base64로 API에 보냈습니다. API가 리사이즈하고, 썸네일을 만들고, 색상과 blurhash를 계산한 다음에야 응답했죠. 느린 회선에서 사진 열 장이면 스피너가 한참 돌았습니다. 그리고 base64 사진 열 장을 실은 요청은 API 서버가 메모리에 들고 있기엔 크고 느린 본문이라, 사실 제가 사진을 작게 유지해 온 조용한 이유도 그거였습니다.</p>
<p>이제는 네 단계로 나뉘고, 백엔드는 단계마다 소식을 듣습니다.</p>

<h3 class="relative group">1. 클라이언트가 사진을 줄이고 슬롯을 요청한다
    <div id="1-클라이언트가-사진을-줄이고-슬롯을-요청한다" 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="#1-%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8%ea%b0%80-%ec%82%ac%ec%a7%84%ec%9d%84-%ec%a4%84%ec%9d%b4%ea%b3%a0-%ec%8a%ac%eb%a1%af%ec%9d%84-%ec%9a%94%ec%b2%ad%ed%95%9c%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>앱은 여전히 기기에서 리사이즈합니다. 서버가 보관하는 긴 변 길이(1080px)에 맞춰 품질 0.7의 JPEG로요. 이 리사이즈는 오로지 사용자의 대역폭을 위한 것이고, 다른 이유는 없습니다. 그다음 API에 업로드 슬롯을 요청합니다.</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="kn">import</span> <span class="nn">uuid</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span><span class="s2">&#34;s3&#34;</span><span class="p">,</span> <span class="n">endpoint_url</span><span class="o">=</span><span class="s2">&#34;https://fly.storage.tigris.dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET</span> <span class="o">=</span> <span class="s2">&#34;example-bucket&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_upload_slot</span><span class="p">(</span><span class="n">user</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">public_id</span> <span class="o">=</span> <span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;uploads/raw/</span><span class="si">{</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.jpg&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">public_id</span><span class="o">=</span><span class="n">public_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_key</span><span class="o">=</span><span class="n">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">expires_at</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">2</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="n">url</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;put_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">BUCKET</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">key</span><span class="p">,</span> <span class="s2">&#34;ContentType&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">15</span> <span class="o">*</span> <span class="mi">60</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="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;upload_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;url&#34;</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;method&#34;</span><span class="p">:</span> <span class="s2">&#34;PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;headers&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;expires_in&#34;</span><span class="p">:</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span></span></span></code></pre></div></div>
<p>슬롯은 파일이기 전에 먼저 행(row)입니다. 그 덕분에 서버는 누가 요청했는지, 언제 만료되는지, 나중에는 실제로 쓰였는지까지 알 수 있습니다.</p>

<h3 class="relative group">2. 클라이언트가 버킷에 바로 PUT한다
    <div id="2-클라이언트가-버킷에-바로-put한다" 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="#2-%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8%ea%b0%80-%eb%b2%84%ed%82%b7%ec%97%90-%eb%b0%94%eb%a1%9c-put%ed%95%9c%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<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">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">api</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="s2">&#34;/photo-uploads/&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">slot</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">method</span>: <span class="kt">slot.method</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">headers</span>: <span class="kt">slot.headers</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">body</span>: <span class="kt">blob</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">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;upload refused&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// 저장할 때 쓰도록 slot.upload_id를 보관
</span></span></span></code></pre></div></div>
<p>API는 바이트를 구경도 못 합니다. 브라우저든 React Native든 네이티브 앱이든 똑같고, 유일한 차이는 브라우저에는 버킷에 CORS 규칙이 필요하다는 점입니다. 아래 함정 섹션에 있습니다.</p>

<h3 class="relative group">3. Tigris가 백엔드에 알리고, 파이프라인이 돈다
    <div id="3-tigris가-백엔드에-알리고-파이프라인이-돈다" 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="#3-tigris%ea%b0%80-%eb%b0%b1%ec%97%94%eb%93%9c%ec%97%90-%ec%95%8c%eb%a6%ac%ea%b3%a0-%ed%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8%ec%9d%b4-%eb%8f%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>몇 초 안에 Tigris가 <code>uploads/raw/…</code>에 대한 <code>OBJECT_CREATED_PUT</code>을 보내고, 핸들러는 base64 경로가 늘 쓰던 것과 같은 이미지 파이프라인을 돌립니다. 사용자가 아직 제목을 입력하는 동안에요.</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="kn">from</span> <span class="nn">io</span> <span class="kn">import</span> <span class="n">BytesIO</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span><span class="p">,</span> <span class="n">ImageOps</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.core.files.base</span> <span class="kn">import</span> <span class="n">ContentFile</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">blurhash</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">MAX_BYTES</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">on_raw_photo</span><span class="p">(</span><span class="n">event</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">event</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;OBJECT_CREATED&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">MAX_BYTES</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">raw_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">,</span> <span class="n">processed_at</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">upload</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="n">longest</span><span class="p">,</span> <span class="n">quality</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="n">longest</span><span class="p">,</span> <span class="n">longest</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span> <span class="o">=</span> <span class="n">BytesIO</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="s2">&#34;WEBP&#34;</span><span class="p">,</span> <span class="n">quality</span><span class="o">=</span><span class="n">quality</span><span class="p">,</span> <span class="n">icc_profile</span><span class="o">=</span><span class="n">im</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;icc_profile&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">ContentFile</span><span class="p">(</span><span class="n">buf</span><span class="o">.</span><span class="n">getvalue</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">Image</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">data</span><span class="p">))</span> <span class="k">as</span> <span class="n">im</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span> <span class="o">=</span> <span class="n">ImageOps</span><span class="o">.</span><span class="n">exif_transpose</span><span class="p">(</span><span class="n">im</span><span class="p">)</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGB&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="mi">1080</span><span class="p">,</span> <span class="mi">1080</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">width</span><span class="p">,</span> <span class="n">upload</span><span class="o">.</span><span class="n">height</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">dominant_color</span> <span class="o">=</span> <span class="s2">&#34;#</span><span class="si">%02x%02x%02x</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span><span class="o">.</span><span class="n">getpixel</span><span class="p">((</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">image</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">1080</span><span class="p">,</span> <span class="mi">80</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">thumbnail</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">_t.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">320</span><span class="p">,</span> <span class="mi">70</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">blurhash</span> <span class="o">=</span> <span class="n">blurhash</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">)),</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">save</span><span class="p">()</span></span></span></code></pre></div></div>
<p><code>on_raw_photo</code>의 첫 줄은 보기보다 중요합니다. 라우터는 <code>uploads/raw/</code> 아래의 모든 이벤트를 여기로 보내는데, 고아 정리 작업과 크기 검사 자체가 만들어 내는 삭제 이벤트까지 포함해서요. 이미 사라진 키에 <code>get_object</code>를 하면 예외만 날 뿐입니다.</p>
<p>그러니 클라이언트에서 줄인 건 처음부터 끝까지 전송로를 위한 것이었습니다. 포맷(WebP), 크기, 썸네일, 플레이스홀더, 저장할 치수는 여전히 서버가 정합니다. Pillow로 다시 인코딩하면 EXIF 블록도 떨어져 나가는데, 휴대폰 카메라가 GPS 위치를 넣어 두는 곳이 바로 거기라서 개인정보 제거는 덤으로 따라옵니다. ICC 프로필도 같은 식으로 사라질 텐데, 그러면 광색역(wide-gamut) 휴대폰 사진의 색이 살짝 틀어지기 때문에 <code>to_webp</code>는 이걸 그대로 넘겨 줍니다. 픽셀 하나 손대지 않은 원본을 보관하고 싶다면 클라이언트에서 줄이지 않으면 됩니다. 흐름은 똑같고 PUT만 커지는데, 큰 PUT이야말로 이 설계가 싸게 만들어 주는 바로 그것이니까요. 한 가지 걸리는 점은, iPhone 원본은 HEIC라서 Pillow가 <code>pillow-heif</code>를 설치해야만 열 수 있다는 겁니다.</p>

<h3 class="relative group">4. 저장은 사진을 붙이기만 하고, 웹훅을 기다리지 않는다
    <div id="4-저장은-사진을-붙이기만-하고-웹훅을-기다리지-않는다" 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="#4-%ec%a0%80%ec%9e%a5%ec%9d%80-%ec%82%ac%ec%a7%84%ec%9d%84-%eb%b6%99%ec%9d%b4%ea%b8%b0%eb%a7%8c-%ed%95%98%ea%b3%a0-%ec%9b%b9%ed%9b%85%ec%9d%84-%ea%b8%b0%eb%8b%a4%eb%a6%ac%ec%a7%80-%ec%95%8a%eb%8a%94%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>&lsquo;게시&rsquo;를 누르면 요청에는 이미지 데이터 대신 <code>upload_ids</code>가 실려 갑니다. 서버는 이를 하나씩 찾아보고, 결과는 세 가지 중 하나입니다.</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">take</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">upload_ids</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploads</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span> <span class="n">public_id__in</span><span class="o">=</span><span class="n">upload_ids</span><span class="p">,</span> <span class="n">expires_at__gt</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</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">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">uploads</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)):</span>     <span class="c1"># 만료됐거나 이 사용자의 것이 아님</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">upload</span> <span class="ow">in</span> <span class="n">uploads</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>          <span class="c1"># 웹훅이 아직 도착하지 않음</span>
</span></span><span class="line"><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">read_raw</span><span class="p">(</span><span class="n">upload</span><span class="p">)</span>              <span class="c1"># PUT이 안 됐으면 None</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">data</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">uploads</span></span></span></code></pre></div></div>
<p>알림이 이미 처리를 마쳤다면 처리된 파일을 그대로 붙입니다. 파일은 버킷에 있는데 알림이 아직 안 왔다면, 저장 요청이 그 자리에서 인라인으로 처리합니다. 그래서 게시글이 웹훅을 기다리는 일은 없습니다. 알림은 의존성이 아니라 먼저 출발할 기회일 뿐이죠. 파일이 아예 없거나, id가 만료됐거나 다른 사람의 것이라면 저장은 검증 에러를 돌려주고 앱이 잠시 후 재시도합니다. 앱은 자기 PUT이 끝나길 기다린 뒤에 보내니까, 실제로는 드문 일입니다.</p>
<p>제대로 버티게 해 준 디테일이 몇 가지 있습니다. 슬롯은 사용자 소유라서 서버는 같은 사람이 만든 업로드 id만 받습니다. 그렇지 않으면 id를 본 누구든 남의 사진을 자기 게시글에 붙일 수 있으니까요. 모든 경로에 대체 수단이 있습니다. 슬롯 요청이 실패하거나, PUT이 거부되거나, API가 구버전이면 앱은 예전과 똑같이 base64를 보냅니다. 업로드 경로를 타는 건 새 사진 전부에 슬롯이 있을 때뿐이라서, 게시글 하나의 사진은 전부 업로드이거나 전부 base64이고, 순서도 목록 하나로 유지됩니다. 그리고 고아 업로드는 만료됩니다. 사진을 골라 놓고 그만두는 사람도 있어서, 어디에도 붙지 않은 업로드는 이틀 뒤에 만료되고 기존의 고아 미디어 정리 작업이 파일을 치웁니다.</p>

<h2 class="relative group">presigned URL 업로드의 함정: CORS, 크기 제한, 만료, 여러 파일
    <div id="presigned-url-업로드의-함정-cors-크기-제한-만료-여러-파일" 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="#presigned-url-%ec%97%85%eb%a1%9c%eb%93%9c%ec%9d%98-%ed%95%a8%ec%a0%95-cors-%ed%81%ac%ea%b8%b0-%ec%a0%9c%ed%95%9c-%eb%a7%8c%eb%a3%8c-%ec%97%ac%eb%9f%ac-%ed%8c%8c%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>presigned 업로드에서 꼬이는 일은 대부분 알림이 나가기도 전에 꼬입니다. 한 페이지에 모아 두고 싶었던 것들입니다.</p>

<h3 class="relative group">CORS, 브라우저에만 해당
    <div id="cors-브라우저에만-해당" 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="#cors-%eb%b8%8c%eb%9d%bc%ec%9a%b0%ec%a0%80%ec%97%90%eb%a7%8c-%ed%95%b4%eb%8b%b9" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>네이티브 앱은 신경 쓸 일이 없습니다. 브라우저는 PUT 전에 버킷으로 preflight <code>OPTIONS</code>를 보내는데, 버킷의 CORS 규칙이 여러분의 오리진에서 오는, <code>Content-Type</code> 헤더가 붙은 <code>PUT</code>을 허용하지 않으면 업로드는 CORS 에러로 실패합니다. Tigris는 표준 S3 CORS 설정을 그대로 받으니 규칙 모양은 AWS와 같습니다. presigned PUT이 <code>curl</code>에서는 되고 페이지에서는 안 될 때 제일 먼저 확인할 것이 이겁니다.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigris 버킷 설정의 CORS and headers 탭: 두 오리진에서 오는 PUT을 Content-Type 헤더와 함께 허용하는 규칙 하나"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp"></span><figcaption>브라우저 업로드에 필요한 CORS 규칙은 하나면 됩니다. 여러분의 오리진, PUT, 그리고 서명이 기대하는 Content-Type 헤더.</figcaption></figure>
</p>

<h3 class="relative group">Content-Type은 서명의 일부다
    <div id="content-type은-서명의-일부다" 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="#content-type%ec%9d%80-%ec%84%9c%eb%aa%85%ec%9d%98-%ec%9d%bc%eb%b6%80%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>ContentType</code>을 넣어 서명하면 클라이언트는 정확히 그 헤더를 보내야 하고, 아니면 버킷이 403 <code>SignatureDoesNotMatch</code>로 답합니다. 슬롯이 URL과 함께 헤더까지 돌려주는 이유가 그겁니다. 일부러 넣어서 서명하되, 그게 어디까지 막아 주는지는 알아 두세요. 강제되는 건 헤더이지 내용이 아니라서, <code>image/jpeg</code>라고 이름표를 붙인 PDF는 통과합니다. 진짜 검사는 파이프라인입니다. Pillow는 이미지가 아닌 것은 열기를 거부하고, 행은 끝내 처리됨으로 표시되지 않습니다.</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="#%ed%81%ac%ea%b8%b0-%ec%a0%9c%ed%95%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>presigned PUT URL에는 크기 제한이 없습니다. 바이트가 떨어지기 전에 확실히 막아야 한다면 <code>content-length-range</code> 정책 조건을 건 presigned POST가 그 일을 합니다. 저는 굳이 그러지 않았습니다. 알림에 객체의 <code>size</code>가 실려 오니까요. 핸들러는 제한을 넘는 것은 읽기 전에 지워 버리고, 저장 시점 경로는 많아야 제한에 1바이트를 더한 만큼만 읽어서 넘었는지 판별합니다. 앱이 보내는 건 몇백 KB입니다. 15MB 근처라면 앱이 보낸 게 아닙니다.</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="#%eb%a7%8c%eb%a3%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>시계가 두 개입니다. presigned URL은 15분 동안 유효한데, PUT 한 번에는 충분하고 새어 나간 URL이 금방 쓸모없어질 만큼 짧습니다. 슬롯 행은 이틀 동안 유효해서, 사진을 골라 놓고 휴대폰을 내려놨다가 다음 날 게시글을 마무리하는 경우까지 감당합니다. 클라이언트는 하루가 지난 미완료 업로드는 낡은 것으로 보고, 요행을 바라는 대신 새로 시작합니다.</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%97%ac%eb%9f%ac-%ed%8c%8c%ec%9d%bc-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%8d%94-%ed%81%b0-%ed%8c%8c%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>파일 하나에 슬롯 하나입니다. 사진 열 장이면 슬롯 열 개와 병렬 PUT 열 개이고, 각각 진행률이 따로 있습니다(<code>XMLHttpRequest</code>는 <code>upload.onprogress</code>를 주고, <code>fetch</code>는 주지 않습니다). 거대한 base64 POST 하나로는 얻을 수 없는 것이죠. 몇 MB를 넘어가면 멀티파트를 쓰세요(파트 하나가 최소 5 MiB라서, 그보다 한참 큰 파일에 쓰는 겁니다). 서버에서 멀티파트 업로드를 만들고, <code>upload_part</code>마다 presign한 뒤, 파트가 다 들어오면 완료하면 됩니다. 업로드가 완료되면 Tigris가 <code>OBJECT_CREATED_MULTIPART</code>를 보내니 같은 핸들러가 돌고 다른 건 아무것도 바뀌지 않습니다.</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%88%9c%ec%84%9c%ea%b0%80-%eb%92%a4%eb%b0%94%eb%80%94-%eb%95%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>객체가 둘 이상의 리전에 쓰였을 수 있기 때문에 이벤트는 순서가 뒤바뀌어 도착할 수 있습니다. 업로드에는 상관없습니다. 키 하나에 이벤트 하나니까요. 덮어써지는 키라면 웹훅이 도착한 순서가 아니라 eTag와 객체의 <code>Last-Modified</code>를 믿으세요.</p>

<h2 class="relative group">같은 신호를 로그에도: Discord 알림
    <div id="같은-신호를-로그에도-discord-알림" 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%99%ec%9d%80-%ec%8b%a0%ed%98%b8%eb%a5%bc-%eb%a1%9c%ea%b7%b8%ec%97%90%eb%8f%84-discord-%ec%95%8c%eb%a6%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>버킷이 백엔드와 이야기할 수 있게 되면, 버킷에 떨어지는 무엇에든 같은 방식으로 반응할 수 있습니다. 제가 두 번째로 겨냥한 것은 로그였습니다.</p>
<p>앱 로그는 작은 gzip 파일로 버킷에 실려 옵니다. 앱마다 대략 5분에 하나씩이죠. 올해 대부분 동안은 15분 주기 cron으로 도는 GitHub Action이 DuckDB로 그 아카이브의 최근 20분을 읽어서 느린 요청 줄을 찾아 Discord에 요약을 올리고 있었습니다. 서류상으로는 문제없습니다. 그런데 실제로는 플랫폼이 바쁠 때 GitHub의 예약 워크플로가 제시간에 돌지 않습니다. 그래서 &ldquo;15분마다&quot;는 사실상 &ldquo;몇 시간마다&quot;에 가까웠고, 겨우 돌 때쯤엔 씹어야 할 구간이 커져 있어서 가끔 메모리가 바닥났습니다. 실행 사이에 빠지는 게 없도록 구간을 겹치게 잡아 뒀더니 같은 느린 요청이 두 번 올라오는 일도 생겼고요. &ldquo;요청이 느리면 알려 줘&rdquo; 하나 하자고 돌리기엔 장치가 너무 많았습니다.</p>
<p>이제는 로그 파일이 도착하는 순간 한 번씩만 읽습니다. gzip으로 몇 KB밖에 안 되는 파일들이죠. 핸들러가 느린 요청 줄을 뽑아내고, 그런 줄이 충분히 많거나 정말 느린 게 하나라도 있으면 Discord 웹훅으로 채널에 요약을 올립니다. 한 파일 안에 5xx 응답이 몰려 있을 때도 마찬가지입니다. Python에서 Discord 웹훅을 쏘는 건 호출 한 번입니다.</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="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discord_alert</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">settings</span><span class="o">.</span><span class="n">DISCORD_WEBHOOK_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">1900</span><span class="p">]},</span>  <span class="c1"># Discord 메시지 상한은 2000자</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>cron도 없고, 구간 겹침도 없고, 중복도 없습니다. 알림은 GitHub가 마음 내킬 때가 아니라 요청이 있고 몇 분 안에 도착합니다. 그 Action은 사라졌습니다.</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%82%ad%ec%a0%9c-%ea%b0%90%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이건 계획한 게 아닙니다. <code>OBJECT_DELETED</code> 이벤트가 생기니 저절로 따라 나온 거죠. 미디어 파일이 삭제되면 핸들러가 아직 그 파일을 가리키는 행이 있는지 확인합니다. 있다면 어딘가에 버그가 있는 거라서(아니면 버킷 콘솔에서 손이 미끄러진 사람이 있거나요), 해당 컬럼을 비워 앱이 깨진 이미지 대신 플레이스홀더를 보여 주게 하고, 보고를 올립니다.</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%8c%ec%95%84-%eb%91%90%eb%a9%b4-%ec%a2%8b%ec%9d%80-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>빨리 응답하세요. Tigris는 전달 한 번에 10초를 줍니다. 5 KB짜리 로그 파일을 읽거나 휴대폰 사진 한 장을 처리하는 건 그 안에 들어갑니다. 더 걸릴 수 있는 건 큐로 보내고 뷰는 200을 돌려주세요. 안 그러면 Tigris가 타임아웃 후 재시도하고, 다시 중복 제거 문제로 돌아가게 됩니다.</p>
<p>버킷 단에서 거르세요. 제 로그 수집기는 앱마다 몇 분에 한 번씩, 끝없이 파일을 씁니다. 관심 있는 프리픽스가 하나뿐이라면 알림 필터에 그렇게 적어서, 나머지 일로 앱이 깨어나지 않게 하세요.</p>
<p>가짜 본문으로 테스트하세요. 페이로드가 작고 안정적이라서, 테스트는 올바른 <code>Authorization</code> 헤더와 함께 JSON을 뷰에 POST하고 핸들러가 한 일을 확인하기만 하면 됩니다. 실제 스토리지는 건드리지 않습니다.</p>
<p>첫날부터 모든 걸 걸지는 마세요. 여기 나온 용도는 전부 대체 수단이 있습니다. 저장이 사진을 직접 처리할 수 있고, 일일 요약은 여전히 아카이브를 훑을 수 있고, 앱은 여전히 base64를 보낼 수 있습니다. 알림 덕분에 하나하나 빨라지고 저렴해졌지만, 웹훅 하나가 빠졌다고 무너지는 건 없습니다.</p>
<p>이번 주의 백미는 그 워크플로 파일을 지운 순간이었습니다. YAML 40줄, Python 스크립트 하나, 저장소 시크릿 두 개, 그리고 주기적으로 찾아오던 &ldquo;알림이 왜 안 왔지?&ldquo;가 전부 사라졌고, 그 자리를 작은 파일 하나가 도착하면 그걸 읽는 핸들러 하나가 대신하고 있습니다. 그래도 핵심은 업로드였습니다. 사진은 네트워크가 허락하는 만큼 빠르게 올라가고, 서버는 여전히 사진 하나하나에 대해 마지막 결정권을 쥐고 있으며, 버킷과 백엔드 사이에 다리를 놓을 필요는 아무에게도 없었습니다. 이제는 버킷이 무슨 일이 있었는지 그냥 말해 주니까요.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>pytest-testmon: 변경된 코드에 영향받는 테스트만 실행하기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmon은 변경이 닿는 테스트만, 짐작이 아니라 커버리지 기록으로 골라 돌립니다. 테스트 2,248개짜리 Django 스위트에 넣은 방법, 필요한 래퍼, Python 3.14의 함정.</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a>은 변경 사항이 영향을 줄 수 있는 테스트만 실행하는 pytest 플러그인입니다. coverage.py로 각 테스트가 어떤 코드 줄을 실행하는지 지켜보고, 테스트가 거쳐 간 코드 블록마다 체크섬을 저장해 둡니다. 다음 실행에서는 그 체크섬을 디스크의 코드와 비교해서, 블록이 바뀐 테스트와 지난번에 실패한 테스트만 돌립니다. <code>pytest --lf</code>가 하던 일까지 겸하는 셈이죠.</p>
<p>이런 기법을 통틀어 테스트 영향 분석(test impact analysis)이라고 하는데, testmon은 Python에서 이걸 무료 오픈소스로 쓰는 방법입니다. git과는 아무 상관이 없습니다. 커밋을 했든 안 했든, 지난 실행에서 본 것과 비교할 뿐입니다.</p>
<p>이번 주에 제 마켓플레이스 앱의 Python 테스트에 이걸 넣었습니다. 속도 자체가 목적은 아니었습니다.</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="이 글을 내레이션으로 소개하는 짧은 영상">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어" default>
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>이 글을 약 8분짜리 영상으로 만들었습니다. 내레이션은 영어이고 한국어 자막이 나옵니다.</figcaption>
</figure>


<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%a3%ec%9d%80-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>기능 요청은 대개 작습니다. 화면 하나, 엔드포인트 하나, 다르게 동작해야 하는 규칙 하나. 그런데 테스트 스위트를 통째로 돌리다 보니 요청마다 실제보다 커졌습니다. 스위트는 Django 테스트 2,248개로, 프로세스 넷이면 3분 반쯤, 하나면 11분이 걸립니다. 그런데 수정할 때마다 이걸 전부 돌렸습니다. 그 거의 전부가 요청과는 아무 상관 없는 것을 확인하는데도요. 상관없는 테스트가 실패하거나 그냥 실행이 오래 걸리면 기능은 기다려야 했습니다. 기능 요청과 무관한 사정에 발이 묶인 건데, 발목을 잡은 게 다름 아닌 제 테스트 스위트였죠.</p>
<p>그렇다고 다른 길이 더 나은 것도 아니었습니다. 전체 실행을 건너뛰고, 변경을 다루는 테스트 파일이 어느 건지 짐작하고, 가끔은 틀리는 겁니다. 빠르긴 해도 정확하지는 않습니다. 그리고 테스트 없이 내보낸 변경은 같은 기능 요청을 두 번 처리하게 만듭니다.</p>
<p>제가 기다리는 테스트는 변경이 닿을 수 있는 테스트와 정확히 같아야 합니다. 더도 덜도 말고요. testmon은 그 집합을 짐작이 아니라 기록으로 줍니다. 전체 스위트는 여전히 CI에서 돕니다. 거기서는 제가 다음에 할 작업을 붙잡지 않습니다.</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%8f%8c%eb%a6%b4-%ed%85%8c%ec%8a%a4%ed%8a%b8%eb%a5%bc-%ea%b3%a0%eb%a5%b4%eb%8a%94-%eb%b0%a9%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon은 Python 파일 하나를 여러 블록으로 나눕니다. 모듈 최상위, 각 클래스, 각 함수가 블록이 되고, 중첩 함수의 본문은 바깥 블록에서 잘라 냅니다. 저장하는 건 블록마다의 체크섬이고, 코드 자체는 저장하지 않습니다. 테스트가 도는 동안 coverage.py가 실행된 줄을 기록하면, testmon은 그 줄이 어느 블록에 속하는지 적어 둡니다. 정적 분석도 없고, import를 보고 짐작하지도 않습니다. 테스트는 정확히 자기가 실행한 블록에만 의존합니다.</p>
<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="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p><code>subtotal()</code>만 호출하는 테스트는 모듈 최상위와 <code>subtotal</code>의 본문에 의존합니다. <code>discount()</code>를 어떻게 고쳐 쓰든 testmon은 그 테스트를 다시 돌리지 않습니다. <code>discount</code>의 줄을 하나도 실행한 적이 없으니까요. 반대로 <code>subtotal</code> 안에서 글자 하나만 바꿔도, 결과가 달라질 수 없는 변경이라 해도 테스트는 다시 돕니다. testmon은 부작용을 두고 머리를 굴리지 않습니다. 실행한 블록이 바뀌었으면 다시 돌 뿐입니다.</p>
<p>코드를 둘러싼 환경도 추적합니다. 기록에는 Python 버전, 그리고 설치된 패키지와 그 버전이 들어갑니다. 환경 변수는 보지 않습니다. 같은 코드를 설정 모듈 두 개로 돌린다면 <code>--testmon-env</code>(8단계)로 이름을 붙여 주지 않으면 기록 하나를 같이 쓰게 됩니다. Django를 업그레이드하면 Django를 건드린 테스트가 전부 다시 영향을 받는데, Django 앱에서는 그게 곧 전부입니다.</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%b3%b4%ec%a7%80-%eb%aa%bb%ed%95%98%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon이 추적하는 건 테스트가 실행하는 Python입니다. 테스트가 읽는 다른 파일, 그러니까 템플릿, JSON, 텍스트, 그 밖에 디스크에 있는 무엇이든 추적하지 않습니다. 네트워크 너머의 서비스도 추적하지 않습니다. 그리고 테스트가 시작되기 전에 한 번 실행되는 것은 어느 특정 테스트에도 묶을 수 없습니다.</p>
<p>Django 프로젝트라면 우선 마이그레이션입니다. 테스트 데이터베이스를 만들 때 한 번 실행되기 때문에 어느 테스트의 기록에도 들어가지 않습니다(아래 래퍼는 새 마이그레이션이 생기면 테스트 데이터베이스를 다시 만듭니다). 다음은 설정과 시작 코드로, pytest가 시작하는 동안 어느 한 테스트의 바깥에서 실행됩니다. 그리고 템플릿과 데이터 파일입니다. 템플릿은 Python이 아니라서, 고쳐도 그걸 렌더링하는 테스트가 하나도 다시 돌지 않습니다.</p>
<p>제 스위트에는 이런 게 몇 가지 더 있습니다. 어떤 테스트는 모바일 앱의 테스트와 함께 쓰는 픽스처 JSON 파일을 읽습니다. 어떤 테스트는 빌드된 웹 번들을 확인하고, 또 어떤 테스트는 같은 저장소에서 생성하는 정적 사이트의 결과물을 확인합니다. 이 중 무엇을 바꾸든 testmon은 태연하게 돌릴 게 없다고 할 겁니다. 아래 방법에서 testmon을 스크립트로 감싸고, 이런 파일이 바뀌면 알아채서 전부 돌리게 하는 이유가 이겁니다.</p>

<h2 class="relative group">Django 프로젝트에 넣는 방법
    <div id="django-프로젝트에-넣는-방법" 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="#django-%ed%94%84%eb%a1%9c%ec%a0%9d%ed%8a%b8%ec%97%90-%eb%84%a3%eb%8a%94-%eb%b0%a9%eb%b2%95" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>아래 단계는 pytest와 <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>를 쓰고, 의존성은 uv로 관리한다고 가정합니다. pip를 쓴다면 <code>uv add --dev</code>를 <code>pip install</code>로 바꾸고 <code>uv run</code>은 빼세요.</p>
<p><strong>1. 설치합니다.</strong></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">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. pytest로 스위트를 돌릴 수 있는지 확인합니다.</strong> testmon은 pytest 플러그인이라 <code>python manage.py test</code>에서는 아무 일도 하지 않습니다. pytest-django에는 설정 모듈과 테스트가 쓰는 파일 패턴을 알려 줘야 합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>지금까지 Django의 러너로만 스위트를 돌렸다면, testmon을 넣기 전에 pytest로 한 번 돌려 보고 결과가 달라지는 부분부터 고치세요. 그러지 않으면 두 가지를 한꺼번에 디버깅하게 됩니다.</p>
<p><strong>3. 기록은 git에 넣지 않습니다.</strong> testmon은 pytest 루트 디렉터리에 <code>.testmondata</code>를 씁니다. SQLite 데이터베이스라서 옆에 <code>-shm</code>과 <code>-wal</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"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. 스위트 전체를 한 번 기록합니다.</strong> testmon은 모든 테스트가 도는 걸 한 번 보기 전까지는 아무것도 고를 수 없습니다.</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">uv run pytest --testmon</span></span></code></pre></div></div>
<p>새로 받은 체크아웃에서는 이 첫 실행이 프로세스 하나로 스위트 전체를 돕니다. 제 경우엔 테스트 2,248개에 12분쯤 걸립니다. 그다음부터는 같은 명령이 영향받는 테스트만 돌립니다. 바뀐 게 없으면 아무것도 돌리지 않습니다.</p>
<p>주의할 점이 두 가지 있습니다. testmon 2.2.0 이하에서는 pytest의 캐시 플러그인이 필요합니다. 처음에는 <code>-p no:cacheprovider</code>를 붙여 돌렸다가 <code>KeyError: 'lf'</code>로 죽었습니다(2.3.0은 <code>--lf</code>가 없으면 미지정으로 처리합니다). 그리고 Python 3.14 이상이라면 기록하기 전에 <code>COVERAGE_CORE=ctrace</code>를 설정하세요. 이유는 뒤에 나오는 &ldquo;초록불처럼 보였던 버그&quot;에서 설명합니다. 아래 스크립트에서 가장 중요한 한 줄입니다.</p>
<p>업데이트(11월 7일): 11월 2일에 나온 testmon 2.3.0은 coverage의 C 트레이서를 스스로 강제하므로, 그 버전에서는 이 export가 혹시 몰라 두는 줄이 됩니다. 2.2.0 이하라면 그대로 두세요. 그리고 일부러 망가뜨려 확인하는 검사는 버전과 상관없이 계속하세요.</p>
<p><strong>5. testmon이 보지 못하는 파일까지 챙기도록 감쌉니다.</strong> 매일 안심하고 쓸 수 있게 해 주는 게 이 부분입니다. 스크립트는 지난 실행이 시작된 시점의 스탬프 파일을 남겨 둡니다. testmon이 추적할 수 없는 파일 중 스탬프보다 새로운 게 하나라도 있으면 전부 돌리고 처음부터 다시 기록합니다. 새 마이그레이션이라면 테스트 데이터베이스만 다시 만듭니다. 그 밖에는 testmon에게 고르게 맡깁니다. 줄인 예제입니다.</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>여기서 두 가지는 짚고 넘어갈 만합니다. <code>--reuse-db</code>는 실행 사이에 테스트 데이터베이스를 유지하는 pytest-django의 플래그입니다. 실행이 몇 초로 줄어들면 이게 중요해집니다. 매번 데이터베이스를 새로 만드는 데 테스트 자체보다 오래 걸릴 수도 있거든요. 전체 실행이나 새 마이그레이션 뒤의 실행에서는 데이터베이스가 현재 스키마를 갖도록 <code>--create-db</code>를 더합니다. 이때 <code>--reuse-db</code>도 옆에 남겨 두세요. <code>--create-db</code>만 쓰면 pytest-django가 실행이 끝날 때 데이터베이스를 지워 버려서, 다음 실행이 처음부터 다시 만들어야 합니다.</p>
<p>그리고 testmon이 모든 테스트를 선택에서 빼면 실행은 0으로 끝나니, &ldquo;바뀐 것 없음&quot;은 통과로 칩니다. 종료 코드 5는 pytest의 &ldquo;수집된 테스트 없음&quot;인데, 스크립트는 이것도 문제없는 것으로 봅니다.</p>
<p>목록에 없는 것도 눈여겨보세요. <code>pyproject.toml</code>과 락 파일입니다. 첫 버전은 락 파일이 바뀔 때마다 전부 돌렸는데, 그러다 보니 느린 대체 경로가 거의 매일 작동해서 오히려 전보다 나빠졌습니다. testmon은 설치된 패키지 버전을 이미 직접 추적하니, 도와줄 필요가 없습니다.</p>
<p><strong>6. 전용 데이터베이스와 캐시를 줍니다.</strong> 같은 머신에서 다른 것도 테스트를 돌린다면, 이를테면 두 번째 터미널이나 코딩 에이전트, <code>manage.py test</code> 같은 것이 있다면, 스크립트가 자기만의 테스트 데이터베이스와 캐시 번호를 쓰게 하세요. 그래야 유지해 둔 데이터베이스가 남의 실행과 마주칠 일이 없습니다.</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="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. 실행할 때마다 먼저 그 캐시를 비웁니다.</strong> 이건 호되게 겪고 배웠습니다. 전체 실행에서는 나지 않던 <code>KeyError</code>로 부분 실행이 실패하기 시작했습니다. 카테고리 라벨 몇 개가 만료 없이 캐시되어 있었는데, 한 실행에서 유지한 테스트 데이터베이스가 그보다 앞선 실행이 데워 둔 캐시와 만난 겁니다. 스크립트 맨 위에서 캐시를 비우니 해결됐습니다.</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">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>testmon은 문서에 적힌 대로 일했을 뿐입니다. 매번 다른 부분 집합을 돌리면, 앞서 돈 테스트 덕분에 통과하던 테스트가 드러납니다. 고칠 곳은 testmon이 아니라 테스트 설정입니다.</p>
<p><strong>8. 선택이 틀렸을 때 쓸 플래그를 알아 둡니다.</strong></p>
<ul>
<li><code>--testmon-noselect</code>는 모든 테스트를 돌리되 실패할 가능성이 높은 것부터 돌립니다. <code>-k</code>, <code>-m</code>, <code>--lf</code>나 테스트의 노드 ID를 함께 넘기면 testmon이 알아서 이 모드로 바꿉니다.</li>
<li><code>--testmon-forceselect</code>는 영향받는 테스트 중 <code>-k</code>나 <code>-m</code>에도 맞는 것만 돌립니다.</li>
<li><code>--testmon-nocollect</code>는 기존 기록을 쓰되 거기에 보태지는 않습니다. 디버거 아래에서나 coverage가 이미 돌고 있을 때는 강제로 이 모드가 됩니다.</li>
<li><code>--no-testmon</code>은 한 번의 실행에서만 testmon을 끕니다. <code>addopts</code>에 넣어 두었을 때 씁니다.</li>
<li><code>--testmon-env=&lt;name&gt;</code>은 한 파일 안에 기록을 따로따로 둡니다. 설정 모듈 두 개로 돌리는 스위트에 씁니다.</li>
</ul>
<p>프로젝트에 JavaScript 쪽도 있다면, Jest에는 같은 기능이 내장돼 있습니다. <code>jest --changedSince=main</code>은 <code>main</code> 이후 바뀐 것을 import하는 테스트를 돌리는데, coverage가 아니라 import를 따라갑니다. 저는 이걸 앱의 <code>package.json</code>에 <code>npm run test:changed</code>로 넣어서 양쪽이 같은 습관을 갖게 했습니다. 유틸리티 모듈 하나를 고쳤더니 앱의 테스트 파일 121개 중 7개가 선택됐습니다.</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%96%bc%eb%a7%88%eb%82%98-%ec%95%84%ea%bb%b4-%ec%a3%bc%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>래퍼를 넣고 다음 절의 버그까지 고친 뒤, 시작 시간을 포함해서 재 봤습니다.</p>
<table>
	<thead>
			<tr>
					<th>바뀐 것</th>
					<th style="text-align: right">실행한 테스트</th>
					<th style="text-align: right">시간</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>없음</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0초</td>
			</tr>
			<tr>
					<td>홈 화면 배너 뒤의 코드 한 군데 수정</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5초</td>
			</tr>
			<tr>
					<td>그 수정 되돌리기</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7초</td>
			</tr>
			<tr>
					<td>비교용 스위트 전체(프로세스 4개)</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">약 3½분</td>
			</tr>
	</tbody>
</table>
<p>평소의 수정이라면 병렬 스위트보다 25배쯤 빠르고, 바뀐 게 없을 때는 200배쯤 빠릅니다.</p>
<p>기록도 더 넓은 범위에서 같은 말을 합니다. 스위트가 실행하는, 테스트가 아닌 Python 파일 307개 가운데 중앙값에 해당하는 파일을 바꾸면 테스트 22개, 테스트 시간으로 8초쯤에 닿습니다. 반대쪽 끝에는 핵심 모듈 몇 개가 있습니다. 거의 모든 테스트가 이 모듈들을 실행하니, 여기를 바꾸면 1,500개 안팎의 테스트에 닿고 testmon은 스위트 대부분을 다시 돌립니다. 그게 맞는 답이지, 실패가 아닙니다. 이 수치들은 파일별 상한입니다. testmon은 블록 단위로 일하니까, 함수 하나를 바꾸면 그 함수를 실행한 테스트만 고릅니다.</p>
<p>비용은 래퍼가 대체 경로로 넘어갈 때 드러납니다. 전체 실행은 첫 실행과 똑같이 프로세스 하나로 기록하는 실행이라 12분쯤 걸리고, 그건 이 래퍼가 대신하는 병렬 스위트보다 느립니다. 프로세스 하나로 도는 건 testmon을 pytest-xdist와 함께 아직 안 써 봤기 때문입니다. xdist는 1.4부터 지원하니, 다음에 재 볼 건 그쪽이죠. 그래서 전체 실행을 촉발하는 파일 목록은 정직하게 줄일 수 있는 데까지 짧아야 합니다.</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%b4%88%eb%a1%9d%eb%b6%88%ec%b2%98%eb%9f%bc-%eb%b3%b4%ec%98%80%eb%8d%98-%eb%b2%84%ea%b7%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>처음 잰 수치는 이것보다 좋았습니다. 수정 한 번에 테스트 6개, 3.3초. 그런데 틀린 수치였고, 화면 어디에도 그런 말은 없었습니다.</p>
<p>Python 3.14부터 coverage.py는 인터프리터의 <code>sys.monitoring</code> 위에 만든 새 코어를 기본으로 씁니다. 이 코어는 한 프로세스가 어떤 줄을 처음 실행할 때만 그 줄을 보고합니다. testmon은 어느 테스트가 어느 줄을 실행했는지를 coverage에서 받아 기록을 만드니, 각 줄은 우연히 그 줄을 처음 실행한 테스트 하나에만 돌아가고 다른 테스트에는 하나도 돌아가지 않았습니다. 테스트 여덟 개가 함께 거치는 줄을 고치면 testmon은 그중 하나만 고르는 거죠.</p>
<p>이걸 잡아낸 건 이 설정을 그대로 가져다 쓴 제 다른 작은 프로젝트였습니다. 거기에 도입하면서 일부러 한 줄을 망가뜨리고 결과를 비교해 봤는데, 숫자가 맞지 않았습니다. 이 스위트로 돌아와 같은 확인을 해 봤습니다. 홈 화면 배너 코드에서 슬라이드 제목 한 줄을 망가뜨렸더니, 그냥 pytest는 실패한 테스트 8개를 보고했고 testmon은 4개를 골랐습니다. 망가진 나머지 4개는 한 번도 돌지 않았고, 출력 어디에도 그것들이 빠졌다는 말은 없었습니다.</p>
<p>고치는 건 한 줄, <code>export COVERAGE_CORE=ctrace</code>입니다. coverage를 예전의 C 트레이서로 되돌리는 설정이죠. 트레이서가 다르면 기록도 달라지니 다시 기록했습니다. 테스트 2,248개에 12분 3초. 그 뒤로는 같은 식으로 망가뜨리는 수정을 하면, 그 수정으로 깨진 테스트 11개가 전부 돌았습니다.</p>
<p>업데이트(11월 7일): 11월 2일에 나온 testmon 2.3.0은 프로세스 안에서도, 서브프로세스에서도 C 트레이서를 스스로 설정하므로 새로 설치하면 이 export는 더 이상 필요 없습니다. 다음 문단의 검사는 여전히 필요합니다.</p>
<p>이 글에서 무엇보다 먼저 전하고 싶은 교훈이 이겁니다. 너무 적게 고르는 테스트 선택기는 성공과 똑같이 생겼습니다. 테스트는 적고, 실행은 빠르고, 전부 초록불이죠. 그러니 선택기를 설정할 때는 여러 테스트가 함께 쓰는 무언가를 일부러 망가뜨리고, 그냥 스위트와 선택기를 둘 다 돌려서 같은 테스트가 실패하는지 확인하세요. 그리고 Python이나 coverage.py의 버전이 바뀔 때마다 다시 확인하세요.</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%bd%94%eb%94%a9-%ec%97%90%ec%9d%b4%ec%a0%84%ed%8a%b8%ea%b0%80-%eb%93%a4%ec%96%b4%ec%98%a4%eb%8a%94-%ec%9e%90%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이제 그 저장소의 변경을 전부 제 손으로 하지는 않습니다. 작은 변경은 코딩 에이전트가, 제 경우엔 Claude Code가 많이 맡는데, 좋은 에이전트는 수정할 때마다 테스트를 돌립니다. 11분짜리 스위트 앞에서는 에이전트도 제가 겪던 나쁜 선택지 두 개를 똑같이 마주합니다. 기다리거나, 어느 테스트 파일이 중요한지 짐작하거나.</p>
<p>그래서 저장소의 에이전트 지침에 이제 이렇게 적혀 있습니다. 작업 중에 테스트를 돌리는 기본 방법은 래퍼 스크립트이고, 전체 실행은 <code>--all</code>. 에이전트는 수정할 때마다 저와 똑같이 정확한 테스트 집합을 몇 초 만에 받습니다. 6단계에서 스크립트에 전용 데이터베이스와 캐시를 준 것도 이 때문입니다. 바쁜 날에는 같은 머신에서 테스트를 돌리는 세션이 하나가 아니고, 유지해 둔 데이터베이스가 남의 실행과 마주쳐서는 절대 안 되니까요.</p>
<p>testmon에 AI 전용 기능이 따로 필요한 건 하나도 없습니다. 같은 기록, 같은 선택입니다. 다만 변경을 만드는 쪽이 변경을 아주 많이 만들 때 더 중요해질 뿐이죠.</p>

<h2 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%97%90%eb%8a%94-%ec%99%9c-%eb%8d%94-%ea%b1%b0%ec%b9%9c-%eb%b2%84%ec%a0%84%ec%9d%84-%eb%84%a3%ec%97%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon에는 CI 모드가 없습니다. 러너에서도 노트북에서와 똑같이 동작하고, 작성자는 CI 러너끼리 <code>.testmondata</code>를 공유하거나 캐시하는 방식이 자기네 호스팅 서비스인 testmon.net보다 훨씬 덜 효과적이라고 적어 두었습니다. 캐시된 기록은 앞에서 말한 모든 면에서 눈이 먼 채일 테고, 제 CI는 Django의 러너로 프로세스 넷에서 스위트를 돌리니, testmon은 빼 두었습니다.</p>
<p>대신 CI에는 같은 아이디어를 파일 단위로 넣었습니다. 체크 워크플로는 이제 작은 잡 하나로 시작하는데, 이 잡이 push의 diff를 보고 저장소의 어느 쪽을 건드렸는지 정합니다. Python API인지, 모바일 앱인지, 둘 다인지, 둘 다 아닌지. 전에는 문서만 바꾼 push까지 포함해 모든 push에서 Python 잡과 앱 잡을 둘 다 돌렸습니다. 이제 문서만 바꾼 push는 아무것도 돌리지 않고, 앱만 바꾼 push는 Django 스위트를 건너뜁니다. 실제 커밋 다섯 개에 대고 이 잡을 시험 삼아 돌려 봤더니, 문서만 바꾼 커밋은 아무것도 돌리지 않았고, 앱만 바꾼 커밋은 Python 잡을, 백엔드만 바꾼 커밋은 앱 잡을 건너뛰었습니다. 줄인 예제입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</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="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>실제 워크플로는 비교할 범위가 없을 때도 전부 돌립니다. 새 브랜치, 강제 push, 수동 실행이 그런 경우입니다.</p>
<p>사각지대는 모양만 다를 뿐 그대로입니다. testmon이 JSON 파일을 못 보듯, 경로 필터도 목록에 적어 주지 않으면 못 봅니다. 그래서 패턴에 공유 픽스처, 빌드된 번들, 생성된 사이트를 명시적으로 적고, 이걸 고치는 사람은 목록을 정직하게 유지해 달라는 주석을 달아 두었습니다. 파일 단위 버전은 더 투박하지만, 실패하더라도 안전한 쪽으로 실패합니다. Python 쪽에서 뭐든 바뀌었으면 스위트 전체가 돕니다.</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%8d%a8-%eb%b3%bc-%eb%a7%8c%ed%95%9c%ea%b0%80%ec%9a%94" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>몇 분씩 걸리는 스위트라면, 네. 진짜 비용은 testmon이 보지 못하는 것들이고, 래퍼 스크립트가 그걸 위해 있습니다. 그렇다 해도 testmon이 있어야 할 자리는 노트북에서 도는 작업 루프입니다. CI는 Python 쪽이 바뀔 때마다 여전히 스위트 전체를 돌리고, 배포 전에 제가 믿는 건 그 실행입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <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>GitHub Actions CI에서 Docker Compose 대신 쓰는 것, 그리고 지금 돌리는 파이프라인</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>2026년에 제가 돌리는 GitHub Actions CI/CD: CI에서는 Docker Compose 대신 서비스 컨테이너, 캐시는 단계별 소요 시간을 보고 고르고, 배포는 스크립트로.</description>
      <content:encoded><![CDATA[<p>CI/CD 파이프라인은 변경 사항이 <code>git push</code>에서 프로덕션까지 가는 자동화된 경로입니다. CI는 모든 push를 깨끗한 머신에서 빌드하고 테스트하고, CD는 통과한 것을 반복 가능한 단계 하나로 릴리스합니다. GitHub Actions에서는 <code>.github/workflows/</code> 아래의 YAML 파일이 그것이고, 제 것은 일부러 얇게 만들었습니다. 체크는 제 노트북에서 돌리는 명령 그대로이고, 배포는 전부 GitHub 없이도 똑같이 동작할 스크립트나 CLI입니다.</p>
<p>GitHub Actions에서 Docker Compose를 돌리는 법을 찾아 오셨다면, 결론부터. 저는 그걸 2년 하다가 그만뒀습니다. 지금 CI는 같은 Postgres와 Valkey 이미지를 <code>services:</code>로 선언하고, 같은 <code>uv run …</code>과 <code>npm run …</code> 명령을 러너에서 바로 돌립니다. Compose는 개발자가 로컬에서 돌리는 것으로 남았고요. 나머지는 그 이유와 실제 파일들입니다.</p>
<p>2024년에 <a href="https://jared.lynskey.co.nz/ko/posts/2024/2024-06-02-simplifyingcicd/" >플랫폼 종속 YAML 대신 Docker Compose로 CI/CD 하기</a>라는 글을 썼습니다. 빌드 로직은 Compose에 두고 CI 도구는 그걸 호출하는 일만 하게 하라는 내용이었죠. 2년이 지나 제가 실제로 배포하는 저장소를 전부 다시 훑어봤습니다. 워크플로가 있는 저장소는 넷이고, 커미터는 모두 저 한 명입니다. 그리고 그 워크플로 중 <code>docker compose</code>를 호출하는 건 이제 하나도 없습니다. 원칙은 살아남았고, 방법은 살아남지 못했습니다. 아래는 그 자리를 대신한 것들입니다. 줄여서 예제로 만든 파일, GitHub가 기록한 단계별 소요 시간, 그리고 CI/CD 다이어그램이 바라는 것보다 덜 자동화된 부분까지 적었습니다.</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%a0%9c%ea%b0%80-%eb%8f%8c%eb%a6%ac%eb%8a%94-%ed%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8-%ec%a0%84%eb%b6%80-%ed%91%9c-%ed%95%98%eb%82%98%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>파이프라인</th>
					<th>시작하는 쪽</th>
					<th>CI가 확인하는 것</th>
					<th>가는 곳</th>
					<th>릴리스하는 사람</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>이 블로그와 클라이언트의 Hugo 사이트 하나</td>
					<td>모든 push, 브랜치 무관</td>
					<td><code>hugo --gc</code> 빌드가 되는지</td>
					<td>Cloudflare Pages: <code>main</code>은 프로덕션, 다른 브랜치는 미리보기 URL</td>
					<td>없음. push가 곧 릴리스</td>
			</tr>
			<tr>
					<td>제 마켓플레이스 앱의 API</td>
					<td>배포 스크립트, 수동</td>
					<td><code>main</code>에 push할 때마다 린트, 타입 체크, Django 테스트</td>
					<td>Fly.io, 롤링 배포</td>
					<td>저</td>
			</tr>
			<tr>
					<td>같은 앱의 프런트엔드: 웹, OTA 업데이트, 스토어 빌드</td>
					<td>릴리스 스크립트와 로컬 스토어 빌드, 수동</td>
					<td><code>main</code>에 push할 때마다 TypeScript와 Jest</td>
					<td>오브젝트 스토리지, OTA 업데이트 서비스, 두 앱 스토어</td>
					<td>저</td>
			</tr>
			<tr>
					<td>제 오픈소스 Python 패키지 pyhtml2text</td>
					<td>모든 push, 풀 리퀘스트, 태그</td>
					<td>빌드 타깃 15개용 wheel, 대부분에서 테스트</td>
					<td>PyPI, 태그를 달 때</td>
					<td>태그</td>
			</tr>
	</tbody>
</table>
<p>교과서 용어로 말하면 두 Hugo 사이트는 지속적 배포이고, 앱(제가 만드는 마켓플레이스 겸 커뮤니티 앱)은 지속적 통합에 명령 한 줄짜리 릴리스를 더한 지속적 전달입니다. 체크는 뭔가 잘못됐다고 알려 줄 뿐, 배포를 막지는 않습니다. 일부러 이렇게 나눴습니다. OTA 업데이트는 대상 바이너리 버전을 쓰는 모든 휴대폰에 닿으니, 머지의 부작용이 아니라 결정이어야 합니다. 그리고 API는 데이터베이스 마이그레이션이 실행되는 곳입니다. 제 나머지 Django 앱 두 개에는 워크플로가 아예 없습니다.</p>

<h2 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%aa%bd-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c-%ed%95%98%eb%82%98-%ec%9e%a1-%eb%91%90-%ea%b0%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱의 CI는 <code>.github/workflows/checks.yml</code> 파일 하나입니다. 10월 11일 전까지는 이것조차 없었습니다. 아래는 그 구조를 줄여서 만든 예제입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</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="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</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="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>두 잡은 나란히 돕니다. 지금까지 성공한 35번의 실행에서 앱 잡의 중앙값은 7분쯤, Python 잡은 12분 반쯤입니다.</p>
<p><code>services:</code> 블록이 바로 Compose 파일이 옮겨 간 자리인데, 다음 절에서 다룹니다. 환경 변수는 몇 개와 플래그 하나뿐입니다. 나머지는 전부 개발자가 <code>docker compose up</code>에서 받는 개발용 기본값으로 떨어지기 때문에, 테스트에는 시크릿이 하나도 필요 없습니다. 정말 그런지 보려고 딱 그 환경만, 다른 건 아무것도 없이 테스트 일부를 돌려 확인했습니다.</p>
<p>CI는 제 노트북, 프로덕션 이미지와 같은 인터프리터, 즉 uv가 직접 빌드한 Python 3.15.0으로 돕니다. <code>uv sync</code>가 <code>.python-version</code>을 읽고 정확히 그 빌드를 내려받기 때문입니다. (실제 파일에서는 setup-uv에 <code>python-version-file</code> 입력도 넘기는데, 이건 아무 일도 하지 않습니다. setup-uv v6에는 그런 입력이 없고, 매 실행마다 그렇다는 경고가 붙어 있습니다. 이 글을 쓰다가야 알았습니다.)</p>
<p>인터프리터를 맞춘 보람은 워크플로를 넣은 지 8일 만에 나타났습니다. 10월 19일에 3.15의 JIT 컴파일러를 켰더니, 켠 뒤 첫 실행에서 테스트 하나가 실패했습니다. 앱이 저장할 때마다 지나가는 코드 경로의 평범한 <code>yield from</code>이 <code>AttributeError: 'list' object has no attribute 'send'</code>를 던진 겁니다. 같은 커밋을 JIT를 끄고 돌리면 통과했습니다. 제 Mac은 arm64라서 이 문제를 한 번도 만나지 않았고, 러너는 프로덕션 머신과 같은 x86_64입니다. JIT는 CI에서도 Dockerfile에서도 다시 꺼 두었고, 버그는 프로덕션에 닿지 않았습니다. python/cpython#155823로 올라가 있던 버그로, 3.15.0이 나온 뒤인 10월 13일에 업스트림에서 고쳐졌습니다.</p>
<p>사실 맨 첫 실행도 실패했는데, 이유는 더 시시했습니다. Expo는 <code>expo start</code>를 할 때 <code>expo-env.d.ts</code>를 만들고 이 파일은 gitignore되어 있습니다. 그래서 제가 쓰던 작업 사본에는 모두 있었고, 새로 받은 체크아웃에는 없었죠. TypeScript가 로컬에서는 한 번도 보지 못한 오류 다섯 개를 냈습니다. 그 파일을 써 주는 단계가 해결책입니다.</p>
<p><code>concurrency</code>는 같은 브랜치에 더 새로운 push가 오면 이전 실행을 취소합니다. 지금까지 취소된 12번의 실행은 모두 이렇게 끝났습니다. 커밋 전 훅(pre-commit hook)이 린터를 돌리고, Python이나 TypeScript가 스테이징돼 있으면 타입 체커도 돌리기 때문에 실수는 대부분 push까지 가지도 않습니다.</p>

<h2 class="relative group">Docker Compose 파일은 어디로 갔나
    <div id="docker-compose-파일은-어디로-갔나" 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="#docker-compose-%ed%8c%8c%ec%9d%bc%ec%9d%80-%ec%96%b4%eb%94%94%eb%a1%9c-%ea%b0%94%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱의 <code>docker-compose.yml</code>에서 CI와 관련된 부분을 예제로 옮긴 것입니다. 실제 파일에는 서비스도 더 있고 환경 변수도 훨씬 많습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</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="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</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="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</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="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p><code>checks.yml</code>의 <code>services:</code> 블록 옆에 놓고 보면 같은 이미지 두 개입니다. Postgres 17(여기서는 Alpine 빌드, CI에서는 Debian 빌드)과 <code>valkey/valkey:8-alpine</code>. 지금의 관계는 이렇습니다. Compose 파일은 개발자가 돌리는 것이고, CI는 같은 이미지를 서비스 컨테이너로 선언한 다음 개발자가 돌리는 것과 같은 명령, <code>uv run …</code>과 <code>npm run …</code>을 러너에서 바로 돌립니다.</p>
<p>2024년 글과 비교하면, 제가 가장 신경 쓰던 부분은 살아남았습니다. CI 도구는 얇고, 체크와 배포는 제가 직접 돌릴 수 있는 명령입니다. 살아남지 못한 건 CI가 Compose를 호출하는 방식입니다.</p>
<p>앱의 CI는 Docker 이미지를 아예 빌드하지 않습니다. 이미지는 배포할 때 Fly의 빌더가 한 번 빌드하고, CI는 코드가 괜찮은지만 말해 주면 됩니다. 툴체인이 캐시돼 있으면 그건 6초짜리 설치로 끝납니다. 게다가 Compose 파일은 작업 사본을 바인드 마운트하고 데이터를 시드하는데, 테스트 실행이 할 일은 아닙니다. 그러니 CI용 Compose 파일이 따로 필요했을 테고, 그건 같은 서비스를 두 번째로 정의해 손으로 맞춰 가야 한다는 뜻입니다.</p>
<p>그리고 2024년 예제에는 당시 눈치채지 못한 버그가 있었습니다. <code>docker compose build</code>는 이미지를 빌드할 뿐 서비스의 <code>command</code>를 실행하지 않습니다. 그러니 그 파일의 <code>npm test</code>는 CI에서 한 번도 돌지 않았고, 워크플로는 테스트가 통과하든 말든 초록불이었습니다.</p>
<p>그 글이 가장 걱정했던 위험, 즉 CI 업체를 바꾸는 일은 결국 일어나지 않았습니다. 실제로 일어난 이동은 반대쪽 끝에서였습니다. 이 블로그와 그 클라이언트 사이트가 호스팅을 Cloudflare Pages로 옮겼는데, 블로그 쪽은 45줄짜리 워크플로 하나를 새로 쓰는 걸로 끝났습니다. 빌드가 원래 명령 한 줄이었기 때문이죠. 그리고 제가 경고했던 300줄짜리 워크플로요? 하나 있습니다. pyhtml2text의 워크플로입니다. wheel을 Linux, macOS, Windows 러너에서 각각 여러 아키텍처로 빌드해야 해서 긴 건데, 그래서 플랫폼 전용 YAML이 맞는 도구인 유일한 작업이기도 합니다.</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%ba%90%ec%8b%b1-%eb%8b%a8%ea%b3%84%eb%b3%84-%ec%86%8c%ec%9a%94-%ec%8b%9c%ea%b0%84%eb%b6%80%ed%84%b0-%ec%9d%bd%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>GitHub는 모든 단계의 소요 시간을 기록해 두고, <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code>로 꺼내 볼 수 있습니다. 아래는 10월 20일, 앱의 가장 최근 성공 실행입니다.</p>
<table>
	<thead>
			<tr>
					<th>잡</th>
					<th>단계</th>
					<th>초</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Postgres와 Valkey 시작</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, 캐시 있음</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django 테스트</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, 캐시 있음</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>의존성 캐시 두 개는 모두 켜져 있고, 둘 다 저렴한 부분입니다. GitHub가 이 저장소용으로 보관하는 uv 캐시는 200KB 정도인데, setup-uv가 다시 내려받으면 되는 것들은 정리해 버리기 때문입니다. npm 캐시는 400MB에 가깝습니다. 시간이 드는 곳은 테스트이고, 테스트는 이미 4갈래로 병렬 실행됩니다. 실행을 더 빠르게 하고 싶다면 캐시가 아니라 테스트 스위트를 손보겠습니다.</p>
<p>블로그는 정반대입니다. Hugo가 처리한 이미지는 gitignore된 <code>resources/_gen</code>에 있어서, 새 러너마다 커버, 본문 이미지, 플레이스홀더를 전부 다시 인코딩했습니다. 아래 워크플로는 <code>actions/cache</code>로 이것을 복원합니다. 키는 사진 자체의 해시이고, 키가 맞지 않으면 가장 최근의 이전 캐시로 대신합니다. Hugo는 빠진 것만 처리하니까요. 캐시를 넣기 전 마지막 8번의 실행에서 빌드 단계는 33<del>76초 걸렸고, 넣은 뒤 26번은 6</del>20초였습니다. 94초가 한 번 있었을 뿐이고, 같은 커밋에서 플레이스홀더와 두 번째 카드 크기까지 추가했는데도 그렇습니다.</p>
<p>앱의 이미지는 Fly의 빌더에서 빌드되고, 의존성 파일을 코드보다 먼저 복사하며 uv의 다운로드 캐시는 BuildKit 캐시 마운트(<code>RUN --mount=type=cache,target=/root/.cache/uv</code>)에 둡니다. 하지만 더 큰 개선은 캐시가 아니었습니다. 예전 <code>.dockerignore</code>는 블록리스트였는데, 추가 git worktree들을 모아 둔 폴더를 빠뜨렸습니다. worktree마다 자기 의존성과 빌드 산출물이 들어 있었죠. 그래서 파일 27만 7천 개, 7.7GB가 배포할 때마다 빌더로 올라갔습니다. 허용 목록으로 다시 쓴 지금은 빌드 컨텍스트가 40MB 정도입니다. 그 패턴을 예제로 보면 이렇습니다.</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"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<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%8b%9c%ed%81%ac%eb%a6%bf%ec%9d%80-%ec%b5%9c%eb%8c%80%ed%95%9c-%ec%a0%81%ec%9d%80-%ea%b3%b3%ec%97%90" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>시크릿은 세 군데에 있습니다. GitHub Actions 시크릿은 워크플로가 하는 일을 위한 것이고, 두 Hugo 저장소에는 <code>CLOUDFLARE_API_TOKEN</code> 딱 하나씩만 있습니다. Fly 시크릿은 앱의 런타임 설정으로 <code>flyctl secrets set</code>으로 넣고, Actions를 거치지 않습니다. 그리고 제 노트북의 gitignore된 <code>.env</code>에는 배포 스크립트가 Cloudflare 캐시를 비울 때 쓰는 토큰이 있습니다.</p>
<p>PyPI에는 시크릿이 아예 필요 없습니다. pyhtml2text는 신뢰할 수 있는 게시(trusted publishing)를 씁니다. PyPI가 GitHub이 워크플로에 발급하는 수명 짧은 OIDC 토큰을 받아 주기 때문에, 릴리스 잡은 <code>id-token: write</code>만 요청하고 저장소의 시크릿 페이지는 비어 있습니다. 샐 것도, 교체할 것도 없죠.</p>
<p>토큰에는 필요한 만큼만 줍니다. GitHub에 있는 앱의 Fly 토큰은 머신 목록만 조회하는 예약 체크용이라 <code>fly tokens create readonly</code>로 만들었습니다. Pages 배포는 <code>contents: read</code>와 <code>deployments: write</code>만 요청하고 그 이상은 없습니다.</p>
<p>입력값은 스크립트에 바로 넣지 않고 반드시 <code>env:</code>를 거칩니다. OTA 워크플로는 자유롭게 쓰는 릴리스 노트를 받습니다. 이걸 <code>run:</code> 안에 <code>${{ inputs.notes }}</code>로 쓰면 bash가 읽기 전에 텍스트가 스크립트에 그대로 붙여 넣어지니, 따옴표와 세미콜론이 든 노트는 코드가 되어 버립니다. 환경 변수로 넘기면 데이터로 남습니다. 패턴을 예제로 보면 이렇습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>아예 GitHub 밖에 두는 키도 있습니다. 예약 리포트 하나에는 앱의 로그인 백엔드에 대한 관리자 권한이 있는 키가 필요해서, 그 리포트는 예약 워크플로에서 제 Mac의 예약 작업으로 옮겼습니다. 워크플로는 수동 예비용으로 남아 있고, 그 키가 필요한 섹션은 빼고 게시합니다.</p>

<h2 class="relative group">배포: push마다, 스크립트로, 태그로
    <div id="배포-push마다-스크립트로-태그로" 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%b0%b0%ed%8f%ac-push%eb%a7%88%eb%8b%a4-%ec%8a%a4%ed%81%ac%eb%a6%bd%ed%8a%b8%eb%a1%9c-%ed%83%9c%ea%b7%b8%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugo 사이트는 push마다 배포됩니다
    <div id="hugo-사이트는-push마다-배포됩니다" 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="#hugo-%ec%82%ac%ec%9d%b4%ed%8a%b8%eb%8a%94-push%eb%a7%88%eb%8b%a4-%eb%b0%b0%ed%8f%ac%eb%90%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>블로그 워크플로를 예제로 옮긴 것입니다. Pages 프로젝트를 처음 만드는 단계는 뺐습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</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="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</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="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</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="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</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="w">      </span><span class="c"># ...</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> 덕분에 <code>main</code>은 프로덕션이 되고 다른 브랜치는 모두 미리보기 URL을 받습니다. 클라이언트 사이트의 워크플로는 이것과 같은 구조에 빌드 전 <code>rm -rf public</code> 한 줄이 더 있습니다. 그쪽은 <code>public/</code>이 커밋되어 있어서, 안 그러면 삭제한 페이지가 업로드에 계속 남기 때문입니다.</p>
<p>Hugo 버전은 여기에서만 고정합니다. 10월에 패키지 매니저 업그레이드로 제 노트북의 Hugo가 0.168이 됐는데, 테마가 지원하는 최신 버전보다 하나 높습니다. 배포는 0.165에 머물렀고 아무 일도 없었습니다.</p>
<p>그리고 빌드가 곧 테스트입니다. 9월 14일, 한 브랜치의 미리보기 배포가 세 번 연달아 실패했습니다. 그 브랜치의 글 하나가 Hugo의 <code>ref</code>로 아직 초안인 글을 가리키고 있었거든요. CI는 프로덕션이 빌드하는 것과 똑같이, 초안을 빼고 빌드합니다. 그러니 링크는 아무것도 가리키지 않았고 Hugo는 <code>REF_NOT_FOUND</code>로 멈췄습니다. 그동안에도 <code>main</code>은 계속 배포되고 있었습니다.</p>

<h3 class="relative group">앱의 API는 스크립트로 배포합니다
    <div id="앱의-api는-스크립트로-배포합니다" 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%b1%ec%9d%98-api%eb%8a%94-%ec%8a%a4%ed%81%ac%eb%a6%bd%ed%8a%b8%eb%a1%9c-%eb%b0%b0%ed%8f%ac%ed%95%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>배포 스크립트의 핵심을 예제로 옮긴 것입니다.</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">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>그리고 <code>fly.toml</code>에서 짝이 되는 부분입니다.</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">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>이미지는 한 번 빌드해서 커밋과 시각으로 만든 라벨을 붙여 Fly 레지스트리에 올리고, 그 라벨로 배포합니다. 그래서 롤아웃되는 건 정확히 빌드한 그것입니다.</p>
<p>무중단 배포를 가능하게 하는 건 예비 머신을 먼저 깨우는 단계입니다. <code>max_unavailable = 1</code>은 실행 중인 머신만 셉니다. 그런데 앱은 한가할 때 실행 중인 머신을 하나도 두지 않습니다(일시 정지된 머신은 1초 남짓이면 깨어납니다). 그래서 깨어 있던 머신 하나가 잠든 머신들과 같은 묶음에서 교체되곤 했고, 그 뒤에는 따뜻한 머신이 하나도 없었습니다. 9월 28일에 오리진에 프로브를 계속 보내 봤더니, 그냥 <code>flyctl deploy</code>를 할 때는 타임아웃 7번과 7.3초의 공백이 있었고, 먼저 빌드하고 롤아웃 전에 머신 하나를 깨워 두는 스크립트 배포로는 요청 2,514개 중 실패가 하나도 없었습니다.</p>
<p>스크립트의 나머지는 오류 알림이 오는 팀 채팅 채널에 배포 표시를 남깁니다. 그래야 오류가 몰렸을 때 방금 무엇이 바뀌었는지와 맞춰 볼 수 있죠. 그리고 Cloudflare 캐시를 비우고 머신들을 데워 둡니다. 웹 프런트엔드는 이미지를 아예 거치지 않는데, 그건 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-14-web-releases-without-redeploying/" >프론트엔드와 백엔드 따로 배포하기</a>에 적었습니다.</p>
<p>마이그레이션은 파이프라인 단계가 아닙니다. Fly의 <code>release_command</code>가 제 환경에서는 멈춰 버려서, 새 이미지로 처음 부팅하는 머신이 락을 잡고 마이그레이션하는 동안 나머지는 기다립니다. 그러니 모든 마이그레이션은 아직 돌고 있는 코드와도 맞아야 합니다. 컬럼은 한 배포에서 추가하고, 다음 배포에서 쓰지 않게 하고, 세 번째 배포에서 지웁니다.</p>
<p>이 스크립트에 꼭 제 노트북이어야 하는 부분은 없지만, 노트북에만 있는 파일 몇 개가 스크립트를 거기 묶어 둡니다. Cloudflare 자격 증명, 그리고 마지막으로 무엇을 배포했는지 적어 둔 기록입니다. 새 러너에는 이 파일들이 없죠. 커밋하지 않은 변경도 배포해 버리고 채팅 채널에 그렇다고 알리는데, 웹 릴리스 스크립트는 더러운 작업 트리를 거부합니다.</p>
<p><a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >브랜치별 프리뷰 환경</a>에서 저는 이걸 손으로 하는 건 혼자일 때는 괜찮지만, 두 번째 개발자부터는 git에서 뭔가 일어났기 때문에 일어나야 한다고 썼습니다. 앱의 개발자는 아직 한 명입니다. 그 글의 브랜치 환경 워크플로는 제가 만들었던 브랜치에 그대로 있습니다.</p>
<p>OTA 워크플로는 수동 전용입니다. 게시는 결정이어야 하고, 업데이트 서비스는 부분 롤아웃이 열려 있는 동안 새 릴리스를 거부하기 때문에 push 트리거라면 카나리 배포 때마다 걸릴 테니까요. 이 워크플로는 한 번도 실행된 적이 없습니다. 지금까지 OTA 업데이트는 전부 제 노트북에서, 워크플로가 호출하는 것과 같은 스크립트로 나갔습니다. 얇은 워크플로를 쓰는 이유가 한 줄로 이겁니다. 릴리스를 어떻게 시작하든 같은 스크립트죠.</p>

<h3 class="relative group">pyhtml2text는 태그로 릴리스합니다
    <div id="pyhtml2text는-태그로-릴리스합니다" 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="#pyhtml2text%eb%8a%94-%ed%83%9c%ea%b7%b8%eb%a1%9c-%eb%a6%b4%eb%a6%ac%ec%8a%a4%ed%95%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>릴리스 잡입니다. 패키지 이름은 일반적인 이름으로 바꿨습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>릴리스 잡은 모든 빌드 잡을 기다리고, 실제 게시는 태그일 때만 합니다. 수동 실행은 빌드하고 증명(attestation)까지 만들지만 아무것도 올리지 않습니다. <code>actions/attest</code>는 어느 워크플로가 각 wheel을 빌드했는지 기록하고, <code>uv publish --trusted-publishing always</code>는 비밀번호 대신 OIDC 토큰으로 업로드합니다. 패키지 자체에 대해서는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/" >닫힌 풀 리퀘스트, 포크, 그리고 PyPI 패키지</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="#%ec%98%88%ec%95%bd-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c%eb%8a%94-%ec%b5%9c%ec%84%a0%ec%9d%84-%eb%8b%a4%ed%95%a0-%eb%bf%90%ec%9e%85%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱에는 예약 워크플로가 하나 있습니다. Fly가 기준치보다 머신을 더 띄우지 않았는지 보는 체크로, 그냥 두면 청구서에서 처음 알게 될 일입니다. 알림은 일부러 실패하는 방식으로 보냅니다. 예약 실행이 실패하면 GitHub이 소유자에게 메일을 보내니까요.</p>
<p>설정은 <code>cron: '*/15 * * * *'</code>, 하루 96번입니다. 그런데 지난 2주 동안은 하루 3~8번만 돌았고, 실행 간격의 중앙값은 3시간 40분쯤이었습니다. GitHub 문서가 내놓는 설명은 부하뿐입니다. Actions가 바쁘면 예약 실행이 늦어질 수 있고, 아주 바쁘면 빠질 수도 있다는 거죠. 계속되는 스케일 아웃이라면 몇 시간이면 충분합니다. 몇 분 안에 울려야 하는 것이라면 GitHub의 cron은 모니터링이 아닙니다.</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%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8%ec%97%90-%eb%8d%98%ec%a0%b8-%eb%b3%bc-%ec%a7%88%eb%ac%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>파이프라인을 만들고 있거나 이미 있는 걸 들여다보고 있다면:</p>
<ol>
<li>GitHub Actions가 하루 동안 멈추면 그래도 릴리스할 수 있나요? 못 한다면 릴리스 로직이 YAML 안에 있는 것이고, 2024년 글이 여전히 유효합니다.</li>
<li>CI가 프로덕션과 같은 인터프리터, 같은 CPU 아키텍처, 같은 데이터베이스 메이저 버전으로 도나요? 제 CI가 컴파일러 버그를 잡은 건 러너와 프로덕션은 둘 다 x86_64이고 제 노트북은 아니었기 때문입니다.</li>
<li>새로 받은 체크아웃이 통과하나요? 안 된다면 대개 생성되고 gitignore된 파일 때문입니다.</li>
<li>실행 시간은 실제로 어디에 쓰이나요? 캐시를 넣기 전에 단계별 소요 시간부터 읽어 보세요.</li>
<li>시크릿마다 몇 군데에 있나요? 그리고 정말 GitHub에 있어야 하나요?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/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>
    <item>
      <title>브랜치별 프리뷰 환경: Fly.io와 Cloudflare로 스테이징 쟁탈전 끝내기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>PR마다 프리뷰 환경을 띄우는 방법입니다. 라벨 하나로 브랜치 이름의 해시를 Fly 앱, Postgres, Redis, 버킷, 호스트명에 찍어 공유 인프라를 나눠 쓰고, PR을 닫으면 걷어냅니다.</description>
      <content:encoded><![CDATA[<p>브랜치마다 프리뷰 환경을 주려고 저는 Fly.io의 인프라를 통째로 복제하지 않고, 공유한 채로 잘라 씁니다. PR에 <code>env</code> 라벨을 붙이면 GitHub Actions 워크플로가 스크립트 하나를 돌리고, 그 스크립트가 브랜치 이름에서 뽑은 이름(슬러그와 짧은 해시)을 Fly 앱, 템플릿에서 복제한 Postgres 데이터베이스, Redis 키 접두사, 버킷 접두사, 새로 생성한 시크릿, Cloudflare 와일드카드 아래의 호스트명에 찍습니다. push할 때마다 갱신되고, PR을 닫거나 머지하면 철거되며, 새어 나간 건 매일 밤 도는 스위퍼가 치웁니다. 공유 인프라 위의 브랜치별 환경이라, 이제 &ldquo;스테이징 쓰는 사람 있어요?&ldquo;라고 물을 일이 없습니다.</p>
<p>앱 하나를 만드는 작은 팀을 떠올려 보세요. 개발자 둘일 수도, 다섯일 수도 있습니다. 전문 분야가 나뉜 사람들이 아니라, 각자 자기 브랜치에서 데이터베이스 마이그레이션부터 API를 거쳐 모바일 앱 화면까지 기능 하나를 처음부터 끝까지 맡는 사람들입니다. 팀을 굴리기에 좋은 방식이고, 두 번째 개발자가 합류하는 순간부터 반복되는 실패가 하나 있습니다. &ldquo;지금 스테이징 쓰는 사람 있어요?&ldquo;라는 메시지입니다.</p>
<p>그 메시지 뒤에는 기능을 다 만들고 테스터나 디자이너에게 보여주고 싶은데 그럴 수 없는 개발자가 있습니다. 스테이징에는 두 번째 개발자의 브랜치가 올라가 있고, dev에는 세 번째 개발자의 브랜치가 올라가 있는데 그건 이틀 전에 로그인을 망가뜨린 뒤로 다시 배포되지 않았습니다. 몇 사람, 공유 환경 둘, 그리고 일정 조율을 떠맡은 채팅 스레드.</p>
<p>이건 사람 문제가 아닙니다. 토폴로지 문제입니다. 모든 개발자가 환경을 &ldquo;내 브랜치가 실제가 되는 곳&quot;으로 다루니, 환경이 머지가 해야 할 일을 브랜치 하나씩 차례로 대신하고 있고, 나머지 전부는 줄을 서 있는 겁니다.</p>
<p>제 스택의 프로덕션 형태는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a>에 적었습니다. Fly.io의 가장 싼 머신 위의 Django, 커넥션 풀러 뒤의 Postgres, 캐시와 Channels 레이어용 Redis, 별도의 WebSocket 앱, 백그라운드 워커, 파일용 S3 호환 버킷, 호스팅형 인증 서비스, 앞단의 Cloudflare, 그리고 이 모든 것과 통신하는 모바일 앱입니다. 이 글은 그 스택을, 어떤 브랜치든 개발자가 PR에 라벨 하나를 붙이면 몇 분 안에 HTTPS가 붙은 공개 복사본 전체를 얻는 것으로 바꾼 이야기입니다. 다른 누구에게 부탁하지도, 누가 프로비저닝해 주지도 않고요. Postgres 클러스터를 하나 더 두지도, Redis를 하나 더 두지도, 버킷을 하나 더 두지도 않고요. 인프라는 공유된 채로 남습니다. 달라지는 건 모든 공유 자원이 이름으로 잘리는 법을 배운다는 것이고, 그 이름은 브랜치에서 나옵니다. 여기에 팀 규모를 전제하는 건 없습니다. 개발자 둘이든 스물이든 설계는 같고, 늘어나는 건 앱 목록뿐이며 그건 스위퍼가 처리합니다. 오후 한나절에 만들고, 브랜치 셋을 동시에 띄우고, 예상 못 한 두 군데에서 부러뜨리고, 다시 걷어냈습니다. 아래 출력은 그 실행에서 나온 것입니다.</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%b3%b5%ec%9c%a0-%ed%99%98%ea%b2%bd-%ed%95%98%eb%82%98%ea%b0%80-%ea%b0%9c%eb%b0%9c%ec%9e%90-%ed%95%9c-%eb%aa%85-%ec%9d%b4%ec%83%81%ec%9c%bc%eb%a1%9c-%eb%8a%98%ec%96%b4%eb%82%98%ec%a7%80-%eb%aa%bb%ed%95%98%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 dev 환경에는 전제가 하나 깔려 있습니다. 모두가 보고 싶어 하는 &ldquo;현재 코드 버전&quot;이 하나라는 전제입니다. 개발자가 한 명이거나, 팀이 변경 흐름 하나를 발맞춰 작업할 때는 맞는 말입니다. 두 번째 개발자가 다른 일정으로 기능을 시작하는 순간 틀린 말이 되고, 처음부터 끝까지 맡는 방식에서는 모든 기능이 그렇습니다. 그때부터 환경이 쥐고 있는 모든 것이 쟁탈 대상이 됩니다.</p>
<ul>
<li><strong>배포된 코드.</strong> 마지막에 배포한 사람이 이깁니다. 나머지 사람들의 작업은 다시 배포하기 전까지 보이지 않고, 다시 배포하면 앞사람 것이 지워집니다.</li>
<li><strong>데이터베이스 스키마.</strong> 개발자마다 자기 기능의 데이터 모델을 소유할 때 가장 아프게 무는 부분입니다. 첫 브랜치가 컬럼을 추가합니다. 한 시간 뒤 배포된 두 번째 브랜치는 그 컬럼을 모르고, ORM이 insert에서 실패하기 시작합니다. 아니면 두 브랜치 모두 <code>0042_*</code> 마이그레이션을 갖고 있어서, 공유 데이터베이스가 누구의 마이그레이션 이력과도 맞지 않는 상태가 되고, 누군가 초기화하면서 세 번째 개발자가 오후 내내 세팅한 테스트 데이터를 지웁니다.</li>
<li><strong>캐시.</strong> 한 브랜치의 serializer 변경이 다른 브랜치 코드가 읽는 것과 같은 Redis 키 아래 캐시됩니다. 그래서 나오는 버그 리포트는 어리둥절하고, 하나에 오전 한나절씩 잡아먹습니다.</li>
<li><strong>서드파티 연동.</strong> OAuth 콜백 하나, 웹훅 URL 하나, 푸시 인증서 하나. 누구 브랜치가 배포돼 있든 그 브랜치가 이벤트를 받습니다.</li>
<li><strong>테스터.</strong> 배포된 것만 테스트할 수 있으니, 기능이 준비된 순서가 아니라 환경이 우연히 점유된 순서대로 한 번에 하나씩 테스트합니다.</li>
</ul>
<p>팀은 예약 시스템으로 대응합니다. 채팅 스레드, 고정 메시지, <code>/claim staging</code> 봇. 그건 락입니다. 작업을 보여줄 수 있는 유일한 장소에 걸린 락은 전달 속도에 걸린 락입니다. 개발자 둘이면 성가신 정도입니다. 셋이면 좋은 주에는 견딜 만합니다. 네 번째가 합류하거나, 기능 하나가 디자이너와 사흘간 주고받느라 스테이징을 붙들고 있으면 견딜 수 없게 됩니다.</p>
<p>사람들이 먼저 손대는 해결책은 세 번째 공유 환경, &ldquo;dev2&quot;나 &ldquo;uat&quot;입니다. 몇 주는 벌어 줍니다. 줄이 두 갈래가 됐을 뿐입니다.</p>
<p>진짜 해결책은 dev와 스테이징이 애초에 환경이 아니었다는 걸 알아차리는 것입니다. 그것들은 <em>누군가의 브랜치의 현재 상태에 URL이 붙은 것</em>이었습니다. 그렇다면 알맞은 개수는 지금 팀이 신경 쓰는 브랜치의 개수이고, 알맞은 수명은 브랜치의 수명입니다.</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%a8%eb%8d%b8-%eb%b8%8c%eb%9e%9c%ec%b9%98%ea%b0%80-%ea%b3%a7-%ed%99%98%ea%b2%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>목표 상태는 이렇습니다.</p>
<table>
	<thead>
			<tr>
					<th>환경</th>
					<th>개수</th>
					<th>만드는 주체</th>
					<th>없애는 주체</th>
					<th>수명</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>브랜치 환경 (예전의 &ldquo;dev&rdquo;)</td>
					<td>활성 브랜치당 하나</td>
					<td>브랜치의 PR에 붙는 <code>env</code> 라벨, 그다음은 push마다. PR 없는 스파이크라면 같은 <code>./scripts/env up</code>을 손으로</td>
					<td>브랜치 삭제, 그리고 매일 밤 도는 스위퍼</td>
					<td>며칠</td>
			</tr>
			<tr>
					<td>스테이징</td>
					<td>릴리스 후보당 하나</td>
					<td><code>main</code>에서 태그가 잘릴 때 CI</td>
					<td>같은 이미지가 프로덕션으로 승격되면 CI</td>
					<td>몇 시간</td>
			</tr>
			<tr>
					<td>프로덕션</td>
					<td>하나</td>
					<td>당신이, 한 번</td>
					<td>아무도</td>
					<td>영원히</td>
			</tr>
	</tbody>
</table>
<p>표에서 누가 만들고 누가 없애는지를 다시 보세요. 전부 git 이벤트 아니면 시계입니다. git이 컨트롤 플레인입니다. 존재해야 할 환경의 집합은 존재하는 브랜치의 집합에서 그대로 따라 나오고, 자동화가 하는 일은 현실을 거기에 맞춰 두는 것뿐입니다. PR의 라벨이 만들고, 그 뒤의 push마다 갱신하고, PR을 닫거나 머지하면 없애고, 매일 밤 도는 잡이 앞의 셋이 놓친 것을 정리합니다. 모든 브랜치가 환경을 얻는 것도 아닙니다. 라벨이 게이트이고 개발자의 클릭 한 번이니, 백업 push나 봇의 의존성 업데이트는 비용이 없습니다. 아무도 환경을 프로비저닝하지 않습니다. 티켓을 받은 DevOps 엔지니어도, 채팅에서 부르는 봇도, 매번 프롬프트를 넣어야 하는 AI 에이전트도 아닙니다. 열 번째 개발자의 첫 브랜치도 첫 번째 개발자의 브랜치와 똑같은 방법으로, PR에 라벨을 붙였다는 이유로 환경을 얻습니다. 사람에게 부탁해야 한다면 확장된 게 아니라 줄이 자리를 옮긴 것뿐입니다.</p>
<p>그리고 이걸 굴러가게 하는 규칙은 하나입니다. <strong>환경이 건드리는 모든 자원은 브랜치 이름을 따서, 결정론적으로 이름 붙인다.</strong> 개발자 이름도 아니고, 티켓 번호도 아니고, 누군가 열기 전까지는 존재하지도 않는 PR 번호도 아닙니다. 브랜치입니다. 개발자가 실제로 다루는 것이 브랜치이고, 이름이 결정론적이면 같은 브랜치에서 <code>./scripts/env up</code>을 두 번째 실행했을 때 새 환경을 또 만드는 대신 이미 만든 환경을 찾아내기 때문입니다.</p>
<p>이름은 사람을 위한 슬러그와 유일성을 위한 짧은 해시입니다.</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">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code>가 환경입니다. 이 문자열이 Fly 앱 이름, Postgres 데이터베이스 이름(하이픈은 밑줄로), Redis 키 접두사, 버킷의 객체 접두사, 에러 트래커의 environment, 그리고 호스트명의 맨 왼쪽 라벨이 됩니다. 브랜치 이름을 아는 사람은 누구나 계산할 수 있습니다. 어디서도 찾아볼 필요가 없습니다.</p>
<p>마지막 문장이 곧 설계입니다. 환경 목록도 없고 조정자도 없습니다. <code>env up</code>, <code>env down</code>, 호스트명을 라우팅하는 Cloudflare Worker, 밤마다 도는 스위퍼가 각자 브랜치에서 이름을 독립적으로 다시 계산하고, 구조적으로 서로 일치합니다. 특히 Cloudflare는 아무것도 조정하지 않습니다. Worker는 호스트명에서 Fly 앱으로 가는 순수 함수이고, 환경이 만들어졌는지 없어졌는지 영영 알지 못합니다. 앱이 있으면 요청이 닿고, 없으면 Fly가 자기 오류로 답합니다.</p>
<p>여기서 세 가지가 따라 나옵니다.</p>
<p>첫째, &ldquo;스테이징 누가 쓰고 있어요&quot;가 더는 질문이 아닙니다. <code>feature-payments-retr-3f9a1c-dev.example.com</code>이 있고 <code>fix-login-redirect-8b21e0-dev.example.com</code>이 있으며, 각각 그 브랜치를 맡은 사람의 것입니다.</p>
<p>둘째, 스테이징이 쓰레기장이 아니라 프로덕션의 정직한 리허설이 됩니다. 프로덕션에 갈 바로 그 이미지로, 몇 분 전 프로덕션 모양의 스냅샷에서 복제한 데이터베이스를 상대로 만들어집니다. 거기서 되면 프로덕션에 남은 변수는 프로덕션 데이터뿐입니다.</p>
<p>셋째, 개발자마다 공유 자원의 root 권한 없이도 힘을 얻습니다. 브랜치 환경의 폭발 반경은 그 브랜치 환경입니다. 네 번째로 합류한 사람이 첫날에 자기 브랜치에 뭘 배포하든, 최악의 결과는 자기 브랜치가 망가지는 것입니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="조직의 Fly.io 대시보드: 기능 브랜치 앱 셋(둘은 이미 suspended)이 프로덕션 앱들과 공유 프리뷰 Postgres, Redis 옆에 나란히"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>브랜치 셋이 올라간 조직의 앱 목록. 셋 중 둘은 배포 1분 만에 이미 suspended 상태이고, 공유 프리뷰 Postgres와 Redis가 곁에 있습니다. 아래쪽 행들이 프로덕션 앱입니다.</figcaption>
</figure>


<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%ac%bc%eb%a6%ac%ec%a0%81%ec%9c%bc%eb%a1%9c%eb%8a%94-%ea%b3%b5%ec%9c%a0-%eb%85%bc%eb%a6%ac%ec%a0%81%ec%9c%bc%eb%a1%9c%eb%8a%94-%eb%b6%84%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>&ldquo;브랜치마다 환경&quot;의 솔깃한 버전은 통째로 복사하는 것입니다. Postgres 클러스터 하나씩, Redis 하나씩, 버킷 하나씩. 깔끔하지만 잘못된 거래입니다. Fly Postgres 클러스터는 머신 하나에 볼륨 하나에 복원 한 번이고, Redis는 머신 하나 더입니다. 환경마다 몇 분씩 기다리고 놀고 있는 데이터베이스 열두 개 값을 내게 됩니다. 스위퍼가 치울 것도 한 종류가 아니라 네 종류가 됩니다.</p>
<p>대신 각 공유 서비스는 프리뷰 조직 전체에 한 번만, 작은 프로덕션 크기로 마련하고, 모든 환경이 <code>$ENV</code>를 키로 한 조각씩 받습니다. 벽에 붙여 두고 싶은 표입니다.</p>
<table>
	<thead>
			<tr>
					<th>자원</th>
					<th>공유되는 것</th>
					<th>환경별인 것</th>
					<th>강제 수단</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>컴퓨트</td>
					<td>Fly 조직 <code>myapp-preview</code></td>
					<td>Fly 앱 하나 <code>myapp-$ENV</code>, 머신 하나</td>
					<td>Fly: 앱이 격리 단위</td>
			</tr>
			<tr>
					<td>시크릿</td>
					<td>Fly의 앱별 시크릿 저장소</td>
					<td>앱 자신의 시크릿. <code>SECRET_KEY</code>, <code>ENCRYPTION_KEY</code>, <code>ENV_TOKEN</code>은 복사가 아니라 환경별로 <em>생성</em></td>
					<td>Fly: 시크릿은 앱에 속하고, 머신은 자기 것만 읽음</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>Fly Postgres 클러스터 하나 <code>myapp-preview-pg</code></td>
					<td>데이터베이스 하나 <code>env_feature_payments_retr_3f9a1c</code>, <code>seed_template</code>에서 복제</td>
					<td>Postgres: 데이터베이스는 단단한 경계, 데이터베이스 간 쿼리 불가</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>공유 Redis 하나</td>
					<td>캐시와 Channels 레이어에 키 접두사 <code>feature-payments-retr-3f9a1c:</code></td>
					<td>Django 캐시의 <code>KEY_PREFIX</code>, 채널 레이어의 <code>prefix</code></td>
			</tr>
			<tr>
					<td>파일</td>
					<td>S3 호환 버킷 하나 <code>myapp-preview</code></td>
					<td>객체 접두사 <code>feature-payments-retr-3f9a1c/</code></td>
					<td>스토리지 백엔드의 <code>location</code>; presigned URL은 이미 키 단위로 범위가 잡힘</td>
			</tr>
			<tr>
					<td>호스트명</td>
					<td>존에 이미 있는 프록시된 <code>*.example.com</code> 레코드</td>
					<td>맨 왼쪽 라벨 <code>&lt;env&gt;-dev</code></td>
					<td><code>*-dev.example.com/*</code>에 걸린 Cloudflare Worker 라우트</td>
			</tr>
			<tr>
					<td>인증</td>
					<td>인증 서비스의 dev 프로젝트 하나</td>
					<td>없음. 사용자 신원은 일부러 공유 (아래 참고)</td>
					<td>승인된 도메인 <code>example.com</code></td>
			</tr>
			<tr>
					<td>오류와 로그</td>
					<td>에러 트래커, Fly 로그</td>
					<td>모든 것에 <code>environment=$ENV</code> 태그</td>
					<td>설정</td>
			</tr>
	</tbody>
</table>
<p>둘은 한마디씩 덧붙일 만합니다.</p>
<p><strong>Postgres: 스키마가 아니라 데이터베이스.</strong> 클러스터 하나를 자르는 다른 방법은 환경마다 스키마를 두고 연결마다 <code>search_path</code>를 설정하는 것입니다. 연결 <code>options</code>로 <code>-c search_path=...</code>를 밀어 넣으면 Django도 해 주지만, 트랜잭션 풀링 모드의 커넥션 풀러는 따로 말해 주지 않는 한 시작 옵션을 버리고, 그러고 나면 어차피 풀의 모든 연결이 한 환경에 속해야 합니다. &ldquo;왜 내 쿼리가 엉뚱한 테이블을 치지&quot;류의 버그를 낳는 원천이고, 환경마다 데이터베이스를 두면 그냥 없는 문제입니다. 데이터베이스는 단단한 경계이고, <code>CREATE DATABASE ... TEMPLATE</code>은 몇 초 만에 하나를 만들며, <code>DROP DATABASE</code>는 아무것도 남기지 않고 지웁니다. 브랜치 환경은 풀러를 거치지 않고 5433 포트로 Postgres에 직접 붙습니다. 프로덕션의 백그라운드 워커가 하는 것과 같은 방식입니다. 풀러는 프로덕션 동시성에서 제 값을 하는데, 브랜치 환경은 그런 동시성을 볼 일이 없습니다.</p>
<p><strong>시크릿: 공유가 아니라 생성.</strong> dev 시크릿 한 벌을 만들어 모든 환경에 복사하면 편할 겁니다. 하지 마세요. <code>env up</code> 스크립트는 환경마다 새 <code>SECRET_KEY</code>(세션 서명), <code>ENCRYPTION_KEY</code>(저장 시 암호화되는 모든 것), <code>ENV_TOKEN</code>(모바일 앱이 제시하는 bearer 토큰, 아래에서 더 설명)을 생성해 그 Fly 앱의 시크릿 저장소에만 넣습니다. 한 브랜치의 세션 쿠키는 다른 브랜치에서 쓸모없고, 유출된 프리뷰 토큰은 프리뷰 하나만 열며, 개발자는 일부러 찾아보지 않는 한 값을 볼 일이 없습니다. Fly만이 아니라 별도의 시크릿 매니저에 시크릿을 둔다면 같은 규칙이 적용됩니다. 경로는 <code>myapp/preview/$ENV/</code>이고, 스위퍼가 환경과 함께 그 경로를 지웁니다. 환경 간에 <em>정말로</em> 공유되는 시크릿은 공유 인프라 자체의 자격 증명(Postgres 관리자 URL, Redis URL, 버킷 키)뿐이고, 그건 어느 환경도 아닌 CI에 삽니다.</p>
<p>Redis 행이 가장 의심하기 쉬우니, 테스트해 봤습니다. 한 환경 안에서 캐시 키를 넣고, 다른 환경에서 읽고, 공유 Redis의 모든 키를 나열합니다.</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">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>같은 Redis, 같은 키 이름인데 다른 환경에서는 아무것도 보이지 않습니다.</p>
<p>이 글의 나머지는 저 여덟 행에 <code>$ENV</code>를 찍는 스크립트와, 그걸 정직하게 유지하는 규칙입니다.</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" 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="#scriptsenv-up" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>개발자가 자기 브랜치에서 실행하는 스크립트로서의 전부입니다. CI도 같은 스크립트를 실행합니다. 따로 놀다 어긋날 CI 전용 경로가 없습니다. 노트북에 필요한 건 <code>fly</code>와 <code>git</code>뿐입니다. SQL은 <code>fly ssh</code>로 Postgres 앱에 가고, 환경의 Redis 키와 버킷 객체는 없애기 전에 환경 자신의 머신이 지우므로, 노트북은 gitignore된 env 파일 말고는 공유 Redis나 버킷 자격 증명을 쥐고 있지 않습니다. 중요한 줄만 남겨 추리면 이렇습니다.</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>데모에서는 push하는 대신 제 노트북에서 직접 돌렸습니다. 브랜치 셋이 저장소 히스토리에 남기고 싶지 않은 일회용이었고, 출력이 흘러가는 걸 지켜보고 싶었기 때문입니다. 팀에서 쓸 때는 아무도 이걸 타이핑하지 않습니다. 아래에 나오는 라벨 워크플로가 정확히 이 스크립트를 돌리고, 지금부터 보이는 출력은 Actions 로그에 남을 그것입니다. 툴링 자체를 담은 브랜치에서 처음 실행했을 때 찍힌 출력입니다.</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">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>명령에서 URL까지 2분 30초쯤이고, 대부분은 이미지 빌드입니다. 커밋 하나 더 한 뒤 같은 브랜치에서 두 번째 <code>up</code>을 하니 &ldquo;New app created&quot;도 &ldquo;CREATE DATABASE&quot;도 찍히지 않았고, 같은 시크릿 이름을 스테이징하되 생성된 것들은 다시 만들지 않았으며, 다시 빌드했습니다. 같은 URL, 새 커밋.</p>
<p>안에 든 선택 몇 가지는 한 문장씩 값을 합니다.</p>
<p><strong><code>fly.preview.toml</code>은 <code>fly.toml</code>이 아닙니다.</strong> 프로덕션은 API, WebSocket 서버, 백그라운드 워커를 별도 Fly 앱과 프로세스 그룹으로 굴려 각자 자기 지표로 스케일하게 하고, 그게 스택 글의 논지 전부입니다. 브랜치 환경은 스케일할 필요가 없습니다. 머신 하나여야 합니다. 그래서 프리뷰 설정은 프로세스가 하나이고, 컨테이너 entrypoint에 프리뷰 모드가 생겨 마이그레이션을 돌린 뒤 WebSocket을 켠 ASGI 서버 <em>하나</em>를 띄웁니다. Channels가 이미 ASGI 애플리케이션 하나 안에서 HTTP와 WebSocket 트래픽을 모두 라우팅하니 프로세스 하나가 앱 전체를 서빙합니다. 그 모드에서 동기 뷰는 thread-sensitive로, 대략 한 번에 요청 하나씩 돕니다. 프로덕션에서 제가 벗어난 바로 그 거래인데, 여기서는 정확히 맞는 거래입니다. 브랜치를 테스트하는 사람은 아무도 눈치채지 못하니까요.</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="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>, <code>min_machines_running = 0</code>.</strong> 점심 이후 아무도 열지 않은 브랜치 환경은 비용이 0이어야 합니다. stop 대신 suspend를 쓰면 30초 콜드 스타트 대신 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >504를 쫓다가</a> 도달한 3초쯤의 재개를 얻는데, 여기서는 30초라도 견딜 만합니다. 데모 환경 셋 중 둘은 배포가 끝나고 1분 만에 <code>fly apps list</code>에서 이미 <code>suspended</code>였습니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="브랜치 앱 하나의 Fly.io 개요: SYD의 shared-cpu-1x 머신 하나, 프로세스 그룹 하나, 자체 *.fly.dev 호스트명"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Fly가 보는 브랜치 환경 하나: 머신 하나, 프로세스 그룹 하나, 리전 하나. 스케일할 가치가 있는 것이 아무것도 없습니다.</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>, 프로덕션과 같은 settings 모듈.</strong> 브랜치 환경은 공개 URL입니다. 테스트에 중요한 모든 면(진짜 HTTPS, 진짜 쿠키, 같은 헬스 체크)에서 프로덕션처럼 굴고, 누군가를 다치게 할 수 있는 모든 면에서는 프로덕션과 달라야 합니다. 별도의 프리뷰용 settings 파일 대신, 차이는 <code>env up</code>이 설정하는 시크릿이 실어 나릅니다. 샌드박스 결제 키, 인증 서비스 관리자 자격 증명 없음, 그리고 아래의 모든 접두사를 켜는 <code>ENV_NAME</code>.</p>
<p><strong>설정 하나가 모든 것을 자릅니다.</strong> <code>ENV_NAME</code>은 settings에 새로 들어간 유일한 손잡이이고, 네 곳에 적용됩니다.</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">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>channels 쪽을 빠뜨리면 두 브랜치의 WebSocket 서버가 그룹 이름을 공유해서, 한 환경에서 보낸 메시지가 다른 환경에 나타납니다. 하루를 잡아먹는 종류의 버그입니다.</p>
<p>다섯 번째 자리는 나중에 공유 Redis를 읽어 보고서야 찾았습니다. 504 글의 시작 락, 즉 이미지당 머신 하나가 마이그레이션을 돌리는 동안 나머지가 기다리게 하는 장치는 이미지 참조를 키로 삼고 있었습니다. 같은 커밋에서 빌드된 브랜치 환경 둘은 같은 이미지를 갖습니다. 두 번째 환경은 첫 번째의 &ldquo;done&rdquo; 마커를 발견하고, 마이그레이션되지 않은 자기 데이터베이스에 대한 자기 마이그레이션을 건너뛰었을 겁니다. 지금은 환경 이름이 키에 들어가고 키는 접두사 아래에 삽니다. 공유 인프라 설계가 계속 만들어 내는 종류의 문제입니다. 암묵적으로 &ldquo;배포당&quot;이던 모든 것을 명시적으로 환경당으로 만들어야 하고, 뭐가 암묵적이었는지는 들여다보기 전까지 모릅니다.</p>
<p><strong>데이터베이스는 빈 상태에서 마이그레이션하는 게 아니라 복제합니다.</strong> 다음 절입니다.</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%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4-%eb%aa%87-%ec%b4%88-%eb%a7%8c%ec%97%90-%eb%b3%b5%ec%a0%9c%eb%90%98%eb%8a%94-%ed%85%9c%ed%94%8c%eb%a6%bf" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>브랜치 환경의 데이터베이스는 셋 중 하나일 수 있습니다. 모두와 공유. 우리를 여기까지 데려온 방식입니다. 빈 상태에서 마이그레이션하고 픽스처로 시드. 결정론적이지만, 픽스처는 실제 모양의 데이터만큼 좋았던 적이 없고 언제나 열여덟 달은 묵어 있습니다. 아니면 프로덕션 모양 스냅샷의 복제. 현실적인 행 수, 픽스처는 늘 채워져 있다고 가정했던 컬럼의 현실적인 null, 빈 테이블에서는 즉시 끝나지만 실제 테이블에서는 40분 걸리는 마이그레이션이 제 모습을 드러냅니다.</p>
<p>세 번째로 가되, 두 번째는 CI 전용 검사로 남깁니다. 그리고 세 번째를 브랜치마다 할 만큼 빠르게 만드는 건 대부분이 있는지도 잊은 Postgres 기능입니다.</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="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</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="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code>은 클러스터 안에서의 파일 수준 복사입니다. 몇 기가바이트면 <code>pg_restore</code>가 걸리는 몇 분이 아니라 몇 초입니다. 복제되는 동안 템플릿에는 열린 연결이 있으면 안 되고, <code>datallowconn = false</code>가 그걸 강제합니다. 밤 작업이 살아 있는 템플릿 위에 복원하는 대신 <code>seed_raw</code>에 빌드하고 마지막에 이름을 바꾸는 이유이기도 합니다.</p>
<p>데모에서는 프로덕션 덤프 대신 손으로 템플릿을 만들었습니다. <code>fly proxy</code>로 프리뷰 클러스터에 터널을 뚫고, <code>manage.py migrate</code>, 그다음 저장소에 있는 시드 명령으로 작지만 현실적인 데이터 세트를 넣었습니다. 19메가바이트. 브랜치 셋이 올라오면서 클러스터에 복제본 셋이 나타났고, 각각 19 MB, 각각 1초 미만이었습니다.</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">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>프리뷰 클러스터는 관리형이 아닌 Fly Postgres 노드 하나, 1 GB 볼륨을 단 <code>shared-cpu-1x</code>로, 한 달에 2달러쯤입니다. 브랜치 환경은 사설 네트워크의 <code>.flycast</code>로 붙습니다. 공개된 것은 아무것도 없습니다.</p>
<p>익명화기는 선택이 아닙니다. 프로덕션 데이터가 <code>-dev</code> 호스트명에서 닿는 순간, 그건 프로덕션 데이터가 아니어야 합니다. 이름, 이메일, 전화번호, 주소, 자유 텍스트, 결제 참조, 토큰을 이름을 바꾸기 전에 전부 덮어씁니다. 익명화기가 실패하면 전날 밤 템플릿이 남고 누군가 메시지를 받습니다. 작은 일이고, 이 접근 전체를 개인정보 설문에 답하는 사람이 받아들일 수 있게 만드는 바로 그 일입니다.</p>
<p>entrypoint가 모든 이미지의 첫 부팅에서 <code>migrate</code>를 돌리므로, 브랜치 자신의 마이그레이션이 템플릿 위에 적용됩니다. &ldquo;내 마이그레이션이 프로덕션 모양의 데이터에서 되나&quot;의 첫 정직한 테스트이고, 누가 URL을 열어 보기 전에 일어납니다.</p>

<h2 class="relative group">호스트명: 와일드카드 하나, Worker 하나, 그리고 문서가 알려주지 않은 두 가지
    <div id="호스트명-와일드카드-하나-worker-하나-그리고-문서가-알려주지-않은-두-가지" 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%98%b8%ec%8a%a4%ed%8a%b8%eb%aa%85-%ec%99%80%ec%9d%bc%eb%93%9c%ec%b9%b4%eb%93%9c-%ed%95%98%eb%82%98-worker-%ed%95%98%eb%82%98-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%ac%b8%ec%84%9c%ea%b0%80-%ec%95%8c%eb%a0%a4%ec%a3%bc%ec%a7%80-%ec%95%8a%ec%9d%80-%eb%91%90-%ea%b0%80%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>모든 환경에는 공개 HTTPS 호스트명이 필요하고, &ldquo;공개&quot;가 핵심입니다. 폰을 든 테스터, 다른 네트워크의 디자이너, VPN을 깔지 않을 프로덕트 매니저. Fly는 앱마다 <code>myapp-$ENV.fly.dev</code>를 공짜로 주고, 그것만으로도 됩니다. 하지만 <code>*.fly.dev</code> 호스트명에는 문제가 셋 있습니다. 보내기에 못생겼고, 앞에 Cloudflare가 없어서 프로덕션에 걸어 둔 보호가 하나도 적용되지 않으며, 자기 도메인에 두고 싶으면 환경마다 DNS 레코드 하나와 인증서 하나를 만들어야 하니 스위퍼가 치울 것이 하나 더 생깁니다.</p>
<p>그걸 전부 없애는 버전은 와일드카드 하나와 Cloudflare Worker 하나입니다. 계획은 <code>*.dev.example.com</code>이었습니다. 두 가지가 막았고, 같은 걸 계획하기 전에 둘 다 알아 둘 가치가 있습니다.</p>
<p><strong>Universal SSL은 와일드카드 한 단계만 덮습니다.</strong> Cloudflare의 무료 인증서는 <code>example.com</code>과 <code>*.example.com</code>에 발급됩니다. <code>*.dev.example.com</code>은 덮지 않습니다. 두 단계 와일드카드에는 월 10달러의 Advanced Certificate Manager가 필요합니다. <code>feature-branch-envir-c65cb4.dev.example.com</code>으로의 첫 요청은 제 것이 무엇 하나 돌기도 전에 SSL 핸드셰이크 실패로 죽었습니다. 해결은 환경을 첫 라벨에 두는 것입니다. <code>feature-branch-envir-c65cb4-dev.example.com</code>. 이미 있는 인증서가 덮고, 이미 있는 프록시된 <code>*.example.com</code> 레코드가 덮습니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >지어낸 서브도메인을 훑는 스캐너</a>용 싱크홀로 제가 만들어 둔 그 레코드입니다. 그러니 환경에는 DNS가 아예 필요 없습니다.</p>
<p><strong>Redirect Rules는 Workers보다 먼저 돕니다.</strong> 라우트를 걸어 두자 모든 <code>-dev</code> 호스트명이 Worker가 돌지도 않은 채 www 홈페이지로 301을 답했습니다. 싱크홀은 존 수준 리다이렉트 규칙 &ldquo;그 밖의 모든 서브도메인 → www&quot;이고, Cloudflare의 리다이렉트 단계는 Workers 단계보다 먼저 실행됩니다. 규칙에 절 하나가 필요했습니다. <code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>. 배포 스크립트가 <code>up</code>에서 넣고 <code>down</code>에서 빼는데, 누군가 손으로 고친 규칙이야말로 잊히는 것이기 때문입니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Cloudflare 대시보드의 리다이렉트 규칙, 표현식이 -dev 예외로 끝남"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>스크립트가 손댄 뒤의 싱크홀 리다이렉트. 존 아래 모든 것이 여전히 www로 튕기되, <code>-dev.example.com</code>으로 끝나는 호스트명만 Worker로 흘러갑니다.</figcaption>
</figure>

<p>DNS 쪽은 이미 있던 그 레코드입니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="와일드카드로 필터한 존의 DNS 레코드: 192.0.2.1을 가리키는 존 와일드카드용 프록시된 A 레코드 하나"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>관련된 유일한 DNS 레코드이고, 이 글보다 먼저 있었습니다. 자리표시 주소를 가리키는 프록시된 와일드카드. Cloudflare가 응답하고, 그다음은 규칙과 Workers의 몫입니다.</figcaption>
</figure>

<p>그 둘을 치우고 나면 라우팅은 <code>*-dev.example.com/*</code>에 걸린 Worker 하나입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</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">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</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">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#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">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</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></code></pre></div></div>
<p>Cloudflare가 와일드카드에서 TLS를 종단하고, Worker가 라벨에서 Fly 호스트명을 계산하며, 머신까지의 구간은 Fly 자체의 <code>*.fly.dev</code> 인증서가 덮습니다. WebSocket 업그레이드는 <code>fetch</code>를 그대로 통과합니다. 그리고 라우트가 존에 있으니 Cloudflare가 프로덕션에 해 주는 모든 것을 쓸 수 있습니다. WAF 규칙, 속도 제한, 봇 대응 모드, 그리고 Access.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="존의 Cloudflare Workers Routes 페이지: dev-router Worker에 묶인 라우트 *-dev.example.com/* 하나"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>환경의 Cloudflare 쪽 전부: 라우트 패턴 하나, Worker 하나. 이 대시보드 어디에도 환경 이름은 없습니다.</figcaption>
</figure>

<p>브랜치 셋을 동시에 띄우고, Worker 하나, 각 호스트명이 답한 것.</p>
<table>
	<thead>
			<tr>
					<th>호스트명</th>
					<th>HTTP</th>
					<th>라우팅 대상</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>없음. Worker가 라벨을 거부</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>모든 200에는 Worker가 붙인 <code>X-Robots-Tag: noindex, nofollow</code>와 <code>X-Dev-Environment: &lt;env&gt;</code>가 함께 왔습니다. 404는 Fly에 묻기도 전에 Worker가 직접 낸 것입니다.</p>
<p>Cloudflare Access는 &ldquo;공개&quot;를 &ldquo;닿아야 할 사람에게 닿음&quot;으로 바꾸는 장치입니다. <code>*-dev.example.com</code>에 애플리케이션 하나, &ldquo;@example.com 이메일이면 누구나&rdquo; 정책 하나면 브라우저 방문자는 로그인 페이지를 한 번 보고 다시는 신경 쓰지 않습니다. 노트북의 디자이너, PM, 테스터는 그걸로 됩니다. 데모에는 넣지 않았습니다. 환경은 한 시간 동안 <code>noindex</code>를 달고 시드 데이터 말고는 아무것도 없이 떠 있었습니다.</p>
<p>모바일 앱은 덮지 못합니다. 네이티브 앱은 Access 로그인 리다이렉트를 완료할 수 없으니까요. 길은 둘입니다. Access는 로그인을 우회하는 서비스 토큰(<code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code> 헤더 쌍)을 지원하고, 모바일 앱의 dev 메뉴가 dev 호스트명용 한 쌍을 실을 수 있습니다. 아니면 더 단순하게, <code>/api/</code>에 Access 우회를 넣고 Django가 로그인한 사용자나 환경의 <code>ENV_TOKEN</code>, 즉 <code>env up</code>이 바로 이 환경을 위해 생성한 그 토큰이 없는 요청을 거부하게 합니다. 어느 쪽이든 모바일 쪽은 기본 URL과 토큰 두 필드가 있는 dev 메뉴 화면이고, 테스터가 이미 설치한 앱이 이제 그 브랜치와 통신합니다. 새 빌드는 없습니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/" >OTA 업데이트 채널</a>은 앱이 빌드된 대로 그대로 두고, API 기본 URL은 빌드 시점이 아니라 런타임 설정이며, 원래 그랬어야 합니다.</p>
<p>인증은 일부러 공유하는 유일한 공유 서비스입니다. 신원은 환경에 따라 다르지 않습니다. 테스터는 모든 브랜치에서 같은 계정으로 로그인하고 싶어 합니다. 승인된 도메인에 <code>example.com</code>을 넣은 인증 서비스의 dev 프로젝트 하나가 모든 <code>-dev</code> 호스트명을 덮고, 모든 환경이 그걸 상대로 ID 토큰을 검증합니다. 각 환경 데이터베이스의 사용자 테이블은 템플릿 사용자의 복사본이니, 테스터의 계정은 템플릿에 있던 모든 곳에 있습니다.</p>

<h2 class="relative group">GitHub Actions로 프리뷰 환경 자동화하기: 이벤트가 들어가고 환경이 나온다
    <div id="github-actions로-프리뷰-환경-자동화하기-이벤트가-들어가고-환경이-나온다" 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="#github-actions%eb%a1%9c-%ed%94%84%eb%a6%ac%eb%b7%b0-%ed%99%98%ea%b2%bd-%ec%9e%90%eb%8f%99%ed%99%94%ed%95%98%ea%b8%b0-%ec%9d%b4%eb%b2%a4%ed%8a%b8%ea%b0%80-%eb%93%a4%ec%96%b4%ea%b0%80%ea%b3%a0-%ed%99%98%ea%b2%bd%ec%9d%b4-%eb%82%98%ec%98%a8%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>위의 모든 것을 손으로 하는 건 혼자 할 때는 괜찮습니다. 두 번째 개발자가 생기는 순간부터는 누가 기억해서가 아니라 git에서 뭔가 일어났기 때문에 일어나야 합니다. 자동화 전체는 워크플로 넷, 저장소 설정 하나, 룰셋 하나, GitHub environment 하나이고, 그 하나하나가 git 이벤트를 개발자가 손으로 돌리는 것과 같은 <code>scripts/env</code> 호출에 대응시킵니다. 따로 놀다 어긋날 CI 전용 경로는 없습니다.</p>
<table>
	<thead>
			<tr>
					<th>Git 이벤트</th>
					<th>실행되는 것</th>
					<th>하는 일</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR에 <code>env</code> 라벨이 붙음</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>그 브랜치의 환경을 만들고, PR에 URL을 남김</td>
			</tr>
			<tr>
					<td>라벨 붙은 PR의 브랜치에 push</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>제자리에서 갱신</td>
			</tr>
			<tr>
					<td>라벨 제거, PR 닫힘 또는 머지</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>환경이 자기 조각을 치우고 없어짐</td>
			</tr>
			<tr>
					<td>브랜치 삭제</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>손으로 띄웠고 PR이 없던 브랜치를 위한 뒷받침; 저장소 설정 <em>delete branch on merge</em>가 머지 때도 이걸 발생시킴</td>
			</tr>
			<tr>
					<td>그 외 브랜치에 push</td>
					<td>아무것도</td>
					<td>백업 push나 스파이크는 누군가 보고 싶다고 하기 전까지 비용이 없음</td>
			</tr>
			<tr>
					<td><code>main</code>으로의 pull request</td>
					<td><code>migrations-check.yml</code></td>
					<td><code>main</code>을 머지한 뒤 마이그레이션 그래프의 리프가 하나인지; 빈 상태에서 전체 체인이 적용되는지</td>
			</tr>
			<tr>
					<td><code>v*</code> 태그 push</td>
					<td><code>release.yml</code></td>
					<td>태그에서 스테이징 환경; 리뷰어의 승인이 같은 이미지를 프로덕션으로 승격하고 스테이징을 없앰</td>
			</tr>
			<tr>
					<td>매일 밤 02:00</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>브랜치가 사라졌거나 일주일 놀고 있는 것을 전부 없앰</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">라벨, push, delete 워크플로
    <div id="라벨-push-delete-워크플로" 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%9d%bc%eb%b2%a8-push-delete-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</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="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>게이트는 <code>env</code> 라벨이고, 제가 가장 강하게 주장하고 싶은 부분입니다. 게이트가 없으면 모든 브랜치가 앱 하나와 데이터베이스 복제본 하나를 비용으로 치릅니다. 노트북 백업용으로 push한 브랜치, 내일 squash될 스파이크, 봇이 새벽 3시에 연 의존성 업데이트까지요. 그래서 push만으로는 아무 일도 일어나지 않고, PR을 여는 것만으로도 아무 일도 일어나지 않습니다. PR에 <code>env</code>를 붙이면 환경이 올라오고, 라벨이 붙어 있는 동안의 push마다 갱신되고, 라벨을 떼거나 PR을 닫거나 머지하면 내려갑니다. 옵트인은 여전히 GitHub 이벤트이지 사람에게 하는 부탁이 아니고, 누가 볼 건지 아는 유일한 사람이 하는 클릭 한 번입니다. 고정 댓글이 사용자 인터페이스입니다. PR에 라벨을 붙이면 누구에게든 보낼 수 있는 URL이 담긴 댓글이 나타나고, push마다 쌓이는 대신 제자리에서 갱신됩니다. PR이 생기기 전에 올리고 싶은 브랜치는 <code>workflow_dispatch</code>가, 나머지는 노트북에서 돌리는 <code>./scripts/env up</code>이 맡습니다.</p>
<p>알아 둘 만한 게이트가 둘 더 있습니다. 브랜치 이름 필터(<code>branches: ['feature/**', 'fix/**']</code>)는 PR이 필요 없지만 모든 기능 브랜치가 비용을 치릅니다. &ldquo;드래프트가 아닌 모든 PR&quot;은 가장 자동적이고, 팀이 PR을 늦게, 준비됐을 때만 연다면 맞는 선택입니다. 저는 라벨을 골랐습니다. 비용 결정을 맥락을 아는 사람에게 두고, 아무것도 닫지 않고 되돌릴 수 있기 때문입니다. 마음에 드는 부작용 하나. 포크에서 온 <code>pull_request</code> 실행은 시크릿을 받지 못하니, 포크는 환경을 띄울 수 없습니다.</p>
<p><code>concurrency</code>는 보기보다 중요합니다. 한 브랜치에 push가 연달아 오면 앞선 배포를 경주시키는 대신 취소합니다. 같은 앱에 <code>fly deploy</code> 둘이 동시에 돌면 하나가 지는 걸로 끝납니다.</p>
<p>PR이 있던 것은 머지됐든 아니든 <code>closed</code>가 철거를 맡습니다. <code>delete</code> 이벤트는 <code>workflow_dispatch</code>로 띄웠는데 끝내 PR이 생기지 않은 브랜치를 위한 뒷받침이고, 브랜치가 실제로 삭제될 때만 발생합니다. 그건 저장소 설정 <em>Automatically delete head branches</em>이고, 기본은 꺼져 있습니다. 켜 두면 Merge를 누르는 것이 PR을 닫고 브랜치를 삭제하니, 두 이벤트 중 어느 하나만으로도 <code>env down</code>이 돌았을 겁니다. 머지 버튼이 철거입니다.</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">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code>이 파일에서 위험한 줄입니다. 앱을 만들고 없앨 수 있으니, <code>$APP</code>이 브랜치 환경이 아닌 무언가가 되는 순간 프로덕션을 없앨 수도 있습니다. 가드는 둘이고 둘 다 두겠습니다. 토큰을 별도의 Fly 조직에 한정해 프로덕션 조직을 말 그대로 볼 수 없게 하는 것(데모에서는 새 조직에 결제 정보를 붙이는 걸 건너뛰려고 프로덕션 조직을 그대로 썼고, 두 번째 가드 하나에 기댔습니다), 그리고 <code>down()</code>이 <code>slug-hash</code> 패턴에 맞지 않는 앱 이름을 전부 거부하는 것.</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="#%eb%b8%8c%eb%9e%9c%ec%b9%98-%ed%99%98%ea%b2%bd%ec%9d%b4-%ec%8a%a4%ec%8a%a4%eb%a1%9c-%ed%95%a0-%ec%88%98-%ec%97%86%eb%8a%94-%ea%b2%80%ec%82%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치는 자기 마이그레이션만 봅니다. 그래서 중요한 검사는 pull request에서 <code>main</code>을 머지한 상태를 상대로 돌고, <code>main</code>이 요구하는 유일한 상태 검사입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>30초. <code>0042</code> / <code>0042</code> 충돌을 릴리스 당일의 놀람에서 두 번째 개발자 PR의 빨간 체크로 바꿔 주는 것이 이겁니다. 아래 마이그레이션 절에서 실제로 걸리는 모습을 보여줍니다.</p>

<h3 class="relative group"><code>main</code>의 규칙
    <div id="main의-규칙" 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="#main%ec%9d%98-%ea%b7%9c%ec%b9%99" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치가 한 달을 살거나 누군가 <code>main</code>에 직접 push하면 이 중 무엇도 버티지 못합니다. 규칙은 보호라기보다 환경을 작게, 마이그레이션을 최신으로 유지하기 위한 것입니다.</p>
<ul>
<li><strong><code>main</code>은 보호되고 언제나 배포 가능합니다.</strong> <em>현재 <code>main</code>을 기준으로</em> <code>migrations</code> 검사가 초록인 PR로만 머지합니다(strict 상태 검사이니, 지난주에 초록이던 PR은 <code>main</code>이 움직이면 다시 돕니다). 선형 이력, force-push 금지, 삭제 금지.</li>
<li><strong>기능 브랜치는 짧습니다.</strong> 2주 열려 있는 브랜치는 <code>main</code>보다 2주 뒤처진 환경과, 남의 것과 충돌할 확률이 2주치 더 높은 마이그레이션을 갖습니다. 한 달짜리 기능은 플래그 뒤에서 조각조각 <code>main</code>에 머지합니다. 오래 사는 건 플래그이지 브랜치가 아닙니다.</li>
<li><strong>브랜치 하나, 환경 하나, 데이터베이스 하나.</strong> 이미 이름이 강제합니다.</li>
<li><strong>프로덕션에 가는 건 릴리스 태그뿐입니다.</strong> 이미지는 태그에서 한 번 빌드되고, 스테이징과 프로덕션은 그 이미지를 돌립니다. 다시 빌드하지 않습니다.</li>
<li><strong><code>migrations/</code>를 건드리는 PR은 나머지 둘 중 하나가 리뷰합니다.</strong> 아래에.</li>
</ul>
<p>룰셋으로는, <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code>으로 한 번 적용합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</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="p">}</span></span></span></code></pre></div></div>
<p>솔직한 주름 하나. 이 저장소는 GitHub Free 플랜의 비공개 저장소이고, Free에서 브랜치 보호와 룰셋은 공개 저장소에서만 쓸 수 있습니다. API는 <code>403 Upgrade to GitHub Pro or make this repository public</code>이라고 답합니다. <code>delete_branch_on_merge</code> 설정, environment, 워크플로는 Free에서도 전부 됩니다. 그러니 비공개 Free 저장소의 작은 팀에게 위 룰셋은 관례입니다. <code>migrations</code> 워크플로는 여전히 돌고 여전히 빨개지지만, 머지 버튼을 회색으로 만드는 것은 없습니다. 월 4달러짜리 결정이고, 저라면 두 번째 사람이 머지할 수 있게 되는 순간 하겠습니다.</p>
<p><code>develop</code> 브랜치도 <code>release/*</code> 브랜치도 없습니다. Git-flow는 통합이 비싸서 묶어서 해야 한다는 전제로 설계됐습니다. 브랜치 환경이 통합을 싸게 만드니, 묶음이 사라집니다.</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%8a%a4%ed%85%8c%ec%9d%b4%ec%a7%95%ec%9d%80-%ed%83%9c%ea%b7%b8%ec%9d%98-%ed%99%98%ea%b2%bd%ec%9d%b4%ea%b3%a0-%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98%ec%9d%80-%ec%8a%b9%ea%b2%a9%ec%9d%b4%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>스테이징은 장치 전부를 재사용합니다. <code>main</code>의 <code>v*</code> 태그가 브랜치 이름 <code>staging/v1.42.0</code>으로 <code>scripts/env up</code>을 돌리고, 이름 규칙이 그걸 <code>staging-v1-42-0-&lt;hash&gt;</code>로 바꿉니다. 같은 템플릿 복제 데이터베이스, 같은 놀면 suspend되는 머신, 같은 호스트명 체계. 차이는 그 뒤에 오는 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p><code>production</code> 잡은 필수 리뷰어가 있는 GitHub environment를 기다립니다. 누군가 스테이징을 보고 Approve를 누르면, 프로덕션은 스테이징이 돌린 <em>같은 이미지</em>를 참조로 받습니다. 다시 빌드하지 않습니다. 마지막 단계가 스테이징을 없애는 건 제 할 일을 다 했기 때문입니다. 아무도 승인하지 않으면 릴리스 후보는 거절된 것이고, 스위퍼가 그 &ldquo;브랜치&rdquo;(<code>staging/v1.42.0</code>은 origin에 존재한 적이 없습니다)가 생존 검사에 실패할 때 스테이징을 치웁니다. 스위퍼가 확인할 수 있는 이유 없이는 아무것도 존재할 수 없습니다.</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="#%eb%b0%a4%eb%a7%88%eb%8b%a4-%eb%8f%84%eb%8a%94-%ec%8a%a4%ec%9c%84%ed%8d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code>은 스위퍼가 Actions가 기본으로 체크아웃하는 커밋 하나가 아니라 모든 원격 브랜치와 마지막 커밋 날짜를 필요로 하기 때문입니다. <code>workflow_dispatch</code>는 사고 뒤에 손으로 돌릴 수 있게 하려는 것입니다.</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="#%ea%b0%9c%eb%b0%9c%ec%9e%90%ea%b0%80-%ec%8b%a4%ec%a0%9c%eb%a1%9c-%ed%95%98%eb%8a%94-%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치를 push하고, PR을 열고, 누군가 봐 줬으면 할 때 <code>env</code> 라벨을 붙입니다. 목록은 그게 전부입니다. 3분 안에 PR에 URL이 나타나고, push마다 갱신되고, PR이 머지되거나 닫히면 사라집니다. 팀원과 충돌했다면 마이그레이션 검사가 누구보다 먼저 알려줍니다. 차지할 스테이징이 없으니 아무도 스테이징을 차지하지 않고, 철거가 곧 머지이니 아무도 뭘 철거하지 않습니다.</p>
<p>그리고 이걸 세팅한 사람이 그 뒤로 브랜치마다 하는 일은, 없습니다. 공유 Postgres, Redis, 버킷은 한 번 프로비저닝하고, 시크릿은 Actions에 한 번 넣고, 그 뒤로 반복되는 일이라곤 월요일에 스위퍼 로그를 읽는 것뿐입니다. 티켓 큐도, 불러야 할 봇도, 프롬프트를 넣을 대상도 없습니다.</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%b2%a0%ea%b1%b0%eb%a5%bc-%eb%af%bf%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%b2%a0%ea%b1%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><code>env down</code>이 보통의 경우이고, 흥미로운 부분은 <em>누가</em> 치우느냐입니다. Redis 키와 버킷 객체는 공유 서비스의 것이고, <code>down</code>을 돌리는 노트북은 어느 쪽 자격 증명도 필요 없습니다. 환경 자신의 머신이 이미 그걸 갖고 있고 자기 접두사를 이미 알기 때문입니다. 그래서 <code>down</code>은 머신이 suspend돼 있으면 깨우고, 거기서 작은 정리 명령을 돌린 다음에야 앱을 없애고 데이터베이스를 드롭합니다. 환경이 스스로 뒷정리를 합니다.</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">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>530은 조정에 관한 요점을 다시 보여줍니다. Worker는 여전히 호스트명을 라우팅하고, Fly에는 아무것도 없으며, 아무도 Cloudflare에 알릴 필요가 없었습니다.</p>
<p>정리 명령은 <code>ENV_NAME</code> 없이는 실행을 거부합니다. 접두사가 비어 있으면 공유 버킷을 통째로 지우는 일이 되니까요. 그 가드가 이게 셸 스크립트 세 줄이 아니라 앱 안의 명령으로 존재하는 이유 전부입니다.</p>
<p>그래도 환경은 샙니다. GitHub Actions가 장애인 사이에 브랜치가 삭제됩니다. 개발자가 실험용으로 노트북에서 <code>env up</code>을 돌리고 push는 영영 안 합니다. <code>fly apps destroy</code>가 일시적으로 실패하고 <code>|| true</code>가 삼킵니다. 석 달 뒤 앱이 마흔 개이고, 누군가 무슨 일이냐고 묻게 만드는 청구서가 옵니다.</p>
<p>그래서 첫 번째를 믿지 않는 두 번째 장치가 있습니다. 위 표의 밤마다 도는 스위퍼입니다. 모든 <code>myapp-*</code> 앱을 나열하고, 각각이 어느 브랜치 것인지 알아내고, 브랜치가 사라졌거나 7일간 push가 없는 것을 전부 없앱니다. 일주일 논 브랜치에 살아 있는 URL은 필요 없고, 다음 push가 2분 만에 다시 만듭니다. 해시는 되돌릴 수 없으니, 스위퍼는 살아 있는 모든 브랜치의 환경 이름을 계산해 그 집합에 없는 앱을 전부 없앤 뒤, 뒤에 앱이 없는 <code>env_*</code> 데이터베이스를 드롭합니다.</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>데모 브랜치는 어느 것도 push된 적이 없으니(위의 노트북 실행), 스위퍼 입장에서는 전부 사라진 것이었습니다. 누군가 노트북에서 띄우고 끝내 push하지 않은 스파이크가 일주일 뒤에 놓일 상태가 정확히 이것입니다. 남은 둘을, tagline 환경이 써 둔 Redis 키까지 포함해 해체했습니다.</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">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>(작게 문 것 하나. macOS에는 연관 배열이 없는 bash 3이 딸려 옵니다. 스위퍼 첫 버전은 그걸 썼다가 제 노트북에서 <code>declare -A</code>에서 죽었습니다. 위 버전은 줄바꿈으로 구분한 목록을 써서 어디서든 돕니다.)</p>
<p>비용은 즐거운 부분입니다. suspend된 머신은 rootfs 값만 내고, 한 달에 몇 센트입니다. 데이터베이스는 이미 값을 치른 볼륨 위의 19 MB입니다. Redis는 종량제 플랜으로, 명령 10만 개에 20센트인데 브랜치 테스트 규모에서는 0으로 반올림됩니다. 버킷 접두사는 저장한 만큼 냅니다. 공유 Postgres 노드가 유일한 고정 항목으로, 한 달에 2달러쯤입니다. 활성 브랜치 스무 개는 항상 켜진 <code>shared-cpu-1x</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="#%eb%b8%8c%eb%9e%9c%ec%b9%98%eb%a5%bc-%ea%b0%80%eb%a1%9c%ec%a7%80%eb%a5%b4%eb%8a%94-%eb%a7%88%ec%9d%b4%ea%b7%b8%eb%a0%88%ec%9d%b4%ec%85%98-%ea%b2%bd%ea%b3%a0%ed%95%b4-%ec%a4%84-%ea%b3%b5%ec%9c%a0-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4-%ec%97%86%ec%9d%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>개발자마다 기능을 처음부터 끝까지 맡으면 모든 브랜치에 마이그레이션이 들어 있습니다. 그래서 이 절이 중요한 절입니다.</p>
<p>공유 환경 세계를 떠날 때 사람들이 놓치는 것이 있습니다. 모두가 dev 데이터베이스 하나에 배포할 때 마이그레이션 충돌은 즉시, 아프게 드러났지만, 어쨌든 <em>드러났습니다</em>. 마이그레이션 하나가 돌고, 다음 배포가 실패하고, 누군가 그날 오후에 고쳤습니다. 브랜치마다 데이터베이스가 있으면 각 마이그레이션은 자기 환경에서 완벽하게 돌고, 둘은 두 번째가 <code>main</code>에 머지될 때 처음 만납니다. 부주의하게 하면 충돌이 싸던 dev에서 싸지 않은 릴리스로 옮겨 갑니다.</p>
<p>규칙 넷, 전부 CI로 강제할 수 있어서 아무도 기억할 필요가 없습니다.</p>
<p><strong>1. <code>main</code>을 머지한 상태에서 마이그레이션 그래프에 리프가 둘이면 CI가 실패합니다.</strong> <code>makemigrations --check</code>는 모델과 마이그레이션이 어긋난 걸 잡지만, 중요한 충돌은 두 브랜치가 둘 다 <code>0042_*</code>를 추가한 것입니다. 브랜치 단독이 아니라 <code>main</code>에 머지된 브랜치를 상대로 확인합니다.</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">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>이건 제가 일부러 일으킨 것입니다. 데모 브랜치 셋 중 둘, <code>feature/profile-tagline</code>과 <code>feature/profile-website</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">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>둘 다 <code>0042_*</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">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>두 번째 작성자가 자기 브랜치에서, 자기 환경에서, 아무도 귀찮게 하지 않고 그 <code>--merge</code>를 돌리면, 다음 <code>env up</code>이 자기 복제본 위에 <code>0043</code>을 적용합니다. (이 경우 git이 먼저 텍스트 충돌도 표시했습니다. 두 필드가 모델 파일의 같은 줄에 추가됐기 때문입니다. 현실적이고, 텍스트 충돌이 해소되기 전까지 마이그레이션 충돌을 가린다는 걸 알아 둘 가치가 있습니다.)</p>
<p><strong>2. 마이그레이션은 이전 릴리스의 코드와 하위 호환됩니다.</strong> 배포 중에는 새 스키마와 옛 코드가 공존하는 창이 있고, 머신이 롤백되면 그 반대도 있습니다. 컬럼은 nullable이거나 기본값을 갖게 추가하고, 한 번에 이름을 바꾸지 않으며, 현재 릴리스가 여전히 읽는 것은 절대 드롭하지 않습니다. 추가하고, 배포하고, 백필하고, 코드를 바꾸고, 나중 릴리스에서 드롭합니다. expand and contract입니다. 여기서 <em>더</em> 중요한 이유는 일회용 환경이 배포를 잦게 만들어서 그 창 안에 더 자주 들어가기 때문입니다. 태그별 스테이징이 그걸 리허설하는 곳입니다.</p>
<p><strong>3. CI는 빈 데이터베이스에 0부터 마이그레이션도 합니다.</strong> 템플릿 복제는 &ldquo;내 마이그레이션이 현실적인 데이터에 적용되나&quot;를 테스트합니다. 빈 실행은 &ldquo;전체 체인이 여전히 무에서 되나&quot;를 테스트하고, 이전 데이터 마이그레이션이 뭔가 채워 놨다고 가정한 마이그레이션을 잡습니다. 30초이고, 그러지 않으면 다음 새 노트북을 기다리는 부류의 버그를 잡습니다.</p>
<p><strong>4. 스키마 변경에는 두 번째 눈이 붙습니다.</strong> 작은 팀에는 <code>CODEOWNERS</code> 파일이 필요 없지만, 규칙은 필요합니다. <code>migrations/</code>를 건드리는 PR은 머지 전에 팀원 하나가 리뷰하고, 리뷰어의 일은 SQL을 확인하는 게 아닙니다. 자기나 팀의 다른 누군가가 이번 2주 안에 같은 테이블을 건드리고 있는지 알고, 그렇다고 말하는 것입니다. 개발자가 열댓 명을 넘어가면 그 지식이 한 사람 머리에 다 들어가지 않으니, 그때는 <code>migrations/</code>에 <code>CODEOWNERS</code> 항목을 두는 게 제값을 합니다. 어떤 툴링도 이걸 대신하지 못합니다. 기계적인 건 툴링이 전부 잡아 주니 2분짜리 일이고, 옛 공유 환경 세계에서 간직할 가치가 있는 유일한 부분입니다. 두 사람이 곧 부딪힐 걸 알아차리는 순간.</p>

<h2 class="relative group">버전 관리, URL이 무엇을 돌리는지 말해 주도록
    <div id="버전-관리-url이-무엇을-돌리는지-말해-주도록" 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%ec%a0%84-%ea%b4%80%eb%a6%ac-url%ec%9d%b4-%eb%ac%b4%ec%97%87%ec%9d%84-%eb%8f%8c%eb%a6%ac%eb%8a%94%ec%a7%80-%eb%a7%90%ed%95%b4-%ec%a3%bc%eb%8f%84%eb%a1%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 환경 하나일 때 &ldquo;스테이징에 뭐 올라가 있어요&quot;는 채팅으로 묻는 질문이었습니다. 여럿이면 환경의 속성이어야 하고, 아니면 모든 버그 리포트가 고고학으로 시작합니다.</p>
<ul>
<li><strong>모든 이미지는 커밋 SHA를 지니고 앱이 그걸 노출합니다.</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code>가 이미지에 환경 변수로 찍고, <code>/api/version/</code>이 환경 이름, 리전과 함께 돌려줍니다(위 출력 참고). &ldquo;feature-profile-tagl-fa8832에서 깨져요&quot;에 누구나 제일 먼저 하는 일은 생각한 커밋을 돌리고 있는지 확인하는 것입니다. 첫 데모 환경은 툴링이 커밋되기 한 커밋 전의 작업 트리에서 배포됐고, 엔드포인트가 그렇게 말했습니다. 제가 예상한 SHA가 아니라 이전 커밋을 보고했죠.</li>
<li><strong><code>ENVIRONMENT=$ENV</code>가 모든 로그 줄과 에러 트래커의 environment 태그에 있습니다.</strong> 브랜치 오류가 프로덕션 오류 추적을 더럽히지 않고, 클릭 한 번으로 걸러집니다.</li>
<li><strong>브랜치는 SHA를, 릴리스는 태그를 갖습니다.</strong> <code>feature-payments-retr-3f9a1c</code>에는 버전 번호가 없습니다. 커밋이 있습니다. 스테이징과 프로덕션에는 <code>v1.42.0</code>이 있습니다.</li>
<li><strong>모바일 앱은 URL이 아니라 최소 API 버전을 지닙니다.</strong> dev 메뉴의 URL 필드가 테스터가 실제 폰을 브랜치에 향하게 하는 방법이고, API 스키마 버전을 실은 버전 엔드포인트가 앱이 이해할 수 있는 상대와 통신하는지 아는 방법입니다.</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="#%ec%9d%b4%ea%b1%b8%eb%a1%9c-%ed%95%b4%ea%b2%b0%eb%90%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>같이 있어야만 말이 되는 기능 둘은 해결하지 못합니다. 브랜치 환경은 브랜치 하나를 보여줍니다. 답은 둘 다 플래그 뒤에 머지하고 <code>main</code>을 보는 것이고, <code>main</code>을 따라가는 상시 환경 하나(<code>main-dev.example.com</code>, 머지마다 재배포)로 할 수 있습니다. 제가 남겨 둘 유일한 장수 비프로덕션 환경이고, 아무도 거기<em>에</em> 배포하지 않습니다. 다음 릴리스가 무엇이 될지 보는 용도입니다.</p>
<p>등록된 콜백을 요구하면서 와일드카드를 받지 않는 제공자는 해결하지 못합니다. 제가 쓰는 인증 서비스는 상위 도메인을 받으니 <code>example.com</code>이 모든 <code>-dev</code> 브랜치 호스트명을 덮습니다. 일부 OAuth와 결제 제공자는 안 되고, 그런 경우 Worker가 마지막으로 차지한 환경으로 라우팅하는 공유 호스트명 하나를 둡니다. 작은 것에 걸린 작은 락이고, 모든 것에 걸린 락보다 훨씬 낫습니다.</p>
<p>마이그레이션의 사람 쪽은 해결하지 못합니다. 툴링은 기계적 충돌을 잡습니다. 같은 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="#%eb%8b%b9%ec%8b%a0%ec%9d%98-%ed%99%98%ea%b2%bd%ec%97%90%ec%84%9c-%ed%99%95%ec%9d%b8%ed%95%b4-%eb%b3%bc-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 dev나 스테이징이 있고 그걸 예약하는 채팅 스레드가 있다면:</p>
<ol>
<li>완성된 브랜치가 작성자 아닌 누군가가 도는 걸 볼 수 있기까지 얼마나 기다립니까? 한 시간 넘으면 그 락 값을 전달 시간으로 치르고 있는 것이고, 작은 팀이면 팀의 상당 부분이 기다리는 겁니다.</li>
<li>공유 데이터베이스는 얼마나 자주 초기화되고, 그때 누가 작업을 잃습니까?</li>
<li>새 개발자가 첫날에 누구에게 아무것도 묻지 않고 자기 브랜치가 도는 공개 URL을 얻을 수 있습니까? 물어야 하는 것이 무엇이든, 그게 제일 먼저 자동화할 것입니다.</li>
<li>새 환경은 현실적인 데이터에서 시작합니까? 빈 상태에서 시작하면, 처음으로 못 잡을 버그는 빈 테이블에서는 멀쩡하고 실제 테이블에서는 40분 걸리는 마이그레이션입니다.</li>
<li>비프로덕션 호스팅에 모두가 잊고 한 달 뒤에도 여전히 존재할 것이 있습니까? 있다면 다른 무엇보다 먼저 스위퍼를 쓰세요. 누수는 첫날부터 시작됩니다.</li>
</ol>
<p>채팅 스레드의 그 질문이 사라지는 건 사람들이 묻기를 그만둬서가 아니라, 답이 언제나 &ldquo;네, 당신 것에서요&quot;이기 때문입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>프론트엔드와 백엔드 따로 배포하기: API 재배포 없이 웹 릴리스</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-14-web-releases-without-redeploying/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-14-web-releases-without-redeploying/</guid>
      <pubDate>Mon, 14 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Deployment</category>
      <category>Object Storage</category>
      <description>프론트엔드와 백엔드를 따로 배포하는 방법입니다. 빌드를 버전 붙은 경로로 오브젝트 스토리지에 올리고, Django가 읽는 포인터를 바꾸고, Cloudflare 캐시를 API로 퍼지합니다.</description>
      <content:encoded><![CDATA[<p>프론트엔드와 백엔드를 따로 배포하려고, 웹 빌드를 API의 Docker 이미지에 복사하는 걸 그만뒀습니다. 이제 스크립트가 프론트엔드를 빌드해서 버전 붙은 경로 아래 오브젝트 스토리지에 올리고, 포인터 객체 하나를 바꿉니다. Django는 요청마다 그 포인터를 읽어 거기 적힌 버전을 서빙합니다. 마지막으로 Cloudflare 캐시를 API로 퍼지하면, CSS 수정 하나가 1분 남짓이면 나가고 아무것도 재시작하지 않습니다.</p>
<p>얼마 전까지 제가 만드는 마켓플레이스 앱의 웹 쪽 변경은 전부 Django API 변경과 똑같은 길로 나갔습니다. Docker 이미지를 빌드하고, 푸시하고, Fly.io의 머신 전부에 롤링 배포하는 식이었죠. 웹 프론트엔드(모바일 앱의 웹 export와 정적 사이트 생성기가 만드는 마케팅 페이지)가 API 옆에 이미지로 복사되어 있었기 때문에 다른 방법이 없었습니다. CSS 수정 하나에 7분쯤 걸리고 머신 11대가 재시작됐습니다. 8월 어느 날은 이걸 여섯 번 했습니다.</p>
<p>이 글은 그걸 무엇으로 바꿨는지 순서대로 따라갑니다. 배포 스크립트가 프론트엔드 트리 둘을 빌드하고, 버전 붙은 경로 아래 오브젝트 스토리지에 올리고, 포인터 객체 하나를 바꾸고, 머신 전부가 새 버전을 서빙할 때까지 기다린 다음, Cloudflare 캐시를 API로 퍼지합니다. 반대편에서는 Django가 요청마다 포인터를 읽어 거기 적힌 버전을 서빙합니다. API는 아예 재시작하지 않습니다.</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%98%88%ec%a0%84-%eb%b0%a9%ec%8b%9d%ec%9d%b4-%ec%99%9c-%eb%ac%b8%ec%a0%9c%ec%98%80%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>가장 큰 비용은 재시작이었습니다. 머신 11대가 롤링된다는 건 콜드 부팅 11번이라는 뜻이고, 공유 CPU에서 Django가 뜨는 데 13초 정도 걸리는데 그중 7초는 그냥 Django를 import하는 시간입니다. 실제 방문자가 그 비용을 치르지 않도록 배포 스크립트 끝에 머신 전부를 한 번씩 찍는 워밍업 단계를 뒀는데, 결국 모든 웹 릴리스가 가장 느린 머신을 기다리는 걸로 끝났습니다. 그 콜드 부팅이 Fly.io에서 얼마나 비싼지는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >0으로 스케일 다운, 그리고 재시작마다 30초짜리 장애</a>에 따로 써 뒀습니다. 요약하면 재시작 하나하나가 방문자가 504를 받을 수 있는 구간이라서, 아무것도 재시작하지 않는 배포 경로는 웹 릴리스에서 그 구간을 아예 없애 줍니다.</p>
<p>두 번째 문제는 정적 사이트 출력물이 둘로 갈라져 있었다는 겁니다. HTML은 이미지에 들어갔지만 RSS 피드와 검색 인덱스는 바로 버킷으로 갔습니다. 같은 소스에서 빌드되긴 하는데 같은 시점은 아니라서 서로 어긋날 수 있었고, 어긋나기 전까지는 아무도 몰랐습니다.</p>
<p>세 번째는 습관에 가깝습니다. 릴리스 한 번에 7분과 재시작이 들면 작은 릴리스를 안 하게 됩니다. 수정 서너 개를 하나씩 내보내는 대신 쌓아 두게 되고, 그게 바로 한 줄짜리 변경이 망가진 무언가와 함께 나가는 방식이죠.</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%ed%82%b7-%ea%b5%ac%ec%a1%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>전부 S3 호환 버킷 안 <code>web/</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">web/current                              ← a text file containing one version string
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/manifest.json ← every file in this version
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/app/...       ← the app&#39;s web export
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/site/...      ← the static site&#39;s output
</span></span><span class="line"><span class="cl">web/9f8e7d6-20260912093012/...           ← the previous version, still there</span></span></code></pre></div></div>
<p>버전은 짧은 git SHA에 UTC 타임스탬프를 붙인 것이라 어떤 커밋이 언제 올라갔는지 바로 읽힙니다. 릴리스가 라이브로 갈 때 바뀌는 건 <code>web/current</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="#%eb%b0%b0%ed%8f%ac-%ec%8a%a4%ed%81%ac%eb%a6%bd%ed%8a%b8%ea%b0%80-%ed%95%98%eb%8a%94-%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>전체가 스크립트 하나이고, 저장소 루트에서 실행합니다. 순서대로:</p>
<p><strong>1. 더러운 트리는 거부합니다.</strong> 제일 먼저 <code>git status --porcelain</code>을 돌리고 뭔가 나오면 종료합니다. 디스크에 있는 게 그대로 올라가는데, 예전 마케팅 사이트 배포 스크립트가 그렇게 커밋 안 된 문구를 내보낸 적이 있거든요. 뭘 하는지 알고 쓰는 경우를 위한 우회 플래그가 있지만 아직 써 본 적은 없습니다. 그다음 <code>git rev-parse --short HEAD</code>와 현재 시각으로 버전 문자열을 만듭니다.</p>
<p><strong>2. 두 트리를 빌드합니다.</strong> 앱은 프로덕션 API와 WebSocket URL을 박아 넣고 웹 export를 돌린 다음, Brotli 사전 압축을 합니다. 사전 압축은 설명할 가치가 있습니다. Cloudflare가 실시간으로 Brotli 압축을 해 주긴 하는데 낮은 품질 레벨이라, 이 번들에서는 gzip보다 오히려 나빴습니다(1.66MB 대 2.04MB). 빌드 시점에 품질 11로 한 번만 압축하면 1.28MB가 되기 때문에, 스크립트가 1KB 넘는 JS, CSS, JSON 파일마다 <code>.br</code> 쌍둥이를 옆에 써 둡니다. 마케팅 사이트는 정적 사이트 생성기를 minify 옵션을 켜고 돌립니다.</p>
<p><strong>3. 업로드합니다.</strong> 빌드 디렉터리 두 곳을 훑어서 모든 파일을 스레드 16개로 <code>web/&lt;version&gt;/app/...</code>와 <code>web/&lt;version&gt;/site/...</code>에 올립니다. 객체마다 파일명에서(<code>.br</code>을 먼저 떼고) 추측한 <code>Content-Type</code>을 붙이고, Brotli 쌍둥이에는 <code>Content-Encoding: br</code>도 붙입니다. 파일이 전부 올라간 뒤에야 상대 경로 목록 두 개가 담긴 JSON인 <code>manifest.json</code>을 씁니다. 이 순서가 중요합니다. Django 쪽은 매니페스트 없는 버전을 존재하지 않는 것으로 취급하기 때문에, 반쯤 올라간 버전은 절대 현재 버전이 될 수 없습니다. 지금은 파일 수백 개이고 업로드에 6초 정도 걸립니다.</p>
<p><strong>4. 포인터를 바꿉니다.</strong> <code>put_object</code> 한 번으로 <code>web/current</code>에 버전 문자열을 씁니다. 그게 릴리스입니다.</p>
<p><strong>5. 머신들을 기다립니다.</strong> API 머신마다 포인터를 5초 캐시하니까, 바꾼 직후 잠깐은 일부가 아직 옛 버전입니다. 스크립트는 API의 버전 엔드포인트를 0.5초마다 폴링하면서 새 버전을 담은 연속 응답을 셉니다. Fly가 머신 전체에 부하를 분산하므로 일치하는 응답이 16번 연속(머신 수보다 많이) 나오면 전부 포인터를 다시 읽었다고 판단합니다. 옛 응답이 하나라도 오면 카운트가 리셋됩니다. 90초 뒤엔 포기하고 직접 확인하라고 알려 줍니다. 첫 실제 전환이 30초 조금 넘게 걸려서 제한을 더 낮추지 않았습니다.</p>
<p><strong>6. Cloudflare를 퍼지합니다.</strong> API 호출 한 번이고, 아래에서 다룹니다. 실패하면 경고를 찍고 계속 갑니다.</p>
<p><strong>7. 팀 채팅에 올립니다.</strong> 런타임 에러 알림이 쓰는 웹훅에 버전과 실행한 사람을 한 줄로 보냅니다. 에러가 몰려올 때 방금 뭐가 바뀌었는지 대조해 볼 수 있게요. 롤백은 주황색으로 올라갑니다.</p>
<p>처음부터 끝까지 릴리스 한 번에 1분 조금 넘게 걸리고, 대부분은 웹 export 시간입니다.</p>

<h2 class="relative group">Django 쪽
    <div id="django-쪽" 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="#django-%ec%aa%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>프론트엔드를 서빙하는 건 원래부터 Django였습니다. API가 아닌 URL은 전부 해당 라우트의 웹 export에서 프리렌더된 페이지로 응답하고, 마케팅·개인정보처리방침·지원 페이지는 정적 사이트 출력물에서 옵니다. 이 뷰들은 예전엔 디스크의 파일을 열었습니다. 지금은 버킷을 아는 작은 레이어 하나를 거쳐서 읽습니다.</p>
<p>요청마다 세 가지를 풉니다. 현재 버전(<code>web/current</code>에서 읽고 5초 캐시), 그 버전의 매니페스트(그 버전이 현재인 동안 캐시), 그리고 파일 자체(파일 본문을 담는 작은 인메모리 LRU라서 페이지 HTML과 메인 번들은 메모리에 남습니다). 404가 싼 이유가 매니페스트입니다. 경로가 목록에 없으면 버킷에 물어보지 않고 없다고 답합니다. 클라이언트가 Brotli를 받으면 <code>.br</code> 쌍둥이 파일을 대신 읽고 인코딩 헤더를 붙입니다. 에셋은 파일명에 콘텐츠 해시가 있으니 1년짜리 immutable 캐시 헤더를 달고 나갑니다. 라우트를 프리렌더된 페이지에 매핑하는 테이블처럼 파일 목록에서 나오는 것들도 버전별로 캐시되기 때문에, 새 배포가 나가면 첫 요청에서 다시 만들어지고 그 전에는 안 만듭니다.</p>
<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">web_version</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># bundle: the layer that reads web/current and the manifest</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">source</span><span class="p">(),</span> <span class="s2">&#34;version&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">version</span><span class="p">()})</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Cache-Control&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;no-store&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">response</span></span></span></code></pre></div></div>
<p>이 엔드포인트는 프로덕션에서는 <code>bucket:&lt;version&gt;</code>을, 이미지에서 서빙 중이면 <code>filesystem</code>을 답합니다. Fly 머신마다 자기 자신에 대해 답하기 때문에 열두 번쯤 찍어 보면 전체가 수렴했는지 알 수 있습니다. 뭔가 이상해 보일 때 어떤 머신이 뭘 라이브라고 생각하는지 확인하는 가장 빠른 방법이기도 합니다.</p>

<h2 class="relative group">Cloudflare 캐시를 API로 퍼지하기
    <div id="cloudflare-캐시를-api로-퍼지하기" 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="#cloudflare-%ec%ba%90%ec%8b%9c%eb%a5%bc-api%eb%a1%9c-%ed%8d%bc%ec%a7%80%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>사이트의 HTML은 Cloudflare 엣지에 5분간 캐시됩니다. 퍼지를 안 하면 배포는 머신에 올라가 있는데 대부분의 방문자는 최대 5분 동안 옛 페이지를 계속 받게 되니, 애초 목적이 무색해집니다.</p>
<p>퍼지는 API 호출 한 번입니다.</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">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="sa">f</span><span class="s2">&#34;https://api.cloudflare.com/client/v4/zones/</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="s2">/purge_cache&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;example.com&#34;</span><span class="p">,</span> <span class="s2">&#34;www.example.com&#34;</span><span class="p">]})</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#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>페이지를 일일이 나열하지 않으려고 URL이 아니라 호스트 단위로 퍼지합니다. 에셋은 퍼지할 필요가 아예 없습니다. 파일명에 콘텐츠 해시가 붙어 있어서 새 빌드는 새 파일명을 가리키고 옛 파일은 그냥 참조되지 않게 되니까요. 그 파일명을 참조하는 HTML만 새로 고치면 됩니다.</p>
<p>스크립트가 퍼지 <em>전에</em> 머신을 기다리는 건 일부러 그런 겁니다. 먼저 퍼지하면 Cloudflare가 아직 옛 버전인 머신에서 캐시를 다시 채울 수 있고, 그 페이지가 엣지에 5분 더 앉아 있게 됩니다.</p>
<p>퍼지가 실패하면 스크립트는 경고를 찍고 그래도 끝까지 갑니다. 캐시는 5분 안에 알아서 만료되니 최악의 경우라도 느린 릴리스이지 망가진 릴리스는 아닙니다. 마지막 단계에서 배포가 중단되는 것보다는 그편이 낫습니다.</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%9d%bc%ec%9d%b4%eb%b8%8c-%ec%a0%84%ec%97%90-%eb%b9%8c%eb%93%9c-%ed%99%95%ec%9d%b8%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>플립 없이 돌리는 모드는 3단계까지만 하고 멈춥니다. 버전은 버킷에 있는데 아무도 서빙하지 않는 상태죠. 확인할 때는 머신 한 대에 환경 변수를 하나 설정하는데, 그러면 <code>web/current</code>가 뭐라고 하든 그 머신은 그 빌드에 고정됩니다. 괜찮아 보이면 롤백 명령으로 현재 버전으로 만듭니다. 이름은 롤백이지만 실제로는 &ldquo;<code>current</code>를 이 버전으로 가리켜라&quot;이고, 앞으로 가나 뒤로 가나 같은 동작입니다.</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%a1%a4%eb%b0%b1" aria-label="앵커">#</a>
    </span>
    
</h2>
<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="#%ea%b7%b8%eb%8c%80%eb%a1%9c%ec%9d%b8-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>API는 여전히 자체 배포 스크립트로 예전 방식대로 배포하고, 그래야 합니다. 상태가 있고 마이그레이션이 실행되는 곳이니까요. Dockerfile도 여전히 프론트엔드 빌드 두 개를 이미지에 복사합니다. 이건 의도한 겁니다. 버킷 설정이 없거나(로컬 개발, 테스트 스위트) 버킷을 읽을 수 없으면 서빙 레이어가 디스크의 그 디렉터리로 넘어가기 때문에, 스토리지 장애가 나도 방문자는 에러 페이지 대신 마지막 이미지에 실려 나간 프론트엔드를 받습니다. 버킷을 잠시 빼야 할 일이 생기면 환경 변수 하나로 프로덕션에서 그 동작을 강제할 수 있습니다.</p>
<p>프론트엔드는 API보다 훨씬 자주 바뀌고, 이제 릴리스 한 번에 7분이 아니라 1분쯤 걸립니다. 애쓰지 않았는데도 모아서 배포하는 버릇이 없어졌습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web SEO와 네이버 크롤러: Cloudflare가 Googlebot에게 빈 페이지를 내줬다</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>React Native Web으로 만든 SPA를 구글과 네이버 크롤러에 보이게 만든 다이내믹 렌더링, 그리고 Googlebot에게 빈 SPA 셸을 내준 Cloudflare 캐시 규칙 이야기입니다.</description>
      <content:encoded><![CDATA[<p>React나 React Native Web으로 만든 싱글 페이지 앱은 그대로 두면 네이버 크롤러(Yeti)도 Bing도 자바스크립트를 실행하지 않고, 구글도 실행할 때가 있을 뿐입니다. 해결책은 다이내믹 렌더링입니다. User-Agent로 크롤러를 감지해서, 앱이 그렸을 것과 같은 내용의 서버 렌더링 HTML을 내주면 됩니다. Cloudflare에서 피해야 할 실수는 그 URL을 모든 방문자용으로 캐시하는 것입니다. Cloudflare는 HTML의 <code>Vary</code>를 무시하기 때문에 먼저 도착한 본문이 이후 모든 방문자에게 나가고, Googlebot이 빈 셸을 받을 수도 있습니다. 크롤러 요청만 캐시하고, 규칙을 좁힌 뒤에는 퍼지하세요.</p>
<p>제가 만드는 마켓플레이스 앱은 React Native Web으로 만든 싱글 페이지 앱입니다. 모든 리스팅 페이지는 자바스크립트가 실행되기 전까지 스크립트 태그 하나와 빈 div일 뿐입니다. 사람에게는 아무 문제가 없지만 검색에는 치명적입니다. 구글은 그 JS를 실행할 때도 있지만, Bing은 하지 않고, 리스팅이 거의 다 한국어인 사이트에 가장 중요한 네이버 크롤러는 확실히 하지 않습니다.</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="#%ed%81%b4%eb%a1%9c%ed%82%b9%ec%9d%b4-%ec%95%84%eb%8b%8c-%eb%8b%a4%ec%9d%b4%eb%82%b4%eb%af%b9-%eb%a0%8c%eb%8d%94%eb%a7%81" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>해결책은 오래됐고 구글도 인정하는 방식입니다. 크롤러를 감지해서, SPA가 그렸을 바로 그 콘텐츠를 담은 서버 렌더링 HTML을 내주는 것. 다른 콘텐츠가 아니라, 같은 콘텐츠를 더 일찍.</p>
<p>그래서 <code>/listings/view?id=...</code> 같은 리스팅 URL은 분기합니다. 브라우저는 SPA 셸을 받습니다. 크롤러는 진짜 문서를 받습니다. 제목, 리스팅의 핵심 정보, alt 텍스트가 붙은 사진별 이미지, 본문 발췌, 그리고 이 페이지가 고아가 되지 않도록 관련 리스팅 몇 개로 가는 링크까지.</p>
<p>마지막 항목이 생각보다 훨씬 중요했습니다. 내부 링크가 생기기 전에는 리스팅 페이지 약 1,000개를 가리키는 것이 아무것도 없었고, 서치 콘솔에서 크롤이 하루 7~11회로 가라앉는 게 보였습니다. 페이지를 찾지 못하는 크롤러는 그 페이지가 얼마나 좋은지 신경 쓰지 않습니다.</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%95%a8%ec%a0%95-%eb%b2%a4%eb%8d%94-%ec%9d%b4%eb%a6%84%ec%9d%80-%ea%b7%b8-%ed%9a%8c%ec%82%ac-%eb%b4%87%ec%9d%98-%ec%9d%b4%eb%a6%84%ec%9d%b4-%ec%95%84%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 감지는 user-agent 매칭이고, 바로 여기서 발목을 잡혔습니다.</p>
<p>한국 플랫폼들은 회사 이름과 전혀 닮지 않은 이름으로 크롤링합니다. 네이버의 크롤러는 <strong>Yeti</strong>. 다음은 <strong>Daumoa</strong>. 카카오의 링크 스크래퍼는 <strong>kakaotalk-scrap</strong>. 셋 중 어느 것도 벤더 이름이 아닙니다.</p>
<p>한편 같은 벤더들이 인앱 브라우저를 탑재한 앱을 내놓고, 그 브라우저는 진짜 브라우저 UA 뒤에 앱 이름을 붙여 보냅니다. 카카오톡 인앱 브라우저는 <code>KAKAOTALK/10.5.1</code>이라고 말합니다. 카카오의 스크래퍼는 <code>kakaotalk-scrap/1.0</code>이라고 말하고요.</p>
<p><code>kakao</code>로 매칭하면 둘 다 걸립니다. 제가 정확히 그렇게 했고, 그래서 링크를 연 카카오톡·네이버앱·다음앱 사용자 전원이 크롤러 페이지를 받았습니다. 자바스크립트도, 내비게이션도, 로그인도 없는 페이지를요. 문서로서는 훌륭하고, 사람에게는 완전히 쓸모없는.</p>
<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="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 인앱 브라우저는 평범한 브라우저 UA 뒤에 앱 이름을 붙인다.</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</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;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 벤더가 아니라 봇 이름으로 매칭한다. 네이버는 Yeti, 다음은 Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 카카오의 링크 미리보기 스크래퍼는 kakaotalk-scrap.</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</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;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># 인앱 브라우저는 절대 크롤러가 아니다. 그래서 이 검사가 먼저 온다.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>인앱 브라우저를 먼저 검사하고, 그쪽이 이깁니다. 그리고 모든 크롤러 토큰은 벤더가 아니라 <strong>봇</strong>을 지목해야 합니다.</p>

<h2 class="relative group">그다음 앞단에 Cloudflare를 놓았다
    <div id="그다음-앞단에-cloudflare를-놓았다" 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%b7%b8%eb%8b%a4%ec%9d%8c-%ec%95%9e%eb%8b%a8%ec%97%90-cloudflare%eb%a5%bc-%eb%86%93%ec%95%98%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>오리진은 시드니에 있는 공유 CPU 머신 한 대이고, 초당 20~30 요청을 감당합니다. Googlebot이 걸어야 할 URL은 약 995개입니다. 그 전부가 시드니까지 도달하고 있었습니다. 모든 URL에서 <code>cf-cache-status</code>가 <code>DYNAMIC</code>이었기 때문입니다. <strong>Cloudflare는 규칙이 시키지 않으면 HTML을 캐싱하지 않습니다.</strong> 존의 약 16%만 캐싱되고 있었고, 정작 중요한 부분은 하나도 포함되지 않았습니다.</p>
<p>그래서 크롤러 페이지에 캐시 규칙을 걸었습니다. 간단한 일이죠. 리스팅 URL의 본문이 하나가 아니라는 점만 빼면. 본문은 셋입니다.</p>
<ul>
<li>HTML을 요청하는 크롤러는 서버 렌더링 페이지를 받습니다.</li>
<li>사람은 SPA 셸을 받습니다.</li>
<li><code>Accept: text/markdown</code>을 보내는 에이전트는 데이터베이스에서 만든 마크다운을 받습니다. 빈 HTML 셸을 변환해봐야 빈 문서가 나올 뿐이니까요.</li>
</ul>
<p>오리진은 <code>Vary: Accept</code>를 보내고, user-agent에 대한 <code>Vary</code>는 어차피 가망이 없습니다. 그리고 제가 몰랐던 게 여기 있습니다.</p>
<blockquote><p><strong>Cloudflare는 HTML에서 <code>Vary</code>를 무시합니다.</strong> 세 본문이 하나의 캐시 키를 공유합니다.</p>
</blockquote><p>먼저 도착한 본문이 그 URL을 차지하고, 해당 엣지 위치의 이후 모든 방문자에게 오리진의 <code>s-maxage</code>가 허용하는 한 시간 동안, 거기에 <code>stale-while-revalidate</code> 하루를 더해 그대로 나갑니다.</p>
<p>프로덕션에서 양방향으로 다 목격했습니다.</p>
<ul>
<li>네이버앱 사용자가 크롤러 페이지로 리스팅 URL을 워밍했고, 그다음 평범한 아이폰 사파리가 HIT으로 그 페이지를 받았습니다. 자바스크립트 없는 문서를 들여다보는 사람이 된 거죠.</li>
<li>사람이 SPA 셸로 URL을 워밍했고, Googlebot이 빈 셸을 받았습니다. SEO 작업 전체가 막으려던 바로 그 실패가, 이제 더 빠르게 엣지에서 제공된 겁니다.</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="#%ec%a2%81%ed%98%80%ec%84%9c-%ea%b3%a0%ec%b9%98%eb%8a%94-%ea%b7%9c%ec%b9%99" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare가 HTML에서 <code>Vary</code>를 존중하게 만들 수는 없습니다. 대신 할 수 있는 건, 세 본문 중 <strong>하나만</strong> 캐시에 들어갈 자격을 갖게 만드는 것입니다.</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">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>크롤러 UA만 매칭하고, 마크다운 요청은 제외합니다. 이제 사람의 요청은 어떤 규칙에도 매칭되지 않으므로 예전처럼 <code>DYNAMIC</code>을 읽고 오리진으로 갑니다. 캐시를 읽고 쓰는 건 크롤러 요청뿐이고, 거기 들어갈 수 있는 본문은 크롤러용 하나뿐입니다.</p>
<p>엣지 TTL은 <code>respect_origin</code>입니다. 수명은 대시보드에 박히는 대신 페이지를 렌더링하는 코드가 계속 소유합니다.</p>
<p>놓치기 쉬운 운영 디테일 하나. <strong>규칙을 좁힌다고 이미 캐시에 있는 것이 사라지지는 않습니다.</strong> 퍼지해야 합니다. 안 그러면 방금 생성을 멈춘 그 오염된 항목을 계속 내주게 됩니다.</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%91%90-%eb%aa%a9%eb%a1%9d-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ea%b7%b8-%ec%98%a4%eb%a5%98%ea%b0%80-%eb%8c%80%ec%b9%ad%ec%9d%b4-%ec%95%84%eb%8b%8c-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 규칙에는 크롤러 UA 목록이 필요하고, Django에는 이미 하나 있습니다. 같은 지식이 두 시스템에 두 벌 있는 건 대개 냄새나는 일이지만, 여기서 흥미로운 건 둘이 어긋났을 때 벌어지는 일입니다. 대칭이 아니거든요.</p>
<ul>
<li><strong>Cloudflare의 봇 목록은 Django 쪽의 부분집합으로 유지되어야 합니다.</strong> Cloudflare는 봇이라 여기지만 Django는 아니라고 보는 토큰이 있으면, 크롤러 전용 키 아래에 SPA 셸이 캐싱되고 그게 진짜 크롤러에게 나갑니다.</li>
<li><strong>인앱 브라우저 제외 목록은 Django 쪽의 상위집합이어야 합니다.</strong> 너무 많이 제외해봐야 캐시 히트를 잃을 뿐입니다.</li>
</ul>
<p>어느 쪽이든 안전한 방향으로 틀리면 캐싱을 조금 잃습니다. 첫 번째를 반대 방향으로 틀리면 다시 Googlebot에게 빈 페이지를 내주게 됩니다. 참고로 이 비대칭 때문에 목록이 정규식 대신 <code>contains</code>를 씁니다. <code>matches</code>는 Business 플랜이 필요하고, 알려진 안전한 방향으로 대충이어도 되는 목록에는 <code>contains</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="#%ed%8e%98%ec%9d%b4%ec%a7%80-%eb%8c%80%ec%8b%a0-%eb%b9%8c%eb%93%9c%eb%a5%bc-%ec%8b%a4%ed%8c%a8%ec%8b%9c%ed%82%a4%eb%8a%94-%eb%b6%88%eb%b3%80%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 페이지에는 7일간 유효한 presigned 이미지 URL이 들어갑니다. 페이지 자체는 엣지에서 한 시간, 거기에 stale-while-revalidate 하루가 붙습니다.</p>
<p>이 두 숫자는 서로 관련이 있고, 캐시 수명이 이미지 수명을 넘어서는 순간 엣지는 모든 사진이 403인 페이지를 내주기 시작합니다. 이걸 알려주는 알림은 없습니다. HTML 자체는 멀쩡하니까요.</p>
<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="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># presigned 이미지 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 엣지 캐시가 이미지 URL보다 오래 살 수 있으면 임포트 시점에 실패시킨다.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>서로 다른 파일에 있는 두 상수 사이의 제약은 코드 리뷰에서는 보이지 않고, 프로세스 시작 시점에는 명백합니다. 프로세스가 시작하는 곳에 두세요.</p>

<h2 class="relative group">Cloudflare가 한 일 중 되돌려야 했던 두 가지
    <div id="cloudflare가-한-일-중-되돌려야-했던-두-가지" 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="#cloudflare%ea%b0%80-%ed%95%9c-%ec%9d%bc-%ec%a4%91-%eb%90%98%eb%8f%8c%eb%a0%a4%ec%95%bc-%ed%96%88%eb%8d%98-%eb%91%90-%ea%b0%80%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code>이 번들을 캐시 밖으로 밀어냈습니다.</strong> <code>django-cors-headers</code>는 자기가 건드리는 모든 응답에 <code>Vary: Origin</code>을 찍고, Cloudflare는 <code>Accept-Encoding</code> 외의 헤더로 <code>Vary</code>가 걸린 응답은 캐싱하지 않습니다. 그래서 크리티컬 패스에서 가장 큰 1.6MB 자바스크립트 번들이 통째로 엣지에서 조용히 제외되고 있었습니다. 해결책은 미들웨어를 목록 맨 앞에 두어 나가는 길에 마지막으로 실행되게 하고, 정적 자산 경로의 <code>Vary</code>를 다시 쓰는 것입니다.</p>
<p><strong>엣지 압축이 오리진 압축보다 나빴습니다.</strong> Cloudflare가 즉석에서 Brotli를 하게 두면 2,041,742바이트가 나왔고, 오리진의 gzip은 1,658,115바이트였습니다. 생각하지 않아도 되는 대가로 23% 더 큰 셈입니다. 온더플라이 압축은 압축률이 아니라 CPU를 최적화합니다. 오리진에서 미리 압축한 자산이 이깁니다.</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%ac%eb%a1%a4%eb%a7%81%ec%9d%84-%ea%b8%b0%eb%8b%a4%eb%a6%ac%ec%a7%80-%ec%95%8a%ea%b3%a0-%ea%b2%80%ec%83%89%ec%97%94%ec%a7%84%ec%97%90-%ec%95%8c%eb%a6%ac%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>프로토콜 두 개, 그리고 둘을 합쳐도 커버리지는 실망스럽습니다.</p>
<p><strong>IndexNow</strong>는 핑 하나를 받고 Bing, Yandex, Seznam을 커버합니다. 구글과 네이버는 참여하지 않습니다. 리스팅이 저장될 때마다 백그라운드에서 짧은 타임아웃으로 핑을 보내고, 판매완료·마감된 리스팅은 건너뜁니다. 네트워크 실패는 경고를 남길 뿐 그걸 촉발한 저장을 절대 실패시키지 않습니다. 부가적인 베스트 에포트 작업에 허용할 수 있는 동작은 이것뿐이죠.</p>
<p><strong>구글의 Indexing API</strong>는 정확히 두 가지 스키마 타입만 받고, 그중 하나가 제게 있는 <code>JobPosting</code>입니다. 살아 있는 공고는 <code>URL_UPDATED</code>, 마감된 공고는 <code>URL_DELETED</code>를 보냅니다. 그 삭제 신호가 정직한 이유는 마감된 리스팅이 <code>noindex</code>도 함께 내보내기 때문이고, 코드에 그렇게 적어두었습니다. 나중에 noindex를 걷어내는 사람이 무엇을 같이 망가뜨리는지 알 수 있도록.</p>
<p>거기서 시간을 두 번 썼습니다. 할당량은 하루 200 URL인데, 리스팅을 일괄 임포트하면 한 번에 수백 번이 호출됩니다. 그래서 임포트된 리스팅은 건너뜁니다. 안 그러면 임포트 한 번이 하루치 예산을 전부 씁니다. 그리고 엔드포인트는 콜론이 들어간 <code>urlNotifications:publish</code>입니다. 슬래시를 쓰면 구글은 평범한 HTML 404를 반환하는데, 그건 API가 비활성화된 것과 똑같아 보입니다.</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%b5%ac%ec%a1%b0%ed%99%94-%eb%8d%b0%ec%9d%b4%ed%84%b0%ea%b0%80-%ea%b0%80%eb%a5%b4%ec%b3%90%ec%a4%80-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>규칙 셋, 각각 서치 콘솔에게 혼나면서 배웠습니다.</p>
<ol>
<li><strong>파싱할 수 없는 건 빼세요.</strong> 급여는 자유 텍스트로 들어옵니다. 예전에는 원문 문자열을 <code>baseSalary.value</code>로 내보냈는데, 그건 유효하지 않고 모든 공고에 단위 누락 플래그가 붙었습니다. 지금은 파싱 안 되는 급여는 그냥 없습니다.</li>
<li><strong>스키마를 만족시키려고 필드를 지어내지 마세요.</strong> 구인 공고에 상세 주소가 있는 경우는 드뭅니다. <code>PostalAddress</code>에 그럴듯한 걸 넣고 싶어지죠. 구인 공고에 지어낸 주소는 불완전한 주소보다 나쁩니다. 그래서 지역과 국가만 들어갑니다.</li>
<li><strong>만료일은 선택이 아닙니다.</strong> 구글은 <code>validThrough</code>가 없는 <code>JobPosting</code>을 버립니다. 그래서 하나를 유도하되, 오래된 공고가 어제 만료됐다고 주장하지 않도록 하한을 둡니다.</li>
</ol>
<p>그리고 사이트맵에 대한 네 번째. <strong>전부에 <code>changefreq: daily</code>를 주장하지 마세요.</strong> 리스팅 천 개를 매일 다시 크롤링해달라는 요청은 과부하 상태의 호스트로 읽히고, 구글은 사이트 전체의 크롤 속도를 낮추는 것으로 응답합니다. 실제로 얼마나 최근에 바뀌었는지에서 유도하세요.</p>
<p>사이트맵에는 품질 하한선도 있습니다. 제목과 본문을 합쳐 40자. 서치 콘솔이 약 1,000개 리스팅을 &ldquo;크롤링됨 – 현재 색인이 생성되지 않음&quot;으로 보고했었고, 어떤 중고 페이지의 고유 콘텐츠 전부가 한 단어짜리 제목과 전화번호 하나였습니다. 40자는 일부러 낮게 잡았습니다. 484개 리스팅 중 471개가 한국어이고 한국어는 글자당 담는 정보가 많으니까요. 영어에 맞춘 기준이었다면 마켓플레이스의 절반이 날아갔을 겁니다. 얇은 리스팅도 인덱스 페이지를 통해 크롤 가능한 상태로 남고, 누군가 수정하면 자동으로 사이트맵에 다시 들어옵니다.</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-%ea%b0%80%ec%a0%b8%ea%b0%88-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li><strong>한 URL에서 서로 다른 본문을 내주는 일은, 무엇보다 먼저 캐시 키 문제입니다.</strong> CDN이 무엇을 키로 삼는지 알아내고, 바로 그 콘텐츠 타입에서 증명하기 전까지는 <code>Vary</code>를 무시한다고 가정하세요.</li>
<li><strong>두 시스템이 하나의 목록에 합의해야 한다면, 어느 방향의 오류가 안전한지 알아내서</strong> 양쪽 사본 옆에 적어두세요. &ldquo;이건 어긋날 수 있고, 허용되는 어긋남은 이 방향뿐이다&quot;가 어긋나지 않는 척하는 것보다 낫습니다.</li>
<li><strong>봇은 벤더 이름이 아니라 봇 이름으로 매칭하세요.</strong> 특히 영어권 밖에서는, 그 벤더가 사용자 손에 들린 브라우저도 만들고 있습니다.</li>
<li><strong>멀리 떨어진 상수 사이의 불변식은 시끄럽게 실패하는 곳에 두세요.</strong> 임포트 시점은 공짜이고 아무도 건너뛸 수 없습니다.</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io 콜드 스타트, 33초를 3초로 줄인 방법</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>성능</category>
      <category>콜드 스타트</category>
      <category>Redis</category>
      <category>디버깅</category>
      <description>Fly.io에서 auto_stop_machines로 0까지 스케일 다운하면, 멈춘 머신에 온 요청은 부팅을 기다려야 합니다. 25~33초 걸리던 콜드 스타트를 약 3초로 줄인 과정입니다.</description>
      <content:encoded><![CDATA[<p><strong>업데이트 (2026년 10월 22일):</strong> 이 글의 핵심 숫자는 틀렸습니다. 글을 올린 그날 존의 504를 user agent별로 묶어 봤더니, 9월 5일부터 9일까지의 7,344건이 전부 Cloudflare 자체의 Early Hints 프로브(<code>bastion early hints</code>, <code>nginx-ssl early hints</code>)였습니다. 이 프로브는 Fly 오리진에서 응답을 받지 못해 504로 기록되고, 실제 요청 경로를 그대로 따라 하기 때문에 사용자 장애와 똑같아 보입니다. 같은 기간 실제 클라이언트(앱, 브라우저, curl, 검색 크롤러)가 받은 504는 하나도 없었습니다. 오리진이 preload 헤더를 보내지 않아 Early Hints가 하는 일이 없었기 때문에 그날 바로 껐습니다. 아래의 콜드 스타트는 진짜였고 해결책도 유효하지만, 22%는 사용자가 겪은 숫자가 아니었습니다. Cloudflare의 5xx 비율을 장애로 판단하기 전에, 어떤 user agent가 그 에러를 받고 있는지부터 확인하세요.</p>
<p><strong>업데이트 (2026년 11월 4일):</strong> 정정이 하나 더 있습니다. 이 504는 오리진이 응답하지 못한 프로브가 아닙니다. Cloudflare 자체의 Early Hints 캐시 조회로, 오리진에는 아예 가지 않으며, 여기서 504는 그 URL에 캐시된 것이 없었다는 뜻일 뿐입니다. Early Hints를 끈 것도 최선의 해결책은 아니었습니다. <code>Link</code> 응답 헤더를 보내자 기능이 동작하기 시작했고 이 미스는 히트로 바뀌었습니다. 자세한 내용은 <a href="https://jared.lynskey.co.nz/ko/posts/2026/cloudflare-504-early-hints/" >Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103</a>에 정리했습니다.</p>
<p>결론부터 말하면, <code>auto_stop_machines</code>를 켜 두면 멈춰 있는 Fly.io 머신에 들어온 요청은 부팅이 끝날 때까지 기다립니다. 제 앱은 머신이 시작될 때마다 entrypoint가 Django 마이그레이션과 <code>collectstatic</code>을 돌리고, 바이트코드도 전혀 캐시되지 않아서 콜드 스타트에 25~33초가 걸렸습니다. 이 작업을 이미지당 한 번만(<code>FLY_IMAGE_REF</code>를 키로 쓰는 Redis 락으로) 실행하고 빌드에 <code>compileall</code>을 넣자 콜드 부팅이 약 9초로 줄었습니다. 그리고 <code>auto_stop_machines</code>를 <code>'stop'</code>에서 <code>'suspend'</code>로 바꾸자(그러려면 swap을 빼야 했습니다) 깨어나는 데 약 3초면 충분해졌습니다.</p>
<p>Cloudflare 대시보드에서 트래픽이 튄 구간을 확인하러 들어갔다가, 그 아래에 훨씬 심각한 문제가 깔려 있는 걸 발견했습니다. 하루 동안 앱이 처리한 요청의 22%가 실패하고 있었습니다. 알림은 하나도 울리지 않았고, 헬스 체크는 전부 정상이었으며, 로그도 멀쩡해 보였습니다. 실패한 요청이 애초에 애플리케이션까지 도달하지 못했기 때문입니다.</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="#%ed%8a%b8%eb%9e%98%ed%94%bd-%ea%b8%89%ec%a6%9d%ec%9d%80-%eb%af%b8%eb%81%bc%ec%98%80%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>원래 확인하려던 건 한 시간 동안 약 4,900건이 몰린 구간이었습니다. 중앙값은 시간당 121건이고요. 순 방문자는 42명. 방문자 한 명당 110건인데, 사람이 이렇게 브라우징하지는 않습니다.</p>
<p>스캐너였습니다. 봇 하나가 존재하지도 않는 서브도메인을 지어내며 시크릿 파일을 찾아다니고 있었습니다.</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">api-stage-asia.example.com/.env.local
</span></span><span class="line"><span class="cl">api-us-east-1-demo.example.com/.env
</span></span><span class="line"><span class="cl">api-eu-uat.example.com/wp-config.php~
</span></span><span class="line"><span class="cl">app-test-us-east-1.example.com/actuator/configprops
</span></span><span class="line"><span class="cl">prod-api-use2.example.com/.env.local
</span></span><span class="line"><span class="cl">relay.example.com/.env.production</span></span></code></pre></div></div>
<p>서른 개쯤 되는 호스트명, 생각할 수 있는 모든 <code>.env</code> 변형, 그리고 Django 앱에 대고 요청한 어느 Java 프레임워크의 actuator 엔드포인트까지. 전부 정규 호스트로 301 리다이렉트됐고 아무것도 노출되지 않았습니다. 지루하고, 실제로 무해한 트래픽이었습니다.</p>
<p>그런데 분석 화면을 열어둔 김에 같은 날짜를 상태 코드로 묶어봤습니다. 진짜 문제는 거기 있었습니다.</p>
<table>
	<thead>
			<tr>
					<th>상태 코드</th>
					<th>24시간 요청 수</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>전체</td>
					<td>11,350</td>
			</tr>
			<tr>
					<td>504 Gateway Timeout</td>
					<td>2,474</td>
			</tr>
	</tbody>
</table>
<p>22%입니다. 게다가 사이트 전체에 고르게 퍼진 게 아니라, 가장 아픈 곳에 정확히 몰려 있었습니다.</p>
<table>
	<thead>
			<tr>
					<th>엔드포인트</th>
					<th>504</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>알림 안 읽음 개수</td>
					<td>621</td>
			</tr>
			<tr>
					<td>알림 목록</td>
					<td>614</td>
			</tr>
			<tr>
					<td>메시지 안 읽음 개수</td>
					<td>469</td>
			</tr>
			<tr>
					<td>피드 커서</td>
					<td>210</td>
			</tr>
	</tbody>
</table>
<p>전부 모바일 앱이 30초마다 폴링하는 엔드포인트입니다. 그러니까 이건 추상적인 에러율 숫자가 아니었습니다. 사용자들의 폰이 하루 종일, 아마 몇 주 동안, 안 읽은 알림 배지를 조용히 못 불러오고 있었던 겁니다.</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%a1%9c%ea%b7%b8%ea%b0%80-%eb%a9%80%ec%a9%a1%ed%95%b4-%eb%b3%b4%ec%98%80%eb%8d%98-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>처음엔 앱이 느려졌겠거니 했습니다. 아니었습니다. 가장 나빴던 시간대의 애플리케이션 요청 로그를 아카이브에서 꺼내봤습니다.</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">[2026-09-08 01:00:29] &#34;GET /api/notifications/unread/&#34; 304 61.615
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/&#34;        304 63.568
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/unread/&#34; 304 58.742
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/&#34;        304 105.438</span></span></code></pre></div></div>
<p>44~105밀리초, 전부 304. 오리진은 빨랐습니다.</p>
<p>이게 중요한 단서이고, 규칙으로 적어둘 만합니다. <strong>엣지가 오리진은 들어본 적도 없는 에러를 보고한다면, 요청은 도착하기 전에 죽고 있는 것입니다.</strong> 애플리케이션 로그를 그만 읽고, 그 앞에 있는 것을 보세요.</p>
<p>제 경우엔 Fly.io의 프록시였고, 답은 머신 스케줄링 방식에 있었습니다.</p>

<h2 class="relative group">0으로 스케일 다운은 비용에 대한 약속이지, 지연 시간에 대한 약속이 아니다
    <div id="0으로-스케일-다운은-비용에-대한-약속이지-지연-시간에-대한-약속이-아니다" 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="#0%ec%9c%bc%eb%a1%9c-%ec%8a%a4%ec%bc%80%ec%9d%bc-%eb%8b%a4%ec%9a%b4%ec%9d%80-%eb%b9%84%ec%9a%a9%ec%97%90-%eb%8c%80%ed%95%9c-%ec%95%bd%ec%86%8d%ec%9d%b4%ec%a7%80-%ec%a7%80%ec%97%b0-%ec%8b%9c%ea%b0%84%ec%97%90-%eb%8c%80%ed%95%9c-%ec%95%bd%ec%86%8d%ec%9d%b4-%ec%95%84%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제 설정은 이렇습니다. 언뜻 보면 합리적이죠.</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">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>유휴 머신은 정지하고, 트래픽이 오면 다시 시작합니다. 쓴 만큼만 냅니다. 트래픽이 들쭉날쭉한 작은 앱이라면 정확히 원하는 동작이고, 그래서 머신을 열 대 정의해두고도 평소엔 한 대만 돌고 있었습니다.</p>
<p>이 방식이 숨기는 청구서는 지연 시간입니다. 정지된 머신으로 요청이 들어오면, 누군가는 그 머신이 부팅될 때까지 기다려야 합니다. 부팅이 프록시의 인내심보다 오래 걸리면, 그 누군가는 504를 받습니다.</p>
<p>그래서 제 머신은 부팅에 얼마나 걸렸을까요? 한 번도 측정한 적이 없었습니다. 로그 아카이브는 알고 있습니다. 시작 스크립트가 각 단계를 출력하고, 모든 줄에 머신 ID가 붙어 있으니까요.</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">WITH</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</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="n">instance</span><span class="p">,</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">         </span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Migrations complete%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</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;%&#34;GET /healthz%&#39;</span><span class="w">       </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="w"> </span><span class="k">END</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">phase</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</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;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="p">)</span><span class="w">    </span><span class="k">AS</span><span class="w"> </span><span class="n">started</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">migrated</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="p">)</span><span class="w">  </span><span class="k">AS</span><span class="w"> </span><span class="n">first_serve</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">instance</span></span></span></code></pre></div></div>
<p>모든 머신에서 일관되게 나온 답입니다.</p>
<table>
	<thead>
			<tr>
					<th>단계</th>
					<th>소요 시간</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>부팅부터 마이그레이션 완료까지</td>
					<td>10~13초</td>
			</tr>
			<tr>
					<td>마이그레이션부터 첫 요청 처리까지</td>
					<td>8~19초</td>
			</tr>
			<tr>
					<td><strong>합계</strong></td>
					<td><strong>25~33초</strong></td>
			</tr>
	</tbody>
</table>
<p>그리고 이게 얼마나 자주 일어나는지 세어봤습니다.</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">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">starts</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">instance</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">machines</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</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;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- starts: 92, machines: 10</span></span></span></code></pre></div></div>
<p>하루에 콜드 스타트 92번. 매번 그 머신으로 라우팅된 요청이 타임아웃되는 30초짜리 창이 열린 겁니다. 22%가 더는 미스터리가 아니었습니다.</p>

<h2 class="relative group">원인은 제 entrypoint 안에 있었다
    <div id="원인은-제-entrypoint-안에-있었다" 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%9b%90%ec%9d%b8%ec%9d%80-%ec%a0%9c-entrypoint-%ec%95%88%ec%97%90-%ec%9e%88%ec%97%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>모든 머신이 요청 하나를 처리하기 전에 실행하던 코드입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># entrypoint.sh, run on every machine start</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running migrations...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running collectstatic in background...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>    <span class="c1"># then start the app server</span></span></span></code></pre></div></div>
<p>&ldquo;하루에 92번&quot;을 떠올리면서 다시 읽어보세요.</p>
<p><code>migrate</code>는 완전한 Django 부팅입니다. 모든 모델을 임포트하고, Postgres에 연결하고, 마이그레이션 테이블을 조회한 뒤, 할 일이 없다고 판단합니다. 공유 CPU에서 아무것도 하지 않기 위해 10초를 씁니다. <code>collectstatic</code>은 두 번째 완전한 Django 부팅이고, 그다음 정적 파일을 오브젝트 스토리지에 업로드하면서 웹 서버가 기동하려는 바로 그 단일 코어를 두고 경쟁합니다. 앱 서버가 세 번째 부팅이고요.</p>
<p>파이썬 인터프리터 세 개, 그중 둘은 이미 끝난 일을 반복하면서, 머신이 깨어날 때마다 매번.</p>
<p>이렇게 짠 이유는 나름 타당했고, 아마 여러분 코드도 비슷할 겁니다. 마이그레이션은 어딘가에서는 돌아야 하고, Fly의 <code>release_command</code>는 예전에 멈춘 적이 있었으며, entrypoint는 배포 시 반드시 실행되는 유일한 지점이니까요. 배포 작업을 넣기에 맞는 자리입니다. 다만 <strong>머신 시작마다</strong> 넣기에 맞는 자리가 아닐 뿐입니다. 그리고 머신이 알아서 멈추고 시작하기 시작한 순간, 이 둘은 더 이상 같은 사건이 아닙니다.</p>
<p>버그의 전부는 이것이고, 흔한 실수라고 생각합니다.</p>
<blockquote><p><strong>릴리스</strong>에 속한 작업이 <strong>머신 시작</strong>마다 실행되고 있었습니다. 0으로 스케일 다운이 사건 하나를 아흔두 개로 바꿔놓았습니다.</p>
</blockquote>
<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%95%b4%eb%b2%95-%eb%a6%b4%eb%a6%ac%ec%8a%a4%eb%a5%bc-%ed%95%9c-%eb%b2%88%eb%a7%8c-%ec%84%a0%ec%a0%90%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>특정 이미지로 부팅하는 여덟 번째 머신에서 마이그레이션이 돌 필요는 없습니다. 그 이미지에 대해 한 번만 돌면 되고, 나머지는 그게 끝났다는 사실만 알면 됩니다.</p>
<p>즉, 배포할 때마다 이름이 바뀌는 락입니다. Fly가 <code>FLY_IMAGE_REF</code>로 그 이름을 공짜로 주고, Redis는 이미 있었습니다. 전체가 Django보다 먼저 실행되는 작은 스크립트라서, 흔한 경우의 답이 프레임워크 부팅이 아니라 밀리초로 끝납니다.</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="c1"># release_gate.py: runs before Django exists. Exit code 0 means &#34;prepare this release&#34;.</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PREPARE</span><span class="p">,</span> <span class="n">SKIP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_TTL</span><span class="p">,</span> <span class="n">WAIT</span><span class="p">,</span> <span class="n">POLL</span> <span class="o">=</span> <span class="mi">300</span><span class="p">,</span> <span class="mi">240</span><span class="p">,</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="n">_sleep</span><span class="p">,</span> <span class="n">_monotonic</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span>    <span class="c1"># aliases, so tests can replace them</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">release_id</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Something that changes exactly when the deployed image does.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_IMAGE_REF&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_VERSION&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="n">release</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">release</span><span class="p">:</span>   <span class="c1"># no Redis, or no idea which release: behave as before</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>
</span></span><span class="line"><span class="cl">    <span class="n">done_key</span><span class="p">,</span> <span class="n">lock_key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:done&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:lock&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>            <span class="c1"># somebody already did it for this image</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">owner</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_ID&#34;</span><span class="p">,</span> <span class="s2">&#34;unknown&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>                  <span class="c1"># we won the race; we do the work</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Someone else is preparing. Wait for them, because serving requests</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># against a half-migrated schema is worse than starting slowly.</span>
</span></span><span class="line"><span class="cl">    <span class="n">deadline</span> <span class="o">=</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="n">WAIT</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">deadline</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_sleep</span><span class="p">(</span><span class="n">POLL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">PREPARE</span>              <span class="c1"># the owner died and its lock expired; take over</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">PREPARE</span>                      <span class="c1"># waited long enough; do it ourselves</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ... plus `done` (sets done_key) and a main() that treats any Redis error as PREPARE</span></span></span></code></pre></div></div>
<p>entrypoint는 <code>if</code> 하나가 됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span> python3 release_gate.py claim<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">    python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">    python3 release_gate.py <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

<h2 class="relative group">모든 실패는 &ldquo;그냥 준비한다&quot;로 끝나야 한다
    <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%a8%eb%93%a0-%ec%8b%a4%ed%8c%a8%eb%8a%94-%ea%b7%b8%eb%83%a5-%ec%a4%80%eb%b9%84%ed%95%9c%eb%8b%a4%eb%a1%9c-%eb%81%9d%eb%82%98%ec%95%bc-%ed%95%9c%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>코드보다 이 부분을 베껴 가시는 걸 권합니다.</p>
<p>이런 게이트는 사용자와 스키마 마이그레이션 사이에 놓입니다. <strong>건너뛰는</strong> 쪽으로 틀리면 마이그레이션되지 않은 데이터베이스에 요청을 받게 되고, 이건 진짜 장애입니다. <strong>준비하는</strong> 쪽으로 틀리면 멱등한 no-op 마이그레이션을 두 번 돌리고 10초를 낭비합니다.</p>
<p>두 결과는 대칭이 아니므로, 코드도 대칭으로 다루면 안 됩니다. 불확실한 경로는 전부 <code>PREPARE</code>를 반환합니다.</p>
<ul>
<li><strong>Redis가 없거나 접근 불가.</strong> 준비합니다. 예전 동작 그대로라서, 캐시 장애가 &ldquo;고장&quot;이 아니라 &ldquo;느림&rdquo; 정도로 그칩니다.</li>
<li><strong>환경에 이미지 참조가 없음.</strong> 준비합니다. 어떤 릴리스인지 모르니 준비됐는지도 알 수 없습니다.</li>
<li><strong>락 주인이 마이그레이션 도중 사망.</strong> 락이 만료되고, 다음 대기자가 넘겨받아 준비합니다.</li>
<li><strong>타임아웃보다 오래 기다림.</strong> 그래도 준비합니다. 영영 서비스하지 못하는 머신이 중복 마이그레이션보다 나쁩니다.</li>
</ul>
<p>반대 방향으로 의도한 비대칭이 하나 있습니다. 다른 머신이 락을 실제로 쥐고 있는 걸 확인한 머신은 서비스를 시작하지 않고 <strong>기다립니다</strong>. 배포 중에 느리게 시작하는 건 괜찮습니다. 절반만 적용된 스키마에 질의를 받는 건 괜찮지 않습니다.</p>
<p>연결 에러를 삼켜 버리는 캐시 래퍼 위에 이걸 만든다면 함정이 하나 있습니다. 제가 처음에 그랬거든요. 그런 래퍼는 Redis에 닿지 못하면 <code>add()</code>가 <code>False</code>를 반환하는데, 이게 &ldquo;다른 누가 락을 쥐고 있다&quot;와 완전히 똑같이 읽힙니다. 그대로 뒀다면 모든 머신이 마이그레이션을 건너뛰었을 겁니다. 정확히 위험한 방향이죠. 락을 되읽어서 구분하세요. 아무도 쥐고 있지 않은 락은 경쟁에서 진 게 아니라 캐시가 고장 났다는 뜻입니다.</p>

<h2 class="relative group">Redis 없이 테스트하기
    <div id="redis-없이-테스트하기" 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="#redis-%ec%97%86%ec%9d%b4-%ed%85%8c%ec%8a%a4%ed%8a%b8%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<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">class</span> <span class="nc">FakeRedis</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Enough of a Redis client for the gate: get, and set with nx/ex.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">nx</span> <span class="ow">and</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div>
<p>그다음 시간을 내 마음대로 흘려보내며 까다로운 경우를 몰아봅니다. 게이트가 <code>time.sleep</code>을 직접 부르지 않고 모듈 수준의 <code>_sleep</code>, <code>_monotonic</code> 별칭을 쓰는 이유가 이겁니다. 덕분에 같은 프로세스의 다른 스레드까지 건드리지 않고 테스트에서 교체할 수 있습니다.</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">test_a_waiter_takes_over_when_the_owner_disappears</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">redis</span> <span class="o">=</span> <span class="n">FakeRedis</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span>   <span class="c1"># the owner takes the lock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">owner_dies</span><span class="p">(</span><span class="n">_seconds</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">redis</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">pop</span><span class="p">(</span><span class="s2">&#34;release:img-1:lock&#34;</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>                              <span class="c1"># its lock expired</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="o">.</span><span class="n">object</span><span class="p">(</span><span class="n">release_gate</span><span class="p">,</span> <span class="s2">&#34;_sleep&#34;</span><span class="p">,</span> <span class="n">owner_dies</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span></span></span></code></pre></div></div>

<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%9a%a8%ea%b3%bc%ea%b0%80-%ec%9e%88%ec%97%88%eb%82%98-%ec%a0%88%eb%b0%98%ec%9d%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>배포한 뒤 머신을 정지시켰다가 다시 시작하면서 시계를 봤습니다. 이제 시작 과정은 자기 판단을 출력하고 곧바로 비켜섭니다.</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">15:23:34  Starting app server...
</span></span><span class="line"><span class="cl">15:23:36  release gate: this release is already prepared, starting straight away
</span></span><span class="line"><span class="cl">15:23:44  &#34;GET /healthz HTTP/1.1&#34; 200</span></span></code></pre></div></div>
<p>10초, 두 번째 머신은 11초였습니다. 이전의 25~33초와 비교하면 창의 3분의 2가 사라졌고, 게이트 자체는 Django를 전혀 임포트하지 않아서 2초면 끝납니다.</p>
<p>하지만 10초는 여전히 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="#%ec%95%84%eb%ac%b4%eb%8f%84-%ec%ba%90%ec%8b%b1%ed%95%98%ec%a7%80-%ec%95%8a%eb%8d%98-%eb%b0%94%ec%9d%b4%ed%8a%b8%ec%bd%94%eb%93%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이미지에는 세상 거의 모든 파이썬 Dockerfile에 들어 있는 그 한 줄이 있습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">PYTHONDONTWRITEBYTECODE</span><span class="o">=</span><span class="m">1</span></span></span></code></pre></div></div>
<p>좋은 조언입니다. 컨테이너가 런타임에 레이어 안으로 <code>.pyc</code>를 갈겨쓰면 안 되죠. 제가 끝까지 생각해보지 않았던 건 이 거래의 나머지 절반입니다. 런타임이 바이트코드를 절대 쓰지 않고, 빌드도 쓰지 않는다면, 아무것도 캐싱되지 않고, 모든 프로세스 시작마다 의존성 트리 전체를 소스에서 컴파일합니다.</p>
<p>세어봤습니다.</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">site-packages .py  files : 5,707
</span></span><span class="line"><span class="cl">site-packages .pyc files : 0</span></span></code></pre></div></div>
<p>Django, REST 프레임워크, 모든 라이브러리가 하루 92번의 시작마다 매번 다시 컴파일되고 있었습니다. 프로덕션 머신에서 측정한 값입니다.</p>
<table>
	<thead>
			<tr>
					<th><code>django.setup()</code></th>
					<th>시간</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>배포된 그대로</td>
					<td>5.15초</td>
			</tr>
			<tr>
					<td><code>compileall</code> 이후</td>
					<td>3.17초</td>
			</tr>
	</tbody>
</table>
<p>해결은 한 줄이고, <code>compileall</code>은 명시적으로 쓰기 때문에 저 환경 변수가 있어도 동작합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">RUN</span> python -m compileall -q /app/.venv/lib /app/myapp <span class="o">||</span> true</span></span></code></pre></div></div>
<p>빌드 시간 16초, 딱 한 번, 캐시되는 레이어에서.</p>
<p>작업하는 김에 더 조용한 문제도 하나 찾았습니다. <code>.dockerignore</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">__pycache__/
</span></span><span class="line"><span class="cl">*.pyc</span></span></code></pre></div></div>
<p>Docker는 이 패턴을 경로의 각 구성요소가 아니라 상대 경로 <strong>전체</strong>에 대해 매칭합니다. 그래서 앵커되지 않은 패턴은 빌드 컨텍스트의 최상위에서만 매칭됩니다. 중첩된 <code>__pycache__</code>는 전부 이미지로 실려 가고 있었습니다. 노트북의 파이썬 3.15가 컴파일한 <code>.pyc</code> 218개가, 인터프리터가 3.14인 이미지 안에 실려 들어가서 전부 조용히 무시되고 있었죠. 패턴은 <code>**/__pycache__/</code>와 <code>**/*.pyc</code>여야 합니다.</p>
<p>솔직한 결과: 전체적으로는 2초가 아니라 1초 정도를 벌었습니다. 10초가 9초가 됐습니다. 격리된 벤치마크가 늘 그렇듯 과대평가였습니다.</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%84%9c%ec%8a%a4%ed%8e%9c%eb%93%9c-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ea%b7%b8%ea%b1%b8-%eb%a7%89%ea%b3%a0-%ec%9e%88%eb%8d%98-%ed%95%9c-%ec%a4%84" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>9초에서 더 걷어낼 작업이 없어졌습니다. 남은 건 피할 수 없는 부팅 한 번이었습니다. 파이썬이 뜨고, Django가 임포트되고, WSGI 앱이 공유 코어에서 올라오는 시간.</p>
<p>그러면 부팅을 안 하면 됩니다. Fly는 머신을 종료하는 대신 메모리를 스냅샷할 수 있습니다.</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">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>   <span class="c"># was &#39;stop&#39;</span></span></span></code></pre></div></div>
<p>테스트하러 갔더니 Fly가 거절했고, 이번 작업 전체에서 가장 유용한 에러 메시지를 돌려줬습니다.</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">failed to suspend VM: failed_precondition:
</span></span><span class="line"><span class="cl">Machines with swap cannot be suspended</span></span></code></pre></div></div>
<p>제 설정 상단에는 제가 직접 쓰고 스스로 믿고 있던 주석과 함께 이게 있었습니다.</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="c"># Swap cushion so a memory spike swaps to disk instead of getting OOM-killed.</span>
</span></span><span class="line"><span class="cl"><span class="nx">swap_size_mb</span> <span class="p">=</span> <span class="mi">2048</span></span></span></code></pre></div></div>
<p>합리적인 예방책입니다. 그런데 실제로 일을 하고 있었을까요? 모든 머신이 이렇게 답했습니다.</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">MemTotal:   985220 kB
</span></span><span class="line"><span class="cl">SwapTotal: 2097148 kB
</span></span><span class="line"><span class="cl">SwapFree:  2097148 kB</span></span></code></pre></div></div>
<p>단 한 페이지도 스왑된 적이 없었습니다. 그날의 로그에도 OOM 킬은 없었고요. &ldquo;oom&quot;에 매칭된 11줄은 알고 보니 콘텐츠 해시에 우연히 그 세 글자가 들어간 자바스크립트 번들 요청이었습니다. 그리고 진짜 메모리 가드는 완전히 다른 곳, 앱 서버 자체 설정에 있었습니다. 800MB를 넘긴 워커는 1GB 천장에 닿기 한참 전에 재시작되고 있었죠.</p>
<p>즉 이 완충 장치는 한 번도 일어난 적 없는 사건에 대한 보험이었고, 이미 다른 메커니즘이 커버하고 있었으며, 그 보험료가 서스펜드 기능이었습니다. 제거했습니다.</p>
<table>
	<thead>
			<tr>
					<th>깨어나는 경로</th>
					<th>응답까지</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>오늘 아침의 콜드 부팅</td>
					<td>25~33초</td>
			</tr>
			<tr>
					<td>게이트와 바이트코드 이후의 콜드 부팅</td>
					<td>9~10초</td>
			</tr>
			<tr>
					<td><strong>서스펜드에서 재개</strong></td>
					<td><strong>2.5~3.1초</strong></td>
			</tr>
	</tbody>
</table>
<p>세 번 측정했고 편차는 0.5초 이내였습니다. 깨어난 직후 모든 엔드포인트가 250~435밀리초 만에 200을 반환했습니다.</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%a8%b8%ec%8b%a0%ec%9d%b4-%ec%9e%90%eb%8a%94-%eb%8f%99%ec%95%88-%ec%86%8c%ec%bc%93%ec%9d%80-%ec%96%b4%eb%96%bb%ea%b2%8c-%eb%90%98%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>좋을 게 없고, 고칠 훅도 없습니다. Fly는 VM을 얼립니다. 프로세스에는 시그널이 가지 않고, 내려가는 길에 아무것도 닫을 수 없습니다. 쥐고 있던 커넥션은 깨어날 때도 그대로 메모리에 남아, 몇 분 전에 상대가 버린 소켓을 가리키고 있습니다.</p>
<p>이건 내려갈 때 푸는 문제가 아닙니다. 올라올 때 푸는 문제이고, 대부분은 이미 올바른 선택을 해둔 덕분에 풀립니다.</p>
<ul>
<li><strong>DB 커넥션은 요청마다 새로 엽니다</strong>(<code>conn_max_age = 0</code>). 오래 살아서 상할 것이 없습니다.</li>
<li><strong>Redis 캐시는 장애가 나도 예외를 던지지 않습니다.</strong> 래퍼가 커넥션 에러를 잡아 미스로 돌려줍니다.</li>
</ul>
<p>두 번째 것이 값을 하는 장면을 직접 봤습니다. 어느 재개에서 정확히 예상했던 실패가 로그에 찍혔습니다.</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">redis.exceptions.ConnectionError: Error while reading from fly-...</span></span></code></pre></div></div>
<p>그리고 그게 발생한 요청은 3.6초 만에 200을 반환했습니다. 죽은 캐시는 느린 페이지이지 에러 페이지가 아니니까요. 이후의 재개에서는 아무것도 찍히지 않았습니다.</p>
<p>여러분의 캐시 클라이언트가 죽은 커넥션에서 예외를 던진다면, 서스펜드는 모든 깨어남을 500 폭죽으로 바꿔놓을 겁니다. 설정을 켜기 <strong>전에</strong> 확인하세요.</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%a0%80%ec%97%90%ea%b2%8c-%ea%b1%b0%ec%a7%93%eb%a7%90%ec%9d%84-%ed%95%9c-%ed%85%8c%ec%8a%a4%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>첫 서스펜드 측정은 이 기능이 재앙이라고 말했습니다. 502, 30초 만에, 두 번 연속으로.</p>
<p>저는 <code>fly-force-instance-id</code>로 요청을 특정 머신에 고정했습니다. 인스턴스 하나를 부하 테스트할 때는 훌륭한 헤더지만, 이 용도에는 최악입니다. 인스턴스를 강제하면 그 머신을 깨워줬을 프록시 로직을 건너뛰기 때문입니다. Fly는 이미 말해주고 있었습니다. 제가 그걸 에러가 아니라 답으로 읽었다면요.</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">machine was recently stopped and is unavailable to service request</span></span></code></pre></div></div>
<p>의미 있는 숫자는 실제 트래픽이 지나는 경로에서 나옵니다. 엣지를 통해 동시 요청 40개, 소프트 리밋 12를 훌쩍 넘겨서 프록시가 서스펜드된 머신으로 확장할 수밖에 없게 만들었습니다.</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">status codes:  40 × 200
</span></span><span class="line"><span class="cl">slowest:       2.1s</span></span></code></pre></div></div>
<p>교훈은 두 가지이고, 두 번째가 값비쌉니다. 계측하기 편한 경로가 아니라 사용자가 지나는 경로를 측정하세요. 그리고 도구가 뭔가를 거절하면, 그 거절문을 읽으세요. &ldquo;Machines with swap cannot be suspended&quot;라는 한 문장이 이 오후 전체를 열어젖혔습니다.</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%eb%9f%ac%eb%b6%84-%ec%95%b1%ec%97%90%ec%84%9c-%ed%99%95%ec%9d%b8%ed%95%b4%eb%b3%bc-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>유휴 인스턴스를 정지시키는 플랫폼을 쓰고 있다면, 아래 질문들에 저는 반나절을 썼고 몇 주치의 조용한 실패를 아낄 수 있었을 겁니다.</p>
<ol>
<li><strong>콜드 스타트는 얼마나 걸리나요?</strong> 컨테이너가 실행되는 시간이 아니라, 실제 요청에 응답하기까지의 시간입니다. 몇 초인지 바로 답할 수 없다면, 필요해지기 전에 측정해두세요.</li>
<li><strong>entrypoint가 시작마다 하는 일 중 배포에 속한 건 무엇인가요?</strong> 마이그레이션, 정적 파일 수집, 캐시 워밍, 인덱스 빌드. 한 번이면 다 괜찮습니다. 아흔두 번이면 다 비쌉니다.</li>
<li><strong>바이트코드를 캐싱하고 있긴 한가요?</strong> 빌드에 <code>compileall</code>이 없는 <code>PYTHONDONTWRITEBYTECODE</code>는 &ldquo;아니요&quot;라는 뜻입니다.</li>
<li><strong>정지 대신 서스펜드를 쓸 수 있나요?</strong> 못 쓴다면, 무엇이 막고 있나요? 제 경우엔 한 번도 건드려지지 않은 스왑 파일이었습니다.</li>
<li><strong>엣지는 보는데 오리진은 모르는 에러가 있나요?</strong> 둘을 비교하세요. 그 격차는 리포팅 오차가 아니라 사이에서 죽고 있는 요청이고, 애플리케이션 로그는 절대 보여주지 않습니다.</li>
</ol>
<p>덧붙이자면, 그동안 헬스 체크는 내내 초록불이었습니다. 늘 그렇습니다. 헬스 체크는 이미 떠 있는 머신을 상대로 돌기 때문에, 그 머신이 존재하기 전의 30초에 대해서는 아무것도 보고할 수 없습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io에서 Django 오토스케일링: 부하 테스트로 작은 머신 한 대의 한계 찾기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>Django 앱 두 개를 Fly.io의 shared-cpu-1x 머신에서 돌립니다. 머신 한 대를 빠르게 만들고, 부하 테스트로 무너지는 지점을 찾고, 그 앞에 오토스케일 임계값을 두는 방법입니다.</description>
      <content:encoded><![CDATA[<p>Fly.io에서 Django를 싸게 돌리려고, 저는 shared-cpu-1x 작은 머신 한 대를 최대한 빠르게 만들고, 지연 시간이 무너질 때까지 부하 테스트를 한 뒤, 그 직전에 Fly 오토스케일러가 두 번째 머신을 띄우도록 설정합니다. 부하 테스트는 단계적으로 부하를 올리는 Locust(다른 앱에서는 동시성을 바꿔 가며 돌리는 작은 Python 스크립트)로 하고, p95 지연 시간과 에러율을 봅니다. 트래픽이 줄면 Fly가 유휴 머신을 다시 멈추기 때문에, 평소 비용은 앱당 작은 VM 한 대입니다.</p>
<p>저는 현재 1인 개발자로 두 개의 프로덕트를 운영하고 있습니다. 하나는 현장에서 견적을 내는 기술자들을 위한 <strong>현장 견적 앱</strong>이고, 다른 하나는 게시글 등록과 실시간 메시징을 갖춘 <strong>마켓플레이스 겸 커뮤니티 앱</strong>입니다.</p>
<p>프로덕트도 다르고 사용자도 다르지만, 기술 스택은 거의 동일합니다. 그리고 둘 다 Fly.io에서 가장 저렴한 티어인 <strong>shared-cpu-1x 머신</strong>에서만 돌아갑니다. 이 제약은 의도적인 것입니다. 피크 부하에 맞춰 서버를 크게 잡는 대신, 작은 머신 한 대에서 뽑아낼 수 있는 성능을 최대한 끌어올리고, 정확히 어디서 무너지는지 측정한 다음, 그 측정된 한계를 기준으로 Fly의 오토스케일러가 머신을 추가하고 제거하도록 맡깁니다. 이 글에서는 이를 가능하게 하는 최적화들, 임계값의 근거가 된 부하 테스트 수치, 그리고 Fly.io에서 스케일 업/다운이 실제로 어떻게 동작하는지 다룹니다.</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%b2%a0%ed%95%99-%eb%a8%b8%ec%8b%a0-%ed%95%9c-%eb%8c%80%eb%a5%bc-%ec%b8%a1%ec%a0%95%ed%95%98%ea%b3%a0-%ea%b7%b8%eb%8b%a4%ec%9d%8c-%ea%b3%b1%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>두 앱 모두 스케일링 전략은 같은 세 단계입니다.</p>
<ol>
<li>작은 머신 한 대를 합리적인 수준에서 최대한 빠르게 만든다.</li>
<li>무너질 때까지 부하 테스트를 하고, 지연 시간이 급격히 나빠지는 동시성 지점을 기록한다.</li>
<li>그 지점 직전에 두 번째 머신이 시작되도록 Fly 오토스케일러를 설정하고, 트래픽이 줄면 유휴 머신을 정지시킨다.</li>
</ol>
<p>그러면 용량은 머신 수에 거의 선형으로 비례해서 늘어나고, 기본 비용은 앱당 작은 VM 한 대입니다. 아래의 모든 내용은 이 세 단계 중 하나를 위한 것입니다.</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%9e%91%ec%9d%80-%eb%a8%b8%ec%8b%a0-%ed%95%9c-%eb%8c%80%eb%a5%bc-%eb%b9%a0%eb%a5%b4%ea%b2%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian: API는 WSGI, WebSocket은 uvloop ASGI
    <div id="granian-api는-wsgi-websocket은-uvloop-asgi" 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="#granian-api%eb%8a%94-wsgi-websocket%ec%9d%80-uvloop-asgi" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>두 백엔드 모두 Django 6 + Django REST Framework입니다. API는 Rust 기반 서버인 <strong>Granian</strong>이 서빙하지만, ASGI가 아니라 <strong>블로킹 스레드 풀을 쓰는 WSGI</strong>로 돌립니다.</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">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>처음에는 <code>--interface asgi --loop uvloop</code>로 시작했는데, 잘못된 선택이었습니다. 이 앱들의 모든 HTTP 뷰는 동기(sync)이고, Django의 ASGI 핸들러는 동기 뷰를 <code>thread_sensitive</code>로, 즉 워커당 한 번에 하나씩 실행합니다. 그래서 ASGI는 워커당 동시 요청을 사실상 하나로 묶어놓으면서도, asyncio/uvloop/Channels 스택 전체를 그냥 메모리에 올려두기만 했습니다. <code>--blocking-threads</code>를 쓰는 WSGI는 같은 동기 뷰를 진짜 스레드 풀에서 돌립니다. 워커 1개 × 스레드 16개 = I/O 바운드 요청(DB 읽기, S3 프리사인) 16개 동시 처리를, 비동기 스택보다 낮은 상주 메모리로 해냅니다.</p>
<p>왜 워커 하나일까요? VM이 코어 하나짜리 <code>shared-cpu-1x</code>이기 때문입니다. 두 번째 워커는 코어 하나에서 파이썬을 병렬로 돌릴 수 없지만(GIL + 코어 1개), 상주하는 Django 프로세스 하나를 통째로 더 잡아먹습니다. Granian이 바로 이 점을 경고합니다. 워커 하나에 스레드 16개가 I/O 바운드 작업에서 1 vCPU 머신의 현실적인 한계치입니다. <code>--workers-max-rss 1400</code>은 워커가 메모리 상한을 넘기면 재생성하므로, 폭주하는 PDF나 이미지 작업이 머신을 OOM으로 끌고 가는 대신 재활용됩니다.</p>
<p>WebSocket은 자체 Granian 프로세스로 빠졌습니다. uvloop와 <code>--ws</code>를 쓰는 <strong>ASGI</strong> 앱이고, 장수명 커넥션을 위해 워커 하나로 돌립니다. 동기 API에는 ASGI 경로가 전혀 필요 없습니다. 뷰가 소켓으로 푸시하려 할 때는 Redis를 통해 <code>async_to_sync(channel_layer.group_send)</code>를 호출하므로, 메시지는 여전히 WebSocket 프로세스로 건너갑니다. 서버 두 개가 각자 진짜 잘하는 인터페이스를 돌리는 셈입니다. 로컬 Docker도 같은 WSGI 스레드 풀로 돌리기 때문에, 테스트할 때 제 노트북이 프로덕션 머신 한 대와 거의 같은 조건이 됩니다.</p>

<h3 class="relative group">핫 패스에는 Rust
    <div id="핫-패스에는-rust" 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%95%ab-%ed%8c%a8%ec%8a%a4%ec%97%90%eb%8a%94-rust" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Python 핫 패스에 Rust 기반 대체재가 있으면 무조건 씁니다.</p>
<ul>
<li><strong>orjson</strong> (<code>drf-orjson-renderer</code> 경유) — 모든 API 응답이 표준 라이브러리 JSON 인코더 대신 Rust로 직렬화됩니다.</li>
<li><strong>blurhash-rs</strong> — 업로드 시점에 이미지 플레이스홀더를 계산합니다.</li>
<li><strong>uuid-utils</strong> — Rust 기반 UUID 생성.</li>
<li><strong>psycopg 3 (binary)</strong> — C로 가속된 Postgres 드라이버.</li>
</ul>
<p>이것들은 아키텍처를 바꾸지 않습니다. 두 번째 머신이 필요해지기 전까지 vCPU 하나가 해낼 수 있는 한계치를 끌어올릴 뿐입니다.</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%88%a8%ec%96%b4-%ec%9e%88%eb%8a%94-%ec%a7%80%ec%97%b0-%eb%b9%84%ec%9a%a9-%ec%97%86%ec%95%a0%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>두 코드베이스를 통틀어 가장 큰 성능 개선은 알고리즘이 아니라 커넥션 재사용이었습니다. Granian의 스레드 풀에서는 새 스레드마다 boto3 S3 클라이언트를 새로 만들고 있었고, 사진이나 PDF를 반환하는 모든 엔드포인트가 그 비용을 지불하고 있었습니다. 작은 커스텀 스토리지 클래스로 클라이언트를 프로세스 단위로 캐싱하도록 바꾸자:</p>
<blockquote><p>새 스레드에서의 서명된 URL 생성이 <strong>~150ms에서 ~1ms</strong>로 줄었습니다.</p>
</blockquote><p>데이터베이스에도 같은 교훈이 적용됩니다. Fly의 Postgres는 PgBouncer 뒤에 있으므로 앱은 <code>conn_max_age=0</code>으로 두고 커넥션 재사용을 풀러에 맡기며, 오래된 SSL 커넥션에 워커가 매달리지 않도록 <code>connect_timeout</code>을 5초로 잡아 빠르게 실패시킵니다.</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%a7%84%ec%a7%9c-%eb%b3%91%eb%aa%a9%ec%9d%b4-%ec%9e%88%eb%8a%94-%ea%b3%b3%ec%97%90-%ec%ba%90%ec%8b%b1%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>마켓플레이스를 부하 테스트하면서 새겨둘 만한 사실을 발견했습니다. <strong>한계는 CPU가 아니라 Postgres 커넥션이었습니다.</strong> 둘러보기 트래픽은 압도적으로 익명이고 사용자 간에 동일한데, 부하가 걸리면 그 동일한 목록 쿼리들이 CPU는 놀고 있는 동안 커넥션 풀을 포화시킵니다.</p>
<p>해결책은 익명 목록 페이지를 위한 Redis 캐시입니다. 무효화는 네임스페이스별 버전 카운터로 처리합니다. 쓰기가 발생하면 카운터 하나만 올리면 되고(O(1), 키 관리 불필요), 해당 리소스의 캐시된 페이지 전체가 즉시 무효화됩니다. 짧은 TTL이 놓친 것들을 보완합니다. 둘러보기 페이지를 Redis에서 바로 서빙하면 진짜 병목이 제거되고, 작은 머신은 커넥션을 로그인 트래픽에 쓸 수 있게 됩니다.</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="#%eb%b8%8c%eb%a1%9c%ec%bb%a4-%ec%97%86%eb%8a%94-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%ec%9e%91%ec%97%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>두 앱 모두 Celery 대신 Django의 데이터베이스 기반 태스크 프레임워크인 <strong>django-tasks-db</strong>를 씁니다. 별도의 <code>db_worker</code> 프로세스가 데이터베이스를 폴링하며 이미지 처리, PDF 생성, 기계 번역, 리마인더 같은 작업을 처리합니다. 브로커도 없고, 크기를 정하고 모니터링해야 할 추가 서비스도 없으며, API 프로세스는 무거운 작업에 절대 블로킹되지 않습니다. 심지어 워커는 PgBouncer를 우회해 Postgres에 직접 연결하는데, 오래 걸리는 작업은 트랜잭션 풀링과 잘 맞지 않기 때문입니다.</p>

<h3 class="relative group">메모리: jemalloc과 스왑 쿠션
    <div id="메모리-jemalloc과-스왑-쿠션" 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%eb%aa%a8%eb%a6%ac-jemalloc%ea%b3%bc-%ec%8a%a4%ec%99%91-%ec%bf%a0%ec%85%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>이미지와 PDF 처리는 정상 상태 기준의 메모리 산정으로는 예측할 수 없는 스파이크를 만듭니다. 저렴한 완화책 두 가지: Docker 이미지에 <strong>jemalloc</strong>을 프리로드해 단편화를 줄이고, <strong>모든 머신에 스왑</strong>을 둡니다(API 머신 2GB, WebSocket 머신 1GB). 스파이크가 나면 프로세스가 OOM으로 죽는 대신 디스크로 스왑됩니다. 스왑은 성능 기능이 아니라, 메모리 할당을 작게 유지할 수 있게 해주는 보험입니다.</p>

<h2 class="relative group">Django 부하 테스트: 변곡점 찾기
    <div id="django-부하-테스트-변곡점-찾기" 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="#django-%eb%b6%80%ed%95%98-%ed%85%8c%ec%8a%a4%ed%8a%b8-%eb%b3%80%ea%b3%a1%ec%a0%90-%ec%b0%be%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>임계값은 직접 측정한 값이어야 설득력이 있습니다. 프로젝트마다 자체 하네스가 있습니다.</p>
<ul>
<li>견적 앱에는 동시성 레벨을 훑으며 RPS, p50/p95/p99, 에러율을 보고하는 작은 Python 스트레스 테스트 스크립트가 있습니다. 미리 생성해 둔 수백 건의 샘플 데이터를 상대로 돌리기 때문에 목록 엔드포인트가 현실적인 데이터 양에서 쿼리됩니다.</li>
<li>마켓플레이스는 <strong>Locust</strong>로 30초 단위 스텝을 밟아 50 → 500 사용자까지 올리며 포화 지점을 찾고, 실제 둘러보기 위주 트래픽에 맞춘 엔드포인트 가중치를 씁니다. 여기에 더 작은 별도 테스트 스위트가 p95 &lt; 2초 임계값으로 프로덕션을 상대로 가상 사용자 5 → 50을 램프합니다.</li>
</ul>
<p>견적 앱 스윕의 대표적인 수치입니다(Granian 워커 1개 × 블로킹 스레드 16개, 개발 머신의 Docker):</p>
<table>
	<thead>
			<tr>
					<th>엔드포인트</th>
					<th>동시성</th>
					<th>RPS</th>
					<th>p95</th>
					<th>에러</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>무거운 목록</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>가벼운 목록</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>무거운 목록</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>그리고 다른 모든 것을 결정하는 숫자는 <strong>shared-cpu-1x 머신 한 대</strong>에서 마켓플레이스를 부하 테스트한 결과에서 나옵니다. 기존 ASGI 빌드에서는 이게 단단한 벽이었습니다. 동기 뷰가 사실상 <strong>슬롯 3개</strong> 정도로 직렬화되는 바람에, p95가 동시 요청 ~5개까지 300ms 미만을 유지하다가 모든 게 thread_sensitive 락 뒤에 줄 서면서 ~8–10개를 지나면 붕괴했습니다. 이 변곡점을 바깥으로 밀어낸 것이 바로 API를 WSGI 스레드 풀로 옮긴 일입니다. 머신당 실질 동시 슬롯이 ~3개가 아니라 <strong>16개</strong>가 됐고, 이 새 천장이 오토스케일링 설정 전체의 근거입니다.</p>

<h2 class="relative group">Fly.io에서 스케일링이 동작하는 방식
    <div id="flyio에서-스케일링이-동작하는-방식" 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="#flyio%ec%97%90%ec%84%9c-%ec%8a%a4%ec%bc%80%ec%9d%bc%eb%a7%81%ec%9d%b4-%eb%8f%99%ec%9e%91%ed%95%98%eb%8a%94-%eb%b0%a9%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Fly의 모델은 단순합니다. 앱은 프록시 뒤에 있는 동일한 머신들의 집합이고, 프록시가 머신별 부하를 셉니다. 선언하는 것은 숫자 두 개입니다.</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">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;requests&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">12</span>   <span class="c"># 이를 넘으면 다른 머신을 시작</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">24</span>   <span class="c"># 이를 넘으면 큐잉 대신 부하를 차단</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p><strong>스케일 업:</strong> 한 머신의 진행 중(in-flight) 요청이 soft limit을 넘으면 프록시가 정지된 머신을 시작하고 새 트래픽을 그쪽으로 보냅니다. WSGI 스레드 풀 덕에 머신마다 실질 동시 슬롯이 16개이므로 soft limit은 풀의 약 75%인 12로 잡았습니다. 이렇게 하면 사용자가 수 초짜리 지연을 겪은 뒤가 아니라 스레드에 아직 여유가 있을 때 두 번째 머신이 시작됩니다. hard limit 24(풀의 1.5배)는 서킷 브레이커입니다. 짧은 버스트는 스레드에서 큐잉되고, 그 너머에서는 머신 하나가 녹아내리게 두는 대신 프록시가 부하를 차단합니다. (기존 ASGI 빌드는 실질 슬롯이 ~3개여서 8/20이었습니다. 풀을 넓힌 덕에 두 숫자 모두 올릴 수 있었습니다.)</p>
<p><strong>스케일 다운:</strong> 트래픽이 줄면 Fly가 유휴 머신을 자동으로 정지시키고, <code>min_machines_running = 1</code>까지 내려갑니다. 정지된 머신은 컴퓨팅 비용이 들지 않고 루트 파일시스템 스토리지만 청구되므로, 각 앱의 기본 비용은 말 그대로 작은 VM 한 대이고 버스트 용량은 항상 대기 중입니다.</p>
<p>원래 저는 여기에 정지된 머신이 &ldquo;1초도 안 되어 콜드 스타트한다&quot;고 썼습니다. 틀렸고, 비싸게 틀렸습니다. 가정하는 대신 실제로 측정해보니 콜드 스타트는 <strong>25~33초</strong>가 걸렸고, 아직 부팅 중인 머신에 떨어진 요청은 그 시간을 고스란히 기다려야 합니다. 처음에는 헬스 체크가 전부 초록불인 와중에 CDN 대시보드에 찍힌 22퍼센트 실패율을 이 콜드 스타트 탓으로 돌렸습니다. 알고 보니 그 숫자는 CDN 자체의 Early Hints 프로브가 오리진에서 실패한 것이었고, 실제 클라이언트가 받은 504는 하나도 없었습니다. 그래도 콜드 스타트 자체는 진짜였습니다.</p>
<p>해결책은 위의 스케일링 숫자에 있지 않습니다. 그 숫자들은 멀쩡합니다. 머신이 올라오는 동안 무엇을 하느냐에 있고, Fly에게 머신을 정지 대신 서스펜드하도록 요청하는 데 있습니다. 그러면 깨어나는 일이 약 3초짜리 메모리 복원이 됩니다. 따로 정리해두었습니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >0으로 스케일 다운, 그리고 재시작마다 30초짜리 장애</a>에서 진단 과정과 무엇을 바꿔야 하는지를 다룹니다. <code>auto_stop_machines</code>를 쓰면서 Fly에서 뭔가를 돌리고 있다면, 이 문단을 믿기 전에 그 글을 먼저 읽으세요.</p>
<p>한 가지 미묘한 포인트: 동시성 타입이 <code>connections</code>가 아니라 <code>requests</code>입니다. 현대 클라이언트는 keep-alive 커넥션을 계속 열어 두기 때문에, 커넥션을 세면 실제 부하에서도 수치가 거의 움직이지 않아 오토스케일러가 트리거되지 않습니다. 진행 중 요청을 세야 실제 작업량이 추적됩니다.</p>

<h3 class="relative group">WebSocket은 다른 축으로 스케일된다
    <div id="websocket은-다른-축으로-스케일된다" 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="#websocket%ec%9d%80-%eb%8b%a4%eb%a5%b8-%ec%b6%95%ec%9c%bc%eb%a1%9c-%ec%8a%a4%ec%bc%80%ec%9d%bc%eb%90%9c%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>마켓플레이스는 WebSocket 서버를 같은 Docker 이미지를 공유하는 <strong>두 번째 Fly 앱</strong>으로 분리합니다. API 앱은 WSGI로 워커 1개 × 블로킹 스레드 16개를 돌리며 <em>요청</em>을 셉니다(soft limit 12). WebSocket 앱은 <strong>512MB</strong> 머신에서 ASGI로 uvloop와 함께 워커 1개를 돌리며 <em>커넥션</em>을 셉니다.</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">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>오래 유지되는 소켓은 열어 두는 비용은 거의 없지만 슬롯을 몇 시간씩 차지하고, HTTP 요청은 슬롯을 밀리초 단위로 차지합니다. 같은 오토스케일링 신호로 둘 다 감당할 수는 없습니다. 분리하면 각 플릿이 자기 부하를 제대로 반영하는 지표로 스케일되고, 유휴 WebSocket 용량은 2GB가 아니라 512MB만 차지합니다. Channels 쪽에서는 Redis 레이어가 <code>capacity: 1500</code>과 60초 메시지 만료로 돌아가고, 30초마다 소켓 keepalive를 보냅니다. Fly의 Redis 프록시가 Channels가 의존하는 장수명 BRPOP 소켓을 유휴 상태로 간주해 끊어버리기 때문입니다.</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%bb%b4%ed%93%a8%ed%8c%85%ec%9d%84-%ec%97%a3%ec%a7%80%ec%99%80-%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8%eb%a1%9c-%ec%98%a4%ed%94%84%eb%a1%9c%eb%93%9c%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>가장 저렴한 서버 작업은 서버가 아예 하지 않는 작업입니다.</p>
<ul>
<li><strong>정적 파일과 미디어</strong>는 Fly의 S3 호환 오브젝트 스토리지에 두고, 비공개 미디어는 서명된 URL로 CDN을 통해 서빙합니다. 이미지 바이트는 Django 머신을 전혀 거치지 않습니다.</li>
<li><strong>AI 추론은 사용자의 폰에서 돌아갑니다.</strong> 두 앱 모두 작은 로컬 LLM(양자화된 4B 모델, 약 2.7GB)을 내장해 기기에서 직접 돌립니다. 서버 측 추론 비용은 0이고, 오프라인에서도 동작합니다.</li>
<li><strong>오프라인 우선 모바일</strong>(기기의 SQLite와 재시도가 있는 동기화 큐) 덕분에 앱은 키 입력마다 수다스러운 요청으로 API를 두들기는 대신, 쓰기를 배치로 모으고 통신 음영 지역을 견딥니다.</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%82%98%eb%a8%b8%ec%a7%80-%ec%8a%a4%ed%83%9d-%ea%b0%84%eb%8b%a8%ed%9e%88" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>인증은 호스팅형 ID 공급자와 WebAuthn 패스키를 쓰고, WebSocket 커넥션은 업그레이드 완료 전에 인증합니다. 프로덕션은 Postgres, 개발은 SQLite. 관리자 화면과 감사 이력에는 기성 Django 패키지 몇 개를 씁니다. 모바일은 Expo 위의 React Native와 TypeScript에 Expo Router와 Tailwind를 쓰고, OTA 업데이트는 자체 호스팅 Expo Updates 서버에서 내보내며, 인앱 구독은 구독 관리 서비스에 맡깁니다. 품질 게이트는 백엔드에 pytest와 mypy, 클라이언트에 단위 테스트와 E2E 테스트입니다.</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%80%ec%a0%b8%ea%b0%80%ec%84%9c-%ec%93%b8-%eb%a7%8c%ed%95%9c-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이식 가능한 아이디어는 특정 라이브러리가 아니라 루프 자체입니다. 저렴한 머신 한 대를 진짜로 빠르게 만들고(Rust 핫 패스, 커넥션 재사용, 진짜 병목 앞의 캐시, CDN과 클라이언트로 오프로드), 정확히 어느 동시성에서 무너지는지 찾을 때까지 부하 테스트를 하고, 오토스케일러의 soft limit을 그 변곡점에 놓고, 유휴 머신은 정지시키고, 최소 플릿은 1로 유지합니다. 프로덕션 앱 두 개, 실시간 기능, AI 어시스턴트 — 그런데 평상시 청구서는 플랫폼에서 가장 저렴한 VM 두어 대 값입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>EAS 없이 Expo OTA 업데이트 자체 호스팅하기: Django와 Tigris로 업데이트 서버 구축</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>Expo OTA 업데이트가 어떻게 동작하는지, 그리고 EAS Update 없이 직접 호스팅하는 방법을 정리했습니다. Expo Updates 프로토콜을 구현한 Django REST 서버, Tigris S3에 올린 번들, 월 1달러 정도의 운영 비용까지.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026년 9월 13일 업데이트.</strong> 이 글이 올라간 뒤 Codemagic이 CodePush 계열의 <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">오픈소스</a> 자체 호스팅 OTA 서버 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>를 공개했습니다. 서버를 직접 만들고 싶지는 않아서 여기까지 오셨다면 이제 현실적인 선택지가 하나 생긴 셈입니다. 저도 그 뒤 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/" >아래 구성에서 Patch로 앱 하나를 옮기고</a> 그 과정을 글로 정리했고, <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/#%eb%a7%88%ec%b9%98%eb%a9%b0" >마치며</a> 아래에 짧은 메모를 덧붙였습니다. 밝혀둘 것: 그 마이그레이션과 글은 Codemagic의 의뢰로 비용을 받고 진행했고, 이 글에 언급을 넣어달라는 요청도 있었습니다. 본문의 코드는 이후 짧은 예제로 줄였습니다.</p>
</blockquote><p>제가 만들고 있는 견적 앱은 버그 수정을 OTA(Over-the-Air)로 배포합니다. JavaScript 변경 사항이 App Store 심사를 거치지 않고 바로 사용자 폰에 도착하죠. Expo의 호스팅 서비스인 EAS Update를 쓰면 이걸 알아서 해주지만, 저는 Django REST Framework와 Tigris S3로 서버를 직접 만들었습니다. 그 뒤로 iOS와 Android 업데이트는 전부 이 서버가 처리하고 있습니다.</p>
<p>Expo OTA 업데이트는 이렇게 동작합니다. 앱에 들어 있는 <code>expo-updates</code> 라이브러리가 플랫폼과 런타임 버전을 헤더에 담아 업데이트 서버에 매니페스트를 요청합니다. 서버는 호환되는 최신 업데이트와 그 JavaScript 번들·에셋의 URL을 돌려주고, 앱은 그것들을 내려받아 다음 실행 때 새 번들로 뜹니다. EAS Update는 이 프로토콜을 구현한 Expo의 호스팅 서버일 뿐이라, 같은 프로토콜을 말하는 서버라면 무엇이든 그 자리를 대신할 수 있습니다. 제가 만든 게 바로 그런 서버입니다.</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="#%ea%b5%b3%ec%9d%b4-%ec%a7%81%ec%a0%91-%ed%98%b8%ec%8a%a4%ed%8c%85%ed%95%98%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제 경우엔 비용이 가장 컸습니다. EAS Update 요금은 사용량에 따라 올라가서, 사용자가 늘고 업데이트가 잦아지면 부담이 됩니다. 반면 Tigris 같은 S3 호환 스토리지는 거의 공짜에 가깝죠. 다른 이유가 더 중요한 경우도 있을 겁니다. 애플리케이션 에셋을 전부 자체 인프라 안에 둬야 하는 업계도 있고, 서버를 직접 운영하면 업데이트 흐름 전체를 손에 쥐게 되니 특정 사용자 그룹에만 배포하거나 번들 A/B 테스트를 하는 것도 자유롭습니다. 업데이트 파이프라인이 Expo의 가용성이나 다음 요금 개편에 좌우되지 않는다는 점도 은근히 큽니다.</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%a0%84%ec%b2%b4-%ea%b5%ac%ec%a1%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>구성 요소는 넷입니다. 업데이트 매니페스트를 내려주고 메타데이터를 저장하는 Django 백엔드, 번들과 에셋 실물을 보관하는 Tigris, 내보내기·업로드·등록을 한 번에 처리하는 퍼블리시 스크립트, 그리고 Expo 서버 대신 제 서버를 바라보도록 설정한 모바일 앱입니다.</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">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</span></span><span class="line"><span class="cl">         │
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</span></span></code></pre></div></div>

<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%b5%ac%ed%98%84" aria-label="앵커">#</a>
    </span>
    
</h2>

<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="#%eb%aa%a8%eb%8d%b8-%eb%91%90-%ea%b0%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>모든 것의 바탕은 <code>ExpoUpdate</code>와 <code>ExpoUpdateAsset</code>입니다. 전자는 업데이트별 메타데이터를 담습니다.</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">class</span> <span class="nc">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>runtime_version</code>은 <code>app.json</code>의 <code>runtimeVersion</code>에 대응하는데, 클라이언트는 자기 런타임 버전과 일치하는 업데이트만 내려받기 때문에 이 필드가 핵심 역할을 합니다. iOS와 Android는 번들 자체가 다르니 레코드를 분리했고, <code>is_active</code>는 롤백 장치입니다. 문제 있는 업데이트를 비활성화하면 클라이언트는 이전 것으로 돌아갑니다. <code>manifest_data</code>에는 Expo Updates v1 프로토콜 매니페스트 전체를 JSON으로 넣어두기 때문에, 서빙할 때는 꺼내기만 하면 됩니다.</p>
<p><code>ExpoUpdateAsset</code>은 개별 파일을 추적합니다.</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">class</span> <span class="nc">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>에셋은 SHA-256 해시로 참조하므로 불변이고 캐시하기도 좋습니다. 같은 에셋을 여러 업데이트에서 공유할 수도 있고요.</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="#%eb%a7%a4%eb%8b%88%ed%8e%98%ec%8a%a4%ed%8a%b8-%ec%97%94%eb%93%9c%ed%8f%ac%ec%9d%b8%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>앱과 서버가 실제로 대화하는 곳이 <code>/api/expo-updates/manifest/</code>입니다. <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates v1 프로토콜</a>을 구현합니다.</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</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></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</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="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span><span class="p">[</span><span class="s2">&#34;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>여기서 중요한 게 셋 있습니다. 업데이트가 없으면 204를 반환하고, 앱은 내장 번들을 그대로 씁니다. 매니페스트의 에셋 URL은 presigned URL이라 Django를 거치지 않고 Tigris CDN에서 바로 내려받습니다. 그리고 응답의 <code>expo-protocol-version</code> 헤더는 생략하면 안 됩니다. 클라이언트가 실제로 확인하거든요.</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%97%85%eb%8d%b0%ec%9d%b4%ed%8a%b8-%ed%8d%bc%eb%b8%94%eb%a6%ac%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>퍼블리시는 Django 관리 명령과 이를 감싼 셸 스크립트로 합니다. 관리 명령은 <code>expo export</code> 출력을 읽고, 에셋마다 SHA-256 해시를 계산하고, 번들과 에셋을 Tigris에 병렬로 올리고, 데이터베이스 레코드를 만들고, 필요하면 프로덕션 동기화용 임포트 JSON까지 버킷에 올립니다. 핵심만 추리면 이렇습니다.</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="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">transaction</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <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-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<p>내보내기 전에 프로덕션 환경 변수를 불러오고, 플랫폼은 하나만 또는 둘 다 지정할 수 있으며, 다음에 설명할 프로덕션 동기화까지 알아서 해줍니다.</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="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98-%eb%8f%99%ea%b8%b0%ed%99%94" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>프로덕션 인증 정보를 제 노트북에 두고 싶지 않아서, 프로덕션 배포는 두 단계로 나눴습니다. 먼저 로컬에서 퍼블리시하고(에셋은 Tigris로, 메타데이터 JSON 스냅샷도 함께), 그다음 S3 경로를 담아 프로덕션 API를 호출하면 서버가 직접 메타데이터를 가져옵니다.</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#34;</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">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</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;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">obj</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</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;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></div>

<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%95%b1%ec%9d%b4-%eb%82%b4-%ec%84%9c%eb%b2%84%eb%a5%bc-%eb%b0%94%eb%9d%bc%eb%b3%b4%ea%b2%8c-%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>app.json</code>에서 업데이트 URL과 런타임 버전을 설정합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</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 class="p">}</span></span></span></code></pre></div></div>
<p><code>runtimeVersion</code>은 앱과 서버 간에 엄격하게 맞춰야 합니다. 네이티브 코드를 바꾸거나 Expo SDK를 올렸다면 런타임 버전을 올리고 그 버전용 업데이트를 새로 퍼블리시하세요.</p>

<h2 class="relative group">Tigris 스토리지
    <div id="tigris-스토리지" 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="#tigris-%ec%8a%a4%ed%86%a0%eb%a6%ac%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Tigris는 S3 호환 오브젝트 스토리지인데, AWS S3보다 훨씬 저렴하고 글로벌 엣지 캐싱이 기본으로 포함됩니다. 사용자가 수 메가바이트짜리 번들을 내려받는 상황에서는 이게 꽤 중요하죠. Django 쪽 설정은 이렇습니다.</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="c1"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>클라이언트 생성은 평범한 boto3입니다.</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="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>다운로드는 presigned URL을 거치므로 앱이 CDN에서 바로 받아옵니다.</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">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;get_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>

<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%b3%b4%ec%95%88" aria-label="앵커">#</a>
    </span>
    
</h2>
<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="n">IMPORT_TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</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">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</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;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></code></pre></div></div>
<p>에셋 무결성은 이 설계에서 공짜로 따라옵니다. 모든 것이 SHA-256 해시로 검증되니 변조된 에셋은 그냥 실패할 뿐입니다. presigned URL도 1시간이면 만료돼서 번들이 계속 직링크로 노출될 일이 없습니다.</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%8d%eb%8f%84" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><code>(runtime_version, platform, is_active, -created_at)</code> 복합 인덱스 덕분에 업데이트가 아무리 쌓여도 매니페스트 쿼리는 빠르게 유지됩니다.</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">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>퍼블리시 스크립트는 에셋을 병렬로 업로드합니다.</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">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>에셋 50개 정도의 일반적인 업데이트 기준으로, 퍼블리시 시간이 2분 남짓에서 15초로 줄었습니다. 배포 쪽은 할 일이 없습니다. Tigris가 알아서 사용자 가까운 엣지에 캐싱해 줍니다.</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%89%ec%86%8c-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c%ec%9a%b0" aria-label="앵커">#</a>
    </span>
    
</h2>

<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="#%ea%b0%9c%eb%b0%9c-%ec%a4%91" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></div>

<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="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98-%eb%b0%b0%ed%8f%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></div>

<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="#%eb%a1%a4%eb%b0%b1" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</span></span></span></code></pre></div></div>

<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%b9%84%ec%9a%a9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 앱의 활성 사용자는 500명 정도입니다. Tigris에서는 과거 업데이트 약 200MB 저장에 월 $0.02쯤, 업데이트 다운로드로 나가는 월 약 50GB 이그레스에 $1.00쯤 나옵니다. Django 쪽은 원래 API가 돌아가던 서버에 얹혀 있어서 추가 비용이 없고요. 다 합쳐 월 1달러 수준입니다. 비슷한 사용량이면 EAS Update는 연간 $300~500 정도 나올 테니, 본전은 사실상 바로 뽑았습니다.</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%a8%eb%8b%88%ed%84%b0%eb%a7%81%ea%b3%bc-%eb%94%94%eb%b2%84%ea%b9%85" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>특별한 건 없습니다. ViewSet이 매니페스트 요청을 전부 로그로 남깁니다.</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">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>사실상의 대시보드는 Django admin입니다. 모델만 등록해 두면 조회와 필터링이 다 됩니다.</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="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>클라이언트 쪽에서는 <code>expo-updates</code>가 지금 무엇을 보고 있는지 알려줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>

<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%95%a8%ec%a0%95%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>

<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="#%eb%9f%b0%ed%83%80%ec%9e%84-%eb%b2%84%ec%a0%84-%eb%b6%88%ec%9d%bc%ec%b9%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>가장 흔한 실패가 가장 조용하게 일어납니다. 클라이언트는 자기 런타임 버전과 일치하는 업데이트만 내려받습니다. 설치된 앱이 런타임 <code>1.0.0</code>인데 <code>1.0.1</code>용으로 퍼블리시하면, 아무것도 도착하지 않고 에러도 없습니다. 런타임 버전은 빌드와 맞춰 두고, 올리는 건 네이티브 코드가 바뀔 때만으로 제한하세요.</p>

<h3 class="relative group">createdAt 타임스탬프
    <div id="createdat-타임스탬프" 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="#createdat-%ed%83%80%ec%9e%84%ec%8a%a4%ed%83%ac%ed%94%84" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code> 클라이언트는 매니페스트의 <code>createdAt</code>을 내장 번들의 <code>commitTime</code>과 비교해서, <code>createdAt</code>이 더 새로운 업데이트만 적용합니다. ViewSet에서는 데이터베이스 타임스탬프로 덮어씁니다.</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">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>로컬 개발에서 OTA를 퍼블리시한 <em>뒤에</em> 바이너리를 빌드했다면, 타임스탬프가 더 새로워지도록 OTA를 다시 퍼블리시하세요.</p>

<h3 class="relative group">매니페스트 에셋 <code>key</code> 값
    <div id="매니페스트-에셋-key-값" 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%a7%a4%eb%8b%88%ed%8e%98%ec%8a%a4%ed%8a%b8-%ec%97%90%ec%85%8b-key-%ea%b0%92" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>매니페스트의 에셋별 <code>key</code> 필드는 <code>expo-updates</code>가 캐싱에 쓰는 값인데, 랜덤 UUID나 아무 문자열이 아니라 결정론적 해시(파일명의 MD5 같은)여야 합니다. 이걸 틀리면 클라이언트가 에셋을 제대로 캐시하거나 가져오지 못해서, 첫 로드만 성공하고 그 뒤로는 업데이트가 조용히 깨집니다. 이 문제를 알려준 독자 Raphael Mutschler에게 감사드립니다. 원인을 찾기 전까지 그의 환경에서는 업데이트가 정확히 한 번만 동작했다고 하네요.</p>

<h3 class="relative group"><code>expoClient</code>의 앱 설정
    <div id="expoclient의-앱-설정" 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="#expoclient%ec%9d%98-%ec%95%b1-%ec%84%a4%ec%a0%95" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>Linking</code>, <code>Constants</code>처럼 런타임에 앱 설정을 읽는 기능을 쓰고 있다면, 매니페스트의 <code>extra.expoClient</code> 필드에 앱 설정을 넣어야 합니다. 없으면 처음엔 멀쩡히 실행되다가, 앱을 껐다 다시 열 때 크래시하거나 아예 열리지 않을 수 있습니다. <code>expo-updates</code>가 내장 매니페스트를 OTA 매니페스트로 교체하는데, <code>expoClient</code>가 빠져 있으면 해당 API들이 의존하는 설정에 접근할 길이 없어지기 때문입니다. 이것도 Raphael Mutschler가 잡아준 문제입니다.</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%97%90%ec%85%8b-%ec%a0%95%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>오래된 업데이트는 Tigris에 계속 쌓입니다. 지금은 수동으로 정리하고 있습니다.</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="c1"># Delete updates older than 30 days</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cutoff</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></span></span></code></pre></div></div>
<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="#%eb%8b%a4%ec%9d%8c%ec%97%90-%eb%b6%99%ec%9d%b4%ea%b3%a0-%ec%8b%b6%ec%9d%80-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><em>(2026년 9월: 지금은 Patch가 이 세 가지를 비롯해 더 많은 걸 해줍니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/" >마이그레이션 후기</a>를 참고하세요.)</em></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%a0%90%ec%a7%84%ec%a0%81-%eb%a1%a4%ec%95%84%ec%9b%83" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>rollout_percentage</code> 필드를 추가하면 일부 사용자에게 먼저 내보낼 수 있습니다.</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">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</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="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></div>

<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%8a%a4%ed%85%8c%ec%9d%b4%ec%a7%95-%ec%97%85%eb%8d%b0%ec%9d%b4%ed%8a%b8-%eb%b6%84%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>environment</code> 필드를 두면 스테이징 빌드와 프로덕션이 서로 다른 업데이트를 받게 할 수 있습니다.</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">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></div>

<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="#%eb%8b%a4%ec%9a%b4%eb%a1%9c%eb%93%9c-%ec%a7%91%ea%b3%84" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>작은 모델 하나면 &ldquo;이 업데이트, 실제로 받은 사람이 있긴 한가?&ldquo;라는 질문에 제대로 답할 수 있게 됩니다.</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">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span></span></span></code></pre></div></div>

<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%a7%88%ec%b9%98%eb%a9%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>시스템 전체가 Django 모델 두 개와 뷰 몇 개, 퍼블리시 스크립트 하나, 그리고 인프라 비용 월 1달러입니다. 시작하기 전에 예상했던 것보다 작게 끝났고, 그 뒤로 두 플랫폼에서 조용히 제 몫을 하고 있습니다.</p>
<p>위 코드는 그 프로덕션 앱의 코드를 예제로 단순화한 것이니, 만드시는 것에 맞게 고쳐 쓰면 됩니다. 독자 Raphael Mutschler가 독립형 구현을 <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>로 공개해 두었으니, 바로 돌아가는 코드에서 시작하고 싶다면 그쪽도 좋습니다.</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%84%9c%eb%b2%84%eb%a5%bc-%ec%a7%81%ec%a0%91-%ec%93%b0%ea%b3%a0-%ec%8b%b6%ec%a7%80-%ec%95%8a%eb%8b%a4%eb%a9%b4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>이 글을 쓴 뒤로 Django 없이 같은 자리를 채워주는 것이 둘 생겼습니다. 하나는 위에서 언급한 Raphael의 <code>expo-ota-server</code>입니다. 다른 하나는 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>입니다. <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">오픈소스</a>이고, Docker Compose 설치로 자기 서버에서 돌아가며, Expo Updates 프로토콜 대신 CodePush 방식을 쓰기 때문에 <code>expo-updates</code>를 Patch SDK로, <code>eas update</code>를 <code>cmpatch release-react</code>로 바꾸게 됩니다. S3 호환 스토리지를 쓸 수 있어서 이 글에 나온 것 같은 Tigris 버킷도 그대로 씁니다.</p>
<p>&ldquo;다음에 붙이고 싶은 것들&quot;에 적어두고 결국 만들지 않은 것들이 기본으로 들어 있습니다. 스테이징과 프로덕션 배포, 퍼센트 롤아웃, 릴리스별 다운로드·설치 집계, 필수 업데이트, 바이너리 패치, 코드 서명, 그리고 새 번들이 앱 준비 완료를 보고하기 전에 크래시하면 기기에서 롤백하는 기능까지요. 대신 SDK가 앱에 들어 있어야 하니 누구든 Patch 업데이트를 받기 전에 새 네이티브 바이너리가 필요하고, 관리 명령 대신 운영할 서버가 생깁니다.</p>
<p>제 앱 하나를, 커밋된 네이티브 프로젝트와 실제 릴리스 파이프라인째로, 이 글의 구성에서 Patch로 옮겼습니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/" >그 후기</a>에 뭐가 됐고 뭐가 안 됐는지, 실제로 얼마나 어려운지를 적었습니다. 그 작업은 Codemagic이 후원했고, 사실관계 검토는 있었지만 편집 방향에 대한 검토는 없었습니다.</p>
<hr>
<p><em>전체 사양은 <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates Protocol Specification</a>을 참고하세요.</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
