<?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>OTA Updates · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/ota-updates/</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/ota-updates/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>직접 만든 Expo 업데이트 서버를 Codemagic Patch로 바꾸기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>자체 호스팅</category>
      <category>모바일 개발</category>
      <category>CI/CD</category>
      <description>네이티브 프로젝트까지 커밋된 실제 Expo 앱을 expo-updates에서 Codemagic Patch로 옮겼습니다. 로컬 스택, 마이그레이션 과정, 제가 만들고 OTA로 고친 버그, 그리고 릴리스가 실제로 크래시했을 때 롤백이 어떻게 되는지까지.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>밝혀둘 것.</strong> 이 마이그레이션과 글은 Codemagic의 의뢰로 비용을 받고 진행했습니다. Codemagic은 사실관계 확인을 위한 검토만 했을 뿐 어조에는 관여하지 않았고, 이 글의 어떤 부분도 그쪽 요청으로 바뀌지 않았습니다. 문제를 만나 보고한 곳은 그렇다고 적었고, 읽으실 시점에 고쳐져 있는 문제는 고쳐졌다고 적어두었습니다.</p>
</blockquote><blockquote><p><strong>2026년 9월 23일 업데이트.</strong> Codemagic 개발자들이 제가 보고한 문제들을 하나씩 처리했습니다. iOS 버전 감지 버그, 두 번 집계되던 설치, 아무 말 없던 첫 실행, 시끄러운 debug 명령을 비롯해 대부분이 이제 고쳐졌고, 문서에도 커밋된 네이티브 프로젝트와 크래시 롤백 예산 이야기가 들어갔습니다. 본문에서 각 문제가 나오는 자리마다 표시해두었고, 아직 열려 있는 몇 가지는 그대로 두었습니다.</p>
</blockquote><blockquote><p><strong>2026년 10월 16일 업데이트.</strong> Patch로 옮긴 뒤 첫 네이티브 업그레이드(Expo SDK 58)에서 Patch 설정이 하나도 들어가지 않은 Android 릴리스가 빌드됐는데, 어디서도 오류가 나지 않았습니다. 무슨 일이 있었는지, 그리고 파일 하나로 끝난 수정은 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/#%ec%b2%ab-%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ec%97%85%ea%b7%b8%eb%a0%88%ec%9d%b4%eb%93%9c" >첫 네이티브 업그레이드</a>에 적었습니다.</p>
</blockquote><p>지난 2월에 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/" >Expo 업데이트 서버를 직접 만든 이야기</a>를 썼습니다. 그 서버는 지금도 잘 돌아갑니다. 제가 운영하는 커뮤니티 앱 maru는 그 뒤로 모든 OTA 수정을 두 플랫폼 모두 이 서버를 통해 받았고, 비용은 월 1달러 정도였습니다.</p>
<p>동시에 그 모든 조각을 제가 떠안고 있다는 뜻이기도 합니다. 데이터베이스 테이블, API 엔드포인트, 관리 명령, 퍼블리시 스크립트, 공유 시크릿, 그리고 업데이트가 하나씩 깨질 때마다 배운 함정 목록까지요. 이번 작업을 계획하려고 앉았다가 알게 된 건데, 퍼블리시 스크립트는 처음 쓴 날부터 프로덕션 퍼블리시가 끝날 때마다 &ldquo;Sent 0 push notifications&quot;를 출력하고 있었습니다. 이 서버가 한 번도 돌려준 적 없는 필드를 읽고 있었던 거죠. 아무도 눈치채지 못했고, 저부터가 그랬습니다. 직접 만든 걸 운영한다는 게 대개 이렇게 흘러갑니다.</p>
<p>그래서 Codemagic에서 CodePush를 <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">오픈소스</a>로 다시 만든, 직접 호스팅할 수 있는 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>를 써보지 않겠냐고 물었을 때, 조건 하나를 걸고 수락했습니다. 데모가 아니라 실제 앱으로 하겠다는 것. 실제 서명, 실제 릴리스 절차, 손으로 고친 부분이 들어 있는 커밋된 네이티브 프로젝트, 전부 다요. EAS Update에서 옮겨가는 사람 대부분은 새 템플릿이 아니라 그런 앱을 옮기니까요.</p>
<p>그 과정이 어땠는지, 거친 부분을 다듬지 않고 그대로 적었습니다.</p>

<h2 class="relative group">Patch가 뭔가
    <div id="patch가-뭔가" 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="#patch%ea%b0%80-%eb%ad%94%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Patch는 직접 운영하는 서버와 앱에 넣는 SDK로 이루어져 있습니다. 서버 쪽은 API, 백그라운드 워커, Postgres, S3 호환 오브젝트 스토리지, 웹 대시보드이고, Docker Compose 스택으로 제공됩니다. 클라이언트 쪽은 <code>@codemagic/react-native-patch</code>인데, iOS와 Android용 TurboModule에 Expo config plugin이 딸려 있고, 여기에 JavaScript를 번들링해서 퍼블리시하는 CLI <code>cmpatch</code>가 있습니다.</p>
<p>소스를 읽고 나니 설계 결정 두 가지가 눈에 들어왔습니다.</p>
<p>첫째, 앱은 업데이트가 있는지 API에 묻지 않습니다. 릴리스가 퍼블리시되면 워커가 오브젝트 스토리지에 평범한 JSON 매니페스트를 써두고, 앱은 그걸 가져오기만 합니다. 업데이트 확인은 스토리지나 CDN에 대한 정적 GET 두 번이고, API는 메트릭만 받습니다. 제 예전 엔드포인트는 앱이 실행될 때마다 요청 경로 위에 있었습니다. 이건 그럴 수가 없죠.</p>
<p>둘째, 모든 릴리스는 자기가 대상으로 하는 바이너리 버전을 정확히 명시하고, CLI는 네이티브 프로젝트의 핑거프린트를 계산해서 네이티브 코드가 일치하지 않는 바이너리에는 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%a8%bc%ec%a0%80-%eb%85%b8%ed%8a%b8%eb%b6%81%ec%97%90%ec%84%9c-%eb%8f%8c%eb%a0%a4%eb%b3%b4%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-bash" data-lang="bash"><span class="line"><span class="cl">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI 설치는 2초 만에 끝났습니다. 스택은 처음에 3분 14초가 걸렸는데 대부분 서버와 대시보드 이미지를 로컬에서 빌드하는 시간이었고, 비대화형 모드에서는 그동안 아무것도 출력하지 않아서 딱 멈춘 것처럼 보입니다. (지금은 고쳐져서 단계마다 진행 상황을 한 줄씩 출력합니다.) 그러고 나면 정상 컨테이너 네 개가 전부 localhost에 바인딩되어 나타납니다. 대시보드는 8080, API는 3000, Postgres, 그리고 MinIO는 9100. 제 스택이 이미 쓰고 있던 포트는 알아서 피해 갔습니다.</p>
<p>이 모드에서는 로그인이 꺼져 있고, 페이지마다 외부에 노출하지 말라는 경고가 붙습니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="로컬 평가용 로그인 페이지. 이메일이 미리 채워져 있고 인증이 비활성화됐다는 경고가 보인다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>로컬 평가 모드에서는 GitHub 로그인 대신 미리 채워진 이메일이 나옵니다.</figcaption>
</figure>

<p>스택에는 Example Data라는 앱이 시드로 들어 있는데, 뭔가 퍼블리시하기 전에 한번 볼 만합니다. 대시보드가 무엇을 위한 것인지 보여주거든요. 다음 두 스크린샷의 숫자는 실제 사용자가 아니라 그 시드 데이터입니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQBACdASogABUAPw1uqkqmIyGYDAQAYxDE9IABUVMfRogwtMiXImNo4AD+1hmAfbeuR0QbIc0lZuw4PBjyhGJq5t/3NdR81x+BG5H496NeiObQODoB8gkWF2AAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_453fd7641ffbc69b.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1037"
      alt="시드된 Example Data 앱의 릴리스 이력. 10% 카나리 릴리스와 릴리스별 성공/실패 횟수가 보인다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_30abe30dd08f2234.webp"></span></div>
  <figcaption>시드 데모 데이터: 10% 카나리, 대상 버전, 릴리스별 성공/실패 횟수가 있는 릴리스 이력.</figcaption>
</figure>

<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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoQAAABXRUJQVlA4IHgAAABwBACdASogABcAPw1qrEsmIqIYDAQAYxDE9IBnsglwh49zWML+lyXzzjPEgAD+4AZDuD4GlZfO+rTnJJ+yBUjSHGV1a2gPc+tdTO7IIMf86boVjtRW8VJ7KXLfxZsJzumvn68JtLzOqetoXfqt/I/Omlo4DyEAAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_91146bb0b6c75d52.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1155"
      alt="시드된 앱의 배포 메트릭: 버전 분포, 시간에 따른 적용률, 업데이트 결과"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_1f6ae6ce53428957.webp"></span></div>
  <figcaption>시드 데모 데이터: 기기들이 실제로 어떤 릴리스를 돌리고 있는지, 설치는 어떻게 됐는지.</figcaption>
</figure>

<p>비교하자면 제 대시보드는 관리자 화면의 업데이트 목록에 &ldquo;활성&rdquo; 체크박스가 달린 게 전부였습니다. 실제로 몇 대의 폰이 업데이트를 받았는지는 전혀 몰랐고요.</p>
<p>CLI 로그인은 PKCE와 localhost 콜백을 쓰는 제대로 된 브라우저 플로우이고, 승인 페이지는 어느 계정이 요청하는지와 코드가 1분쯤 뒤에 만료된다는 걸 알려줍니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="CLI 로그인을 승인할지 묻는 대시보드"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login은 대시보드에 터미널을 승인해 달라고 요청합니다.</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="#%ec%8b%a4%ec%a0%9c-%ec%95%b1%ec%97%90-%ec%97%b0%ea%b2%b0%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Patch는 플랫폼마다 앱 하나를 원하고, 각 앱에는 Staging과 Production 배포(deployment)가 미리 만들어져 있습니다. 저는 <code>maru-ios</code>는 대시보드에서, <code>maru-android</code>는 <code>cmpatch app create</code>로 만들었습니다. 둘 다 써보고 싶어서요. 앱을 만들고 나서 뜨는 다이얼로그가 마이그레이션에는 이 제품에서 가장 유용한 화면입니다. 배포 키 두 개, SDK에 필요한 URL 두 개, 그리고 배포 키는 비밀이 아니라는 안내까지 한 화면에 있습니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="앱 생성 완료 다이얼로그. Staging과 Production 배포 키, SDK URL이 보인다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK에 필요한 모든 것이 한 화면에.</figcaption>
</figure>

<p>사소한 것 둘. <code>cmpatch init</code>은 이미 존재하는 앱에 프로젝트를 연결하는 명령이라, 마이그레이션 가이드가 어떻게 읽혔든 첫 번째 명령이 될 수 없었습니다. 이건 그 뒤에 고쳐져서, 이제 대화형 모드에서는 플랫폼마다 앱을 하나씩 직접 만들어줍니다. 그리고 다이얼로그는 API URL을 <code>http://localhost:8080</code>으로 알려주는데 문서와 CLI는 3000이라고 합니다. 각 포트가 둘 다 서빙하기 때문에 둘 다 동작하지만, 고개를 갸웃하게 되죠.</p>
<p>그다음 손대지 않은 앱에 <code>cmpatch doctor</code>를 돌렸더니 3초 만에 진짜 문제를 하나 찾아냈습니다. iOS 바이너리 버전을 2.0.0이라고 보고한 겁니다. 앱은 2.5.1입니다.</p>
<p>CLI는 <code>ios/</code> 아래의 모든 <code>Info.plist</code>를 찾고, 경로에 &ldquo;test&quot;가 들어간 게 없으면 알파벳순으로 첫 번째를 고릅니다. maru에는 WidgetKit 익스텐션이 있는데, 그 폴더가 앱 본체 폴더보다 앞에 정렬되는 바람에 CLI가 익스텐션의 <code>Info.plist</code>를 집어 들었습니다. 그 plist가 2.0.0에 머물러 있던 건 제 버그지만, 결과는 고약합니다. 자동 감지는 모든 iOS 릴리스를 아무 폰도 돌리지 않는 버전에 겨냥하고, 에러는 하나도 나지 않습니다. 업데이트가 그냥 영영 도착하지 않는 거죠. 자체 호스팅 글에서 경고했던 바로 그 실패입니다. Android는 <code>versionName</code>을 제대로 읽었습니다. 우회책은 <code>--target-binary-version</code>이나 <code>--plist-file</code>을 넘기는 것이고, 제 퍼블리시 스크립트는 이제 <code>app.config.js</code>와 두 네이티브 프로젝트가 일치하는지 확인한 뒤 버전을 명시적으로 넘깁니다. 나중에는 같은 버그 때문에 <code>doctor</code> 자체의 매니페스트 검사가 멀쩡히 동작하는 배포에 대해 실패하기도 했습니다. 2.0.0 버전의 매니페스트를 찾으러 갔으니까요. 낡은 plist를 고치고 나서는 <code>doctor</code>가 24개 검사를 전부 통과했습니다.</p>
<p>Codemagic은 그 뒤 이 문제를 고쳤습니다. 이제 CLI는 익스텐션이 아니라 앱 타깃의 <code>Info.plist</code>에서 버전을 읽고, 앱일 수 있는 타깃이 둘 이상이면 <code>cmpatch</code>가 어느 쪽인지 물어봅니다.</p>

<h2 class="relative group">JavaScript 쪽
    <div id="javascript-쪽" 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="#javascript-%ec%aa%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><code>expo-updates</code>를 제거하고 SDK를 추가하는 건 두 줄짜리 변경입니다. 앱이 업데이트로 하던 일을 대체하는 데는 더 고민이 필요했습니다.</p>
<p>maru는 실행 시점과 포그라운드로 돌아올 때마다 업데이트를 확인하는데, 아무리 잦아도 15분에 한 번입니다. 업데이트가 있으면 내려받고 재시작할지 물어봅니다. 거절하면 다음 콜드 스타트 때 적용되고요. 설정 화면에는 지금 어떤 번들이 돌고 있는지 보여주는 수동 확인도 있고, 에러 리포터는 모든 리포트에 출처 업데이트를 태그로 붙입니다.</p>
<p>SDK 소스에서 처음 알게 된 건, 임포트 시점에 <code>TurboModuleRegistry.getEnforcing</code>으로 네이티브 모듈을 찾고 모듈이 없으면 예외를 던진다는 점입니다. maru는 웹 빌드도 내놓고 있어서, 최상위 임포트는 웹 앱을 통째로 죽이게 됩니다. 그래서 전부 SDK를 지연 로드하는 작은 모듈 하나를 거치게 하고, 아무것도 반환하지 않는 <code>.web.ts</code> 짝을 두었습니다. 덕분에 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="c1">// ota.ts (ota.web.ts exports a patchSdk() that always returns null)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Platform</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></span><span class="line"><span class="cl"><span class="kr">type</span> <span class="nx">PatchSdk</span> <span class="o">=</span> <span class="k">typeof</span> <span class="kr">import</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">sdk</span>: <span class="kt">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">|</span> <span class="kc">undefined</span><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">patchSdk</span><span class="p">()</span><span class="o">:</span> <span class="nx">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">)</span> <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</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="p">)</span> <span class="k">return</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">)</span> <span class="kr">as</span> <span class="nx">PatchSdk</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="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</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>sync()</code>입니다. 예외를 던지지 않고 상태 값으로 resolve되죠.</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">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">patch</span><span class="p">.</span><span class="nx">sync</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">installMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESTART&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">mandatoryInstallMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESUME&#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="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s2">&#34;update-installed&#34;</span><span class="p">)</span> <span class="nx">offerRestart</span><span class="p">();</span></span></span></code></pre></div></div>
<p>필수(mandatory) 릴리스에 대한 SDK 기본값은 <code>IMMEDIATE</code>인데, 사용자가 어디에 있든 JavaScript를 그 자리에서 다시 로드합니다. 저는 앱이 다음에 백그라운드에서 돌아올 때 적용되도록 바꿨습니다. 쓰다 만 글이 날아가는 건 웬만한 수정이 주는 이득보다 더 나쁜 버그니까요. 당시에는 <code>InstallMode</code>가 문서 예제가 암시하는 enum 객체가 아니라 문자열 유니온이었습니다. 지금은 상수 객체로 export되기 때문에 <code>InstallMode.ON_NEXT_RESTART</code>가 문서대로 동작합니다.</p>

<h2 class="relative group">네이티브 쪽, ios/와 android/를 커밋해뒀다면
    <div id="네이티브-쪽-ios와-android를-커밋해뒀다면" 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-%ec%aa%bd-ios%ec%99%80-android%eb%a5%bc-%ec%bb%a4%eb%b0%8b%ed%95%b4%eb%92%80%eb%8b%a4%eb%a9%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>마이그레이션 가이드는 config plugin을 추가한 뒤 <code>npx expo prebuild --clean</code>을 돌리라고 합니다. 별도 체크아웃에서 돌려봤더니 번들된 이미지들, Android 네트워크 보안 설정, iOS 프라이버시 매니페스트, <code>Podfile.lock</code>을 지우고, <code>build.gradle</code>의 테스트 러너용 수정과 <code>Info.plist</code> 수백 줄을 되돌려놨습니다. <code>--clean</code> 없이도 Expo는 두 디렉터리를 어차피 비웠고요.</p>
<p>Patch 잘못은 아닙니다. maru는 managed Expo 앱으로 시작해서 네이티브 커스터마이징이 늘어난 경우라 <code>ios/</code>와 <code>android/</code>가 커밋되어 있고, EAS는 그걸 그대로 빌드합니다. 하지만 이런 앱은 흔한데, 가이드는 경고해주지 않았습니다. 지금은 해줍니다. Expo 마이그레이션 가이드가 두 갈래로 나뉘어서, 네이티브 프로젝트를 생성하는 경우에는 prebuild를, 커밋해둔 경우에는 손으로 적용할 변경 사항을 정확히 안내합니다.</p>
<p>플러그인이 실제로 바꾸는 건 손으로 해도 될 만큼 적었습니다. iOS에서는 <code>AppDelegate.swift</code>에 임포트 하나와 한 줄:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android에서는 <code>MainApplication.kt</code>에 임포트 하나와 인자 하나(React Native 0.82 이상 형태이고, 그 전 버전은 대신 <code>getJSBundleFile()</code>을 오버라이드합니다):</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>여기에 플랫폼별 설정값 세 개와 기존 <code>expo-updates</code> 설정 제거까지. 두 프로젝트 합쳐 13줄 추가, 20줄 삭제였습니다.</p>
<p>플러그인은 그 설정값을 prebuild 시점에 리터럴로 써넣는데, 네이티브 프로젝트를 커밋해둔 상태에서는 모든 빌드에 배포 하나가 하드코딩된다는 뜻입니다. 저는 같은 프로젝트에서 프리뷰 빌드는 Staging으로, 스토어 빌드는 Production으로 가길 원했기 때문에, 값을 빌드 환경에서 가져오게 했습니다. <code>Info.plist</code>에서는 Xcode가 빌드 설정을 확장해줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p>그리고 <code>android/app/build.gradle</code>에서는:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>키는 각 <code>eas.json</code> 빌드 프로파일에 <code>EXPO_PUBLIC_</code> 값들 옆에 둡니다. 값이 비어 있으면 SDK는 조용히 아무것도 하지 않는데, 로컬 빌드라면 괜찮지만 스토어 빌드라면 재앙입니다. 그래서 <code>app.config.js</code>는 이제 preview나 production EAS 빌드에 키가 빠져 있거나 여전히 localhost를 가리키면 예외를 던집니다.</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%ab-%ec%97%85%eb%8d%b0%ec%9d%b4%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-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>30초 걸렸고 대부분 Metro와 Hermes 시간이었습니다. 서버 워커는 업로드가 끝나고 0.6초 뒤에 매니페스트를 퍼블리시했습니다. 업데이트는 압축된 7.3 MB tarball이었는데, 예전 export가 만들던 11 MB Hermes 번들과 에셋에 비하면 작습니다.</p>
<p>여기서 제대로 해야 할 게 하나 있습니다. JavaScript 번들은 빌드될 때 <code>EXPO_PUBLIC_</code> 값을 인라인하기 때문에, 업데이트는 그것이 올라갈 바이너리와 같은 값으로 빌드해야 합니다. 안 그러면 엉뚱한 백엔드에 말을 거는 JavaScript를 내보내게 되죠. 제 스크립트는 바이너리를 빌드한 것과 같은 <code>eas.json</code> 프로파일에서 값을 불러옵니다. 이제는 Patch의 CI 문서에도 이 내용이 적혀 있고, <code>release-react</code>가 EAS 프로파일을 직접 읽지 않는다는 점도 함께 나옵니다.</p>
<p>그런 다음 앱을 다시 실행했습니다. 기기 로그에는 업데이트 확인 전체가 오브젝트 스토리지로 가는 요청 두 개, <code>meta.json</code>과 <code>2.5.1/manifest.json</code>으로 찍혔습니다. SDK는 실행 3초 뒤에 <code>Downloaded</code> 이벤트를 기록하고 업데이트를 스테이징했고, 앱은 재시작을 제안했습니다.</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="번들 그대로 다시 실행된 앱이 업데이트를 제안하고 릴리스 v1로 재시작하는 모습">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>재실행, sync()가 update-installed를 반환해 뜬 앱 자체 프롬프트, 그리고 OTA로 전달된 릴리스 v1.</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%b2%84%ea%b7%b8%eb%8a%94-%ec%a0%9c-%ea%b2%83%ec%9d%b4%ec%97%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>그리고 대시보드를 봤습니다. 시뮬레이터 한 대가 릴리스 v1을 설치했는데, 성공 설치 2건에 활성 사용자 2명으로 표시됐습니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging 배포에 기기 한 대가 활성 사용자 2명, 성공 2건으로 표시된 화면"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>시뮬레이터 한 대가 두 번 집계됐습니다.</figcaption>
</figure>

<p>SDK는 상태와 아직 보내지 않은 메트릭 이벤트를 앱 컨테이너 안에 파일로 보관하기 때문에 추적이 쉬웠습니다. 타임스탬프는 같고 id는 다른 <code>Success</code> 이벤트가 두 개 있었습니다. 제 훅은 마운트 시 <code>notifyAppReady()</code>로 실행 중인 번들을 확인한 뒤 곧바로 <code>sync()</code>를 호출했는데, <code>sync()</code>는 내부에서 <code>notifyAppReady()</code>를 또 호출합니다. 두 호출이 겹치면서 각각 릴리스를 대기 중으로 보고, 각각 이벤트를 기록한 겁니다. 서버는 이벤트 id로 중복을 제거하지만 id가 달랐으니 둘 다 집계됐고, 그 중복은 영구적으로 남습니다.</p>
<p>제 쪽 수정은 실행마다 한 번의 공유 확인을 두고 나머지가 전부 그걸 await하게 하는 것이었습니다. SDK 쪽에서도 겹치는 호출을 막을 수 있을 텐데, 그렇게 제안했습니다. 지금은 실제로 막아줍니다. <code>sync()</code> 안의 호출까지 포함해 겹치는 <code>notifyAppReady()</code> 호출들이 확인 하나를 공유하기 때문에, 설치는 한 번만 기록됩니다. SDK 레퍼런스에도 이제 <code>sync()</code>가 준비 확인을 스스로 한다고 적혀 있어서, <code>sync()</code>를 쓰는 앱은 <code>notifyAppReady()</code>를 따로 호출할 필요가 없습니다.</p>
<p>기분 좋았던 건 수정을 배포하는 과정이었습니다. JavaScript만 바뀐 변경이라 릴리스 v2로 내보냈고, v1을 돌리는 기기에는 바이너리 diff를 보낼 수 있어서 v2는 7.3 MB 번들 대신 543 KB 패치로 내려갔습니다. 크기의 약 7%죠. 재시작 후: 다운로드 1, 설치 1, 성공 1, 활성 기기 1.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="v1은 부풀려진 숫자, v2는 정확한 숫자를 보여주는 릴리스 이력"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1의 중복은 남고, v2는 제대로 집계됩니다.</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="#%ec%9d%bc%eb%b6%80%eb%9f%ac-%eb%a7%9d%ea%b0%80%eb%9c%a8%eb%a6%ac%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>롤백은 제가 가장 신경 쓴 기능입니다. 예전 구성에는 기기 쪽 롤백이 전혀 없었거든요. 나쁜 업데이트가 나가면 제가 알아채고 체크박스를 해제할 때까지 실행할 때마다 크래시가 났을 겁니다.</p>
<p>그래서 첫 렌더 전에 예외를 던지는 v3를 릴리스했습니다. 즉 <code>notifyAppReady()</code>를 절대 호출할 수 없는 번들이죠. 앱은 내려받고 재시작을 제안한 뒤 홈 화면으로 죽었습니다.</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="잘못된 릴리스로 앱이 크래시한 뒤의 시뮬레이터 홈 화면">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3가 실행 직후 크래시.</figcaption>
</figure>

<p>다음 실행에서도 v3가 다시 부팅되고 다시 크래시했는데, 이건 의외였습니다. 문서에는 <code>notifyAppReady()</code> 전에 크래시하는 번들은 롤백된다고 되어 있으니까요. 소스를 보니 설명이 됩니다. SDK는 크래시로 판정하기 전에 확인되지 않은 실행을 세 번까지 허용합니다. 확인되지 않은 실행 한 번은 JavaScript가 돌기도 전에 iOS가 멀쩡한 프로세스를 죽인 것일 수도 있으니까요. 네 번째 실행에서 v2로 돌아왔습니다.</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back-poster.jpg" style="background:url('data:image/webp;base64,UklGRsABAABXRUJQVlA4ILQBAABQCgCdASogAEYAPw1mq0umIqGU2q7YYxDE9IBowO6mweBInrUPWk42PSTT65hMmOGGOXZWA/sd5q9BGdjSK2j84FuUL93nyJVQuphNZ6mwXQhv8NVWhs+Q6BIAAP74ukd3WXEH+9Z7Rrx8W8zZkkK5+g1e2Lc8Yi325om3Qy3bb7uYsUutPzhS99cwA1kptzZhyUOs2MW6lH9eP8+SPePVMJGvfSIxSO6tL43kYr+PfLzJw/oJabDsUcKslfz5t3nFCXsiVKRSWBi80g/4Y8CJZiDrEvRejhn6wF7DlE4cfLc64tC8OpWtwMjLNLzfe0pK5MjRdPbq4v5sKsY7T49CjMzJz033ddGNJ1Vmp+9decCnIDCYl2pLqRBhrM5E+D4Cw6TfkPAN/WpX0LXCs9Wr8ACcRRwajxQnbjxLU1tKX+yJyrshjPphgACyyYJKjidt2xw5zVQZcF6XykRe430a0dWMKdcBvnMqq2qBswoqRDNp4NIHmM7r87FSOG3y5ScC9wPZH2OoU+6Am86Nz+s2Tk/J+sj1gZFVQqfqXFWH4AEGF/cO+yLfluiKfZvqAOcwbgAA') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="자동 롤백 후 다시 v2를 실행 중인 앱">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>네 번째 실행: 아무도 서버를 건드리지 않았는데 v2로 롤백.</figcaption>
</figure>

<p>그다음 기기가 스스로 실패를 <code>crash_rollback</code>으로 보고했고, 대시보드는 그걸 v3에 표시했습니다. 합리적인 설계지만 무슨 뜻인지는 분명히 해둘 필요가 있습니다. 나쁜 릴리스는 사용자마다 크래시 세 번을 겪은 뒤에야 회복된다는 것, 그리고 당시 문서에는 그 숫자가 없었다는 것. 지금은 문서에 나와 있고, 예산도 바꿀 수 있습니다. 네이티브에서는 <code>CodemagicPatchMaxLaunchAttempts</code>, Expo 플러그인에서는 <code>maxLaunchAttempts</code>로요. Staging 먼저, 그다음 Production 소규모 롤아웃을 해야 하는 이유입니다.</p>
<p>다른 모든 사용자에게 나쁜 릴리스를 멈추는 건 명령 하나이고, 1초도 안 걸렸습니다.</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">cmpatch release rollback --app maru-ios --deployment Staging</span></span></code></pre></div></div>
<p>이력을 고쳐 쓰지는 않습니다. 이전 릴리스를 새 릴리스(v4, v2의 롤백으로 표시)로 다시 퍼블리시하기 때문에, 크래시한 릴리스는 실패 횟수와 함께 목록에 남습니다. 정직한 기록이죠. 대시보드에서도 다이얼로그로 같은 일을 할 수 있습니다.</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAABwBACdASogABQAPw1qp0emIyGYBABjEMT0gGfQEs2nvxX7EOeFbRn3H5Si4AD+1iXJGzIunVk/ewlUVdIUbFiwAGX5QAkFFcZDl2at4PkETvjCXCOVoXFdscz2ut9AAAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="대시보드에서 Staging을 롤백하는 모습. 이전 릴리스가 v4로 다시 퍼블리시된다">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback.mp4" type="video/mp4">
    </video></div>
  <figcaption>대시보드에서의 롤백: v2가 v4로 돌아오고, v3는 목록에 남습니다.</figcaption>
</figure>

<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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAAAwBACdASogABQAPw1op0emIyGYBABjEMTygAFRZbu1b6Gdq9jYHm/TLAAA/tYZgH+HWJrfGlNiRvwNU+RVn1Uhl5QjE2FNRxQPa3YDnfLX9O0wDNn7xOtAkLPDgAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5552e433d6a65a3f.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="롤백 후 릴리스 이력. v4는 롤백으로 표시되고 v3에는 실패 1건이 남아 있다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5066c51b1ba693c2.webp"></span></div>
  <figcaption>v4가 롤백이고, v3는 실패 기록을 유지합니다.</figcaption>
</figure>


<h2 class="relative group">Android
    <div id="android" 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="#android" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Android에서는 두 가지가 놀라웠는데, 둘 다 Patch 탓은 아닙니다. SDK용으로 추가한 R8 keep 규칙이 <code>app.config.js</code>에 있었는데, 이건 prebuild를 통해서만 네이티브 프로젝트에 반영되기 때문에, 첫 릴리스 빌드는 본의 아니게 SDK가 keep 규칙 없이 R8을 견디는지 테스트한 셈이 됐습니다. 견딥니다. R8이 클래스 이름을 전부 바꿨는데도 업데이트 다운로드, 설치, 롤백이 문제없이 됐습니다. 그리고 막 콜드 부팅한 에뮬레이터는 Gradle이 아직 메모리를 쥐고 있어서 너무 느렸고, 첫 테스트는 SDK가 이미 시작한 다운로드를 끝내기 45초 전에 포기해버렸습니다.</p>
<p>릴리스 v1은 성공 1, 활성 기기 1로 적용됐고, 크래시하는 v2는 같은 세 번의 실행 예산을 쓴 뒤 롤백됐습니다.</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="OTA로 전달된 헤드라인을 보여주는 Android 앱"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android: 자체 롤백 후 다시 v1으로.</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="#%ed%95%b4%ed%94%bc-%ed%8c%a8%ec%8a%a4-%eb%84%88%eb%a8%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>업데이트가 도착하고 롤백되는 게 핵심이지만, 팀은 나머지 기능 속에서 살아가니 하나씩 짚어봤습니다.</p>
<p>단계적 롤아웃은 결정론적인데, 이 점이 마음에 들었습니다. <code>md5(deviceId + &quot;-&quot; + releaseLabel)</code>의 앞 여덟 자리 16진수를 100으로 나눈 나머지가 롤아웃 퍼센트보다 작으면 포함되므로, 같은 폰은 한 릴리스에 대해 계속 포함되거나 계속 제외됩니다. 제 시뮬레이터가 다음 릴리스에서 91번 버킷에 있다는 걸 계산해두고 25%로 퍼블리시했더니, 시뮬레이터는 정확히 아무것도 받지 않았습니다. <code>cmpatch release patch</code>로 95%까지 올리는 데 1초도 안 걸렸고, 다음 실행에서 업데이트됐습니다.</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="대시보드에서 릴리스의 롤아웃을 25%에서 95%로 올리는 모습">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>부분 롤아웃을 다시 퍼블리시하지 않고 25%에서 95%로 올리기.</figcaption>
</figure>

<p>그다음 릴리스는 409로 실패했습니다. &ldquo;deployment has an active rollout below 100 percent&rdquo;. 배포당 부분 롤아웃은 하나만 허용되니, 다른 걸 내보내기 전에 카나리를 끝내거나 비활성화하거나 롤백해야 합니다. 합리적이고 문서화도 되어 있지만, 머지마다 퍼블리시하는 파이프라인은 카나리 도중에 이 벽에 부딪힐 겁니다. 제가 보고한 뒤로는 CLI가 이 상황을 설명해줍니다. 활성 부분 롤아웃이 막고 있다면 기다려도 소용없다는 것과 롤아웃을 완료하거나 비활성화하는 방법을 알려주고, 다른 릴리스 작업이 아직 돌고 있다면 <code>release inspect --wait</code>로 기다리라고 합니다. 문서에도 릴리스 변경을 순서대로 하는 방법을 다룬 섹션이 생겼습니다.</p>
<p>필수 릴리스는 SDK의 <code>mandatoryInstallMode</code>를 씁니다. 제 설정은 다음 resume 때 적용이라, 앱은 업데이트를 내려받고 프롬프트를 띄웠고, 프롬프트를 무시한 채 앱을 백그라운드로 보냈다가 다시 가져오니 새 버전이 그냥 거기 있었습니다.</p>
<p>릴리스 비활성화는 단순히 제공을 멈추는 것 이상입니다. 최신 릴리스를 비활성화한 순간 매니페스트는 아직 활성 상태인 가장 새로운 릴리스를 가리켰고, 비활성화된 릴리스를 돌리던 기기에는 그쪽으로 돌아가는 패치가 제공됐습니다. 한 바이너리 버전의 릴리스를 전부 비활성화하면 매니페스트의 target이 null이 되고, SDK는 앱을 바이너리에 실려 나간 번들로 되돌립니다. 제 업데이트 훅은 처음엔 이 경우에 프롬프트를 띄우지 않았는데, 이제는 띄웁니다.</p>
<p>Staging에서 Production으로 승격(promote)하는 데는 1초도 안 걸렸고, 다시 빌드하는 대신 테스트한 패키지를 해시와 노트까지 그대로 재사용했습니다. 롤아웃 퍼센트도 받기 때문에 &ldquo;Production의 10%로 승격&quot;이 명령 하나입니다.</p>
<p>빌드와 퍼블리시를 별도 단계로 나눌 수도 있습니다. <code>cmpatch bundle</code>이 <code>.cmpatch</code> 아티팩트를 만들고, 대시보드의 &ldquo;Bundle upload&rdquo; 옵션은 업로드 전에 그걸 읽어서 플랫폼, 대상 버전, 핑거프린트, 서명 상태, 크기, 번들러를 보여줍니다. CI가 아티팩트를 빌드하고 퍼블리시 시점은 다른 사람이 정하는 구성에 맞습니다.</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnwAAABXRUJQVlA4IHAAAAAwBACdASogABQAPw14sE+mI6IhKAgAxiGJ6QAAM7aVPPd3tO2d2B9IEAAA/tYlyRsyLp1nLc5Hh5nniWZ+UIxN4AAKrWhTSPPLnDPJV90MvTw1QAmDXcvY3C3ghY+Uo+Wdxkjji3HNEgzIPqpIwAAA') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="대시보드에서 .cmpatch 아티팩트를 업로드하는 모습. 플랫폼, 버전, 핑거프린트, 크기를 읽은 뒤 핑거프린트가 다르다고 경고한다">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload.mp4" type="video/mp4">
    </video></div>
  <figcaption>번들 업로드: 대시보드가 아티팩트를 읽고, 이어서 핑거프린트 가드가 개입합니다.</figcaption>
</figure>

<p>핑거프린트 가드를 제대로 맞닥뜨린 것도 여기서였습니다. 낡은 위젯 plist를 고친 뒤, 서버는 다음 릴리스를 409로 거부했습니다. 네이티브 프로젝트의 핑거프린트가 2.5.1에 대해 기록해둔 것과 더 이상 일치하지 않았기 때문입니다. Android에서는 <code>app.config.js</code>의 주석 하나를 고친 것만으로 충분했습니다. Expo가 앱 설정을 핑거프린트에 포함시키니까요. 대시보드는 같은 검사를 두 핑거프린트와 &ldquo;Upload anyway&rdquo; 버튼이 달린 경고로 보여줍니다. 두 경우 모두 설치된 바이너리의 네이티브 쪽은 아무것도 바뀌지 않았으니 무시하고 올리는 게 맞았습니다. 하지만 실제 운영에서 정직한 답은 거의 언제나 버전을 올리고 스토어 빌드를 하는 것이고, 제 퍼블리시 스크립트는 이제 이 상황이 생기면 정확히 그렇게 말해줍니다.</p>
<p>코드 서명은 RS256입니다. CLI가 패키지 해시를 RSA 개인 키로 서명하고, SDK는 바이너리에 컴파일된 공개 키로 검증합니다. 키는 base64 한 줄로 들어가서 제 빌드 설정 방식과 잘 맞았고, 두 플랫폼에서 모두 테스트했습니다. 앱에 키가 들어 있으면 서명되지 않은 릴리스는 다운로드 전에 거부됐습니다. 앱에 &ldquo;require code signing&quot;을 켜두면 서버는 서명되지 않은 퍼블리시를 거부하면서 해결 방법을 적고 가이드를 링크한 에러를 돌려줬습니다. 서명된 릴리스는 설치됐고, SDK는 서명이 검증됐다고 기록했습니다. 서명된 릴리스를 비활성화했을 때 서버가 되돌아간 이전의 서명되지 않은 릴리스도 거부했고요. 걸리는 점은, 업데이트로는 공개 키를 추가할 수 없기 때문에 처음 내보내는 바이너리에 공개 키가 들어 있어야 한다는 것입니다.</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRnAAAABXRUJQVlA4IGQAAABwBACdASogAB0APw14sE+mI6IhKAgAxiGJ6QAAM+3OuAEp3oUrP1GNW2QgAAD+1hmAf4eqLtdnP/9v1g2ZTHRXrrujcZyW6apQhqigB3MdqokDYA7HESmkf+QYJtIlWU+VYAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_2173e76e5a1b2cec.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="1264"
      alt="서명이 Signed, sha256으로 표시된 릴리스 상세"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_f18ca5bb6112ee2a.webp"></span></div>
  <figcaption>서명된 릴리스.</figcaption>
</figure>

<p>팀과 CI도 갖춰져 있습니다. 멤버는 팀 전체에 적용되는 네 역할(owner, admin, developer, viewer) 중 하나를 받고, 초대는 그 사람이 처음 로그인할 때까지 대기합니다. 개인 액세스 토큰은 홈 디렉터리가 비어 있는 상태에서도 <code>--token</code>이나 환경 변수로 잘 동작했고, 토큰 목록에는 각 토큰이 마지막으로 쓰인 시점이 나옵니다. 같은 퍼블리시 스크립트를 Linux 러너에서 시크릿에 든 서명 키로 돌리는 수동 실행 CI 워크플로우도 써뒀는데, 호스팅된 서버가 필요해서 아직 돌려보지는 않았습니다.</p>
<p>그 당시라면 건너뛰었을 도구가 하나 있는데, <code>cmpatch debug ios</code>입니다. 시스템 로그를 &ldquo;OTA&quot;로 대소문자 구분 없이 필터링해서 스트리밍했는데, 이러면 &ldquo;Rotation&quot;에도 매칭돼서 20초짜리 앱 실행에 SpringBoard 메시지가 화면 한가득 나오고 앱에서 나온 건 하나도 없었습니다. 이건 고쳐져서, 이제는 SDK 마커가 붙은 줄만 보여줍니다. SDK 자체는 로그를 많이 남기지 않고 대부분 에러와 개발 빌드 경고 정도라, 스트림은 조용한 편이라고 생각하시면 됩니다. 혹시 보이는 <code>getpwuid_r</code> 경고는 Patch가 아니라 <code>xcrun simctl</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%b7%b8%eb%9e%98%ec%84%9c-%ec%96%bc%eb%a7%88%eb%82%98-%ec%96%b4%eb%a0%a4%ec%9a%b4%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Codemagic이 제 의견을 물었으니, 최대한 담백하게 적겠습니다.</p>
<p>써보는 건 쉽습니다. 명령 두 개, 몇 분이면 실제 서버와 데모 데이터가 든 대시보드, 그리고 자기 앱에 연결할 수 있는 CLI가 생깁니다. 첫 한 시간 중 가장 좋은 부분이었습니다.</p>
<p>SDK 통합은 중간 정도의 작업이고, 어려움의 대부분은 Patch가 아니라 자기 프로젝트 안에 있습니다. 네이티브 폴더가 생성된 새 Expo 앱이라면 정말로 플러그인 하나에 <code>expo-updates</code> 호출을 바꾸는 몇 줄이 전부입니다. maru처럼 커밋된 네이티브 프로젝트, 위젯 익스텐션, 웹 빌드, R8, 빌드 프로파일이 있는 앱에서는 작업이 가장자리에 있었습니다. prebuild를 피하려고 네이티브 변경을 손으로 적용해야 했고, Staging과 Production 빌드가 다를 수 있도록 설정을 빌드 시점으로 옮겨야 했고, SDK를 웹에서 떼어놔야 했습니다. 업데이트의 JavaScript가 바이너리와 같은 환경으로 빌드되도록 하는 것도요. 이해하고 나면 어려운 건 없었습니다. 제가 작업할 때는 그 대부분이 문서에 없었는데, 커밋된 네이티브 프로젝트 경로와 번들 시점의 환경 문제는 이제 문서에 들어가 있습니다.</p>
<p>일상적인 사용은 좋습니다. 퍼블리시는 명령 하나에 30초 남짓. 롤아웃, 승격, 비활성화, 롤백은 CLI에서든 대시보드에서든 전부 1초 이내이고, 에러는 뭐가 잘못됐고 뭘 해야 하는지 말해줍니다. 서버의 가드(핑거프린트 검사, 한 번에 카나리 하나, 서명)는 엄격한데, 그 덕분에 제가 잘못된 일을 하는 걸 여러 번 막아줬습니다.</p>
<p>프로덕션에서 운영하는 건 더 큰 결심이 필요합니다. Postgres와 오브젝트 스토리지가 딸린 Docker Compose 스택을 직접 호스팅하고 백업하고 업그레이드해야 하고, 도메인 두 개와 OAuth 앱이 필요합니다. 이미 서버를 운영하는 팀에게는 조용한 오후 한나절이고요. 서버를 원치 않아서 EAS를 쓰는 팀에게는 이게 전환의 진짜 비용이고, SDK 작업보다 큽니다.</p>
<p>실제 시간으로 따지면 12:29에 CLI를 설치했고 13:34에 시뮬레이터에서 첫 업데이트가 적용됐는데, 그중 30분은 Patch와 무관한 이유로 멈춰 있던 Xcode 빌드였습니다. 이 글에 있는 모든 것은 두 플랫폼 모두 오후 중반까지 끝났습니다. 호스팅 서버는 빼고요.</p>
<p>시간을 가장 많이 잡아먹은 것들을 순서대로 꼽으면 이렇습니다. 커밋된 네이티브 프로젝트를 날려버린 <code>expo prebuild --clean</code>과 그 뒤 모든 걸 손으로 연결하는 방법을 알아내는 일. 아무도 돌리지 않는 버전으로 업데이트를 보낼 뻔한 iOS 버전 감지 버그. 두 번 집계된 설치. 이건 제 잘못이지만 <code>sync()</code>가 스스로 <code>notifyAppReady()</code>를 호출하는 이상 누구나 되풀이하기 쉬운 실수입니다. 그리고 테스트 도중에 저를 놀라게 한, 문서에 없는 크래시 3회 예산. 그다음은 맞긴 하지만 계획이 필요한 것들입니다. 무해한 네이티브 인접 편집에도 발동하는 핑거프린트 가드, 배포당 카나리 하나, 그리고 겹칠 수 없는 라이프사이클 작업(스크립트가 여러 일을 연달아 하는 순간 문제가 됩니다). 나머지는 사소했습니다. 아무 말 없는 첫 실행 설정, 시끄러운 debug 명령, 코드와 맞지 않던 문서 예제 몇 개.</p>
<p>이 모든 걸 상세 내용과 함께 Codemagic에 정리해서 보냈고, 9월 23일 기준으로 대부분이 고쳐졌습니다. 버전 감지, 겹치는 확인, prebuild 경고, 크래시 예산, 아무 말 없던 설정, debug 명령, <code>InstallMode</code> 예제, 그리고 릴리스 작업이나 카나리가 가로막을 때 나오는 에러까지요. 아직 남아 있는 건 더 작은 것들입니다. 다른 곳은 전부 <code>:3000</code>이라고 하는데 대시보드만 API를 <code>:8080</code>으로 보여주는 것, SDK가 웹과 Jest에서 임포트 시점에 예외를 던지는 것, 그리고 문서의 빈틈 몇 군데.</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%a1%9c%eb%8d%95%ec%85%98%ec%9c%bc%eb%a1%9c-%ea%b0%80%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>위의 모든 것은 평가용 스택에서 돌렸습니다. 프로덕션은 같은 앱과 같은 스크립트를 실제 서버에 연결하는 것이고, 이게 maru의 계획입니다.</p>
<!-- 편집자 노트: 이 섹션은 절차입니다. 실제 전환 후에 실제로 일어난 일(호스트, 소요 시간, 깨진 것)로 교체할 것. -->
<p>서버를 운영하는 공식 지원 방식은 Docker Compose 설치입니다. 80, 443 포트가 열린 Linux 머신, 도메인 두 개(API와 대시보드용 하나, 다운로드용 하나), 로그인용 GitHub OAuth 앱(이게 없으면 서버가 시작하지 않습니다)이 필요합니다. 나머지는 TLS까지 포함해 설치 스크립트가 처리합니다.</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">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>데이터베이스와 스토리지 시크릿이 담긴 <code>.env.selfhost</code>를 써주는데, 이건 비밀번호 관리자에 넣어둬야 합니다.</p>
<p>다만 번들된 데이터베이스와 스토리지를 꼭 써야 하는 건 아니고, 그래서 제 스택 같은 구성에도 맞습니다. maru의 API는 이미 Fly.io에서 돌고 파일은 Tigris에 있는데, Patch는 둘 다에서 동작합니다. Compose 파일 아래를 보면 서버는 <code>MODE=all</code>(API와 릴리스 워커 함께)로 도는 컨테이너 하나이고, Postgres 데이터베이스, S3 호환 스토리지, 파일용 공개 HTTPS URL이 필요합니다. 즉 저장소의 Dockerfile로 빌드한 Fly 앱(아직 공개된 이미지가 없어서 <code>fly deploy</code>가 빌드합니다), Fly Postgres 데이터베이스, Tigris 버킷이면 됩니다.</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">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>주의할 점 둘. Compose 밖에서 운영하는 건 저장소에 참고 자료로 문서화되어 있을 뿐 이번 첫 오픈소스 릴리스에서 지원되는 경로는 아니라서, Fly에서는 업그레이드와 백업을 알아서 챙겨야 합니다. 저도 아직 그렇게 배포해보지는 않았습니다. 이건 보고가 아니라 계획입니다. 그리고 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly의 0으로 스케일 다운</a> 글을 읽으셨다면, 머신은 최소 한 대는 켜두세요. 매니페스트가 스토리지에서 바로 오기 때문에 폰이 업데이트 확인에서 API를 기다리는 일은 없지만, 그렇지 않으면 모든 메트릭 업로드와 모든 릴리스 작업이 콜드 부팅으로 시작하게 됩니다.</p>
<p>Tigris가 제가 가장 기대하는 부분입니다. maru의 파일이 이미 거기 있고, 앱이 실행될 때마다 때리는 URL 앞에 CDN을 세워주니까요. 이렇게 할 거라면 매니페스트 캐싱에 주의하세요. 기본 base-URL 전달 어댑터에서 Patch는 매니페스트를 <code>no-cache</code>로 서빙하는데, CDN을 퍼지할 수 없기 때문입니다. 매니페스트가 자체 TTL로 캐시되면 방금 롤백한 릴리스를 계속 내보내게 됩니다. 이 내용은 이제 설정 레퍼런스의 <code>MANIFEST_CACHE_CONTROL</code> 항목에 문서화되어 있고, 문서 사이트에서도 S3 호환 스토리지를 다룹니다.</p>
<p>호스팅된 서버에서는 앱을 다시 만들고, 새 키를 <code>eas.json</code>의 <code>preview</code>와 <code>production</code> 프로파일에 넣고(Staging 키와 Production 키), 퍼블리시용 토큰을 만듭니다.</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">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>코드 서명을 쓸 거라면 지금 정하세요. 프로덕션 키 쌍을 생성하고, 개인 키는 CI 시크릿으로 보관하고, 공개 키는 빌드 프로파일에 넣고, 두 앱 모두 &ldquo;require code signing&quot;을 켭니다. 첫 Patch 바이너리에는 새 버전 번호도 필요합니다. 스토어에 이미 올라간 바이너리는 <code>expo-updates</code>를 돌리고 있으니까요. 먼저 preview 프로파일로 TestFlight과 Play 내부 테스트용으로 빌드하고, 누군가에게 건네기 전에 키가 실제로 빌드된 <code>Info.plist</code>와 Android 리소스에 들어갔는지 확인하세요. 제 로컬 테스트가 검증하지 못한 유일한 고리가 이 부분입니다. 환경을 손으로 export했으니까요. 그다음 Staging에 퍼블리시하고, 테스터마다 다운로드 1, 성공 1을 확인하고, 그제야 스토어용으로 빌드합니다.</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">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>그동안 기존 서버는 계속 켜둡니다. 사람들이 아직 업데이트하지 않은 2.5.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%b2%ab-%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ec%97%85%ea%b7%b8%eb%a0%88%ec%9d%b4%eb%93%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><em>2026년 10월 16일 추가.</em></p>
<p>Patch는 10월 초부터 maru의 프로덕션 업데이트를 맡고 있습니다. 그 뒤 첫 네이티브 릴리스는 Expo SDK 58로의 이전이었습니다. React Native 0.88, 그리고 Android에서는 Gradle 플러그인의 새 메이저 버전입니다. 네이티브 변경은 OTA로 보낼 수 없으니 새 바이너리 2.6.0이 되고, 제 퍼블리시 스크립트는 그 바이너리가 나오기 전까지 2.6.0용 업데이트를 보내지 않습니다. 두 플랫폼 모두 첫 시도에 깔끔하게 빌드됐습니다.</p>
<p>위의 프로덕션 섹션에서, 빌드를 누군가에게 건네기 전에 키가 실제로 빌드된 <code>Info.plist</code>와 Android 리소스에 들어갔는지 확인하라고 했습니다. 이번에는 정말 확인했고, 이 섹션이 있는 이유가 바로 그 확인입니다. iOS 빌드에는 Patch 설정 네 개가 다 있었습니다. Android 빌드에는 하나도 없었습니다.</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">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>이 명령은 2.5.11 빌드에서는 키 네 개를 출력했고 2.6.0에서는 아무것도 출력하지 않았습니다. 환경 변수는 문제가 아니었습니다. Gradle의 <code>generateReleaseResValues</code>는 네 개를 다 씁니다. 지우는 건 그 뒤에 도는 릴리스 리소스 축소기(resource shrinker)인데, Android Gradle Plugin 9에서 훨씬 공격적으로 바뀌었습니다. 같은 앱의 리소스가 9,240개에서 7,116개로 줄었습니다. SDK는 설정을 <code>getIdentifier</code>로 이름을 써서 읽는데, 축소기는 이걸 볼 수 없으니 네 문자열을 쓰이지 않는 것으로 보고 지웠습니다. 없는 문자열은 빈 값으로 읽히고, 빈 값은 &ldquo;업데이트 없음&quot;이라는 뜻입니다. 실패하는 건 아무것도 없었을 겁니다. Android 2.6.0은 설치되고, 평소처럼 돌아가고, 업데이트는 한 번도 받지 못했을 것이고, 유일한 신호는 대시보드에 Android 2.6.0 기기가 하나도 없다는 것뿐이었을 겁니다.</p>
<p>수정은 축소기에게 네 문자열을 남기라고 알려주는 리소스 파일 하나입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p>이름을 <code>keep.xml</code>로 짓지는 마세요. React Native의 번들 태스크가 JavaScript 에셋용으로 이미 <code>raw/keep.xml</code>을 생성하고(이미지와 폰트가 살아남은 게 그 덕분입니다), 두 파일이 충돌합니다. 라이브러리의 keep 파일은 앱에 자동으로 병합되니 이 규칙이 있어야 할 더 나은 자리는 SDK 자체이고, 배포 키 없이 릴리스 빌드가 시작될 때 경고 하나만 나와도 조용히 지나가던 상황이 눈에 보이게 될 겁니다. 앞에서 나온 iOS 버전 감지 버그와 같은 종류의 실패입니다. 오류는 나지 않고, 업데이트가 그냥 도착하지 않습니다.</p>
<p>같은 확인에서 Patch와는 상관없는 두 번째 문제도 드러났는데, 이건 다른 이유로 알아둘 만합니다. Android 릴리스 빌드가 실행하자마자 크래시했는데, 그것도 두 번, Expo 자체 모듈 안의 서로 다른 두 가지 이유 때문이었습니다. 먼저 Expo의 위젯 모듈이 네이티브 클래스를 등록하지 못해서, JavaScript가 돌기도 전에 앱이 죽었습니다. 그걸 고치자 앱은 JavaScript를 시작하는 데까지는 갔다가 멈췄는데, Expo의 새 모듈 시스템으로 옮겨간 모듈 세 개가 등록되지 않았기 때문입니다. 둘 다 Patch의 크래시 롤백으로는 도움이 안 됩니다. 롤백할 업데이트가 없으니까요. 크래시 롤백은 나쁜 업데이트로부터 지켜주는 것이지, 나쁜 바이너리로부터 지켜주는 게 아닙니다. Patch가 있든 없든, 네이티브 업그레이드는 스토어에 올리기 전에 실제 기기나 에뮬레이터에 설치해서 열어봐야 합니다.</p>
<p>같은 테스트에서 작은 것이 하나 더 나왔습니다. <code>sync()</code>는 실패하면 <code>&quot;error&quot;</code>를 반환하고, 앱이 개발 모드로 돌고 있지 않으면 이유는 알려주지 않습니다. 그래서 릴리스 빌드는 뭔가 잘못됐다는 것밖에 로그로 남길 수 없습니다. 이제 제 훅은 sync가 실패하면 <code>checkForUpdate()</code>를 한 번 호출해서 거기서 던지는 오류를 로그로 남기는데, 덕분에 문제가 서버가 아니라 로컬 시뮬레이터 빌드의 Patch URL이 비어 있던 것(<code>unsupported URL</code>)이라는 걸 알게 됐습니다. 마지막 오류를 릴리스 빌드에서도 읽을 수 있다면 도움이 될 겁니다.</p>
<p>이제 제 네이티브 릴리스 체크리스트에는 전에 없던 세 줄이 있습니다. 빌드된 <code>.ipa</code>와 <code>.aab</code>에서 Patch 설정을 덤프해볼 것, 배포 키를 빌드 프로파일과 비교할 것, 그리고 제출하기 전에 debug 빌드가 아니라 릴리스 빌드 자체를 실행해볼 것.</p>

<h2 class="relative group">Expo 청구서를 받고 있는 분에게
    <div id="expo-청구서를-받고-있는-분에게" 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="#expo-%ec%b2%ad%ea%b5%ac%ec%84%9c%eb%a5%bc-%eb%b0%9b%ea%b3%a0-%ec%9e%88%eb%8a%94-%eb%b6%84%ec%97%90%ea%b2%8c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>서버를 만드는 게 너무 큰 일처럼 보여서 EAS Update에 돈을 내고 있다면, Patch는 실질적인 답입니다. 서버를 운영하긴 하지만, 그건 Compose 스택과 설치 스크립트이지 여러분이 유지보수할 코드가 아닙니다. 제가 만든 것과 비교하면 단계적 롤아웃, 릴리스별 설치·실패 숫자, 바이너리 패치, 핑거프린트 가드, 코드 서명, 기기에서의 크래시 롤백, 롤백 버튼이 따라옵니다. 저는 그중 하나도 만들지 않았습니다.</p>
<p>비용은 이렇습니다. SDK가 앱에 들어 있어야 하니 누구든 Patch 업데이트를 받기 전에 새 바이너리가 필요하고, 계속 업그레이드하고 백업해야 할 서버가 생기고, 릴리스가 충분히 나쁘면 기본값 기준으로 사용자마다 크래시 세 번을 겪고, 네이티브 프로젝트가 커밋되어 있다면 손이 좀 갑니다. 아직 어리기도 합니다. 이번은 0.3.0 버전이었고, 버전 감지 버그, 시끄러운 debug 명령, 문서의 빈틈 몇 군데를 만났고, SDK가 막아줄 수 있었을 버그를 제가 직접 하나 만들었습니다. 버전 감지와 debug 명령은 그 뒤 고쳐졌고, 문서의 빈틈도 대부분 메워졌고, SDK는 이제 제가 한 실수를 막아줍니다. 그중 어떤 것도 마이그레이션을 멈추지는 못했고, 전부 CLI에서, 대시보드에서, 혹은 기기 안 SDK 파일에서 눈에 보였습니다. 제 예전 구성에 대해서는 할 수 없는 말이죠.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/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>
