<?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>S3 · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/s3/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/s3/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>S3에 직접 업로드하면서 이미지 압축도 놓치지 않기: Tigris 객체 알림과 Django</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/tigris-object-notifications/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/tigris-object-notifications/</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Tigris</category>
      <category>S3</category>
      <category>S3 Event Notifications</category>
      <category>Presigned URLs</category>
      <category>Image Uploads</category>
      <category>Image Compression</category>
      <category>Object Storage</category>
      <category>Webhooks</category>
      <category>Django</category>
      <category>Fly.io</category>
      <category>Discord</category>
      <description>presigned URL로 S3에 직접 업로드하면 빠르지만, 파일이 도착했다는 걸 백엔드는 알 길이 없어서 이미지 압축과 썸네일 처리가 애매해집니다. Tigris 객체 알림이 그 빈틈을 메워 줍니다. 버킷이 Django 웹훅으로 POST를 보내고, 사용자가 아직 글을 쓰는 동안 사진이 처리되죠. 같은 신호로 로그 파일을 Discord 알림으로 바꾸기도 했습니다.</description>
      <content:encoded><![CDATA[<p>제가 만들어 본 이미지 업로드에는 늘 같은 긴장이 있었습니다. 올리는 사람은 사진이 빨리 올라가길 바랍니다. 저는 서버가 그 사진을 압축하고, 썸네일을 만들고, blurhash와 색상 몇 개를 뽑아내고, 있어서는 안 될 메타데이터를 떼어 내고, 앱의 나머지 부분이 기대하는 모양으로 저장해 주길 바라고요. 제 목록을 전부 채우는 가장 쉬운 길은 파일을 API로 보내는 것인데, 바로 그 길이 올리는 사람의 목록을 느리게 만듭니다.</p>
<p>빠른 쪽은 presigned URL(미리 서명된 URL)입니다. API가 URL에 서명해 주면 클라이언트가 파일을 버킷에 바로 PUT하고, 바이트는 API를 전혀 거치지 않습니다. 표준적인 답이고, 그럴 만한 이유가 있습니다. 그런데 이렇게 하는 순간 백엔드는 흐름에서 빠져 버립니다. URL을 하나 내주고 나서는 아무 소식도 못 듣죠. 파일이 도착했는지, 예상한 크기인지, 애초에 JPEG이 맞기는 한지. 누군가는 백엔드에 알려 줘야 하는데, 흔한 선택지는 하나같이 좀 서글픕니다. 클라이언트가 &ldquo;다 됐어요&quot;라고 다시 호출해 주길 믿거나(클라이언트는 거짓말하고, 죽고, 신호가 끊깁니다), 버킷을 주기적으로 폴링하거나(느리고 낭비죠), 첫 읽기 때 느긋하게 처리하거나(첫 방문자가 그 비용을 치르고, 파일을 언제부터 참조해도 안전한지는 여전히 모릅니다). S3 직접 업로드가 실제보다 어려워 보이는 이유가 이겁니다. 업로드 자체는 쉽습니다. 빠진 건 신호입니다.</p>
<p>객체 알림이 바로 그 신호입니다. 제가 Fly.io에서 쓰는 S3 호환 스토리지인 Tigris는 버킷에서 객체가 생성되거나 삭제될 때마다 작은 JSON 본문을 여러분의 앱에 POST해 줍니다. AWS에서 이에 해당하는 것이 S3 이벤트 알림인데, 보통은 Lambda나 SQS 큐, EventBridge를 먼저 엮어야 뭔가가 코드에 닿습니다(SNS가 HTTPS 엔드포인트로 POST를 보내 줄 수는 있지만, 그러면 구독 확인과 서명 검증부터 해야 하죠). Tigris는 이걸 여러분이 소유한 URL로 가는 HTTP 요청 하나로 줄였습니다. 스토리지에서 백엔드로 신호를 보내는 방법으로는 이보다 단순하기 어렵죠. 버킷이 백엔드와 이야기할 수 있도록, 여러분이 그 사이에 다리를 놓지 않아도 되도록 있는 기능입니다.</p>
<p>이 글은 그 흐름 전체를 다룹니다. 설정, 이벤트를 받는 Django 엔드포인트, 서버 쪽 처리를 하나도 포기하지 않는 presigned 업로드, 그리고 함정들(CORS, 크기 제한, 만료, 여러 파일)까지요. 마지막에는 같은 신호로 백오피스 일을 시킨 이야기도 있습니다. 버킷에 떨어지는 로그 파일이 Discord 알림이 됐고, 덕분에 몇 달 동안 달래 가며 쓰던 cron 작업 하나를 지울 수 있었습니다.</p>

<h2 class="relative group">짧은 답
    <div id="짧은-답" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a7%a7%ec%9d%80-%eb%8b%b5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Tigris 대시보드에서 버킷을 열고 Settings, 그다음 Object notifications로 들어갑니다. 앱의 HTTPS URL을 넣고 Basic 인증을 고릅니다(Tigris가 <code>Authorization</code> 헤더에 담아 보내는 사용자 이름과 비밀번호입니다. 다른 선택지는 bearer 토큰이고요). 관심 있는 키만 듣도록 필터도 하나 겁니다. 필터는 SQL 비슷한 표현식이라서, &ldquo;이 글에서 다루는 프리픽스 세 개만&quot;은 이렇게 씁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="o">`</span><span class="k">key</span><span class="o">`</span><span class="w"> </span><span class="n">REGEXP</span><span class="w"> </span><span class="s2">&#34;^(uploads/raw|logs/api|media)/&#34;</span></span></span></code></pre></div></div>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigris 버킷 설정의 Notifications 탭: 객체 알림 켜짐, 웹훅 URL, 사용자 이름과 비밀번호를 쓰는 Basic 인증, SQL 비슷한 키 필터"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp"></span><figcaption>설정은 이게 전부입니다. 앱의 HTTPS URL, Basic 인증, 그리고 키 필터. 여기 보이는 버킷 이름과 URL은 예시로 바꿔 둔 것입니다.</figcaption></figure>
</p>
<p>그러면 Tigris가 변경이 있을 때마다 이런 배치를 보냅니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;events&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventName&#34;</span><span class="p">:</span> <span class="s2">&#34;OBJECT_CREATED_PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventTime&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-11-07T09:12:44Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;example-bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;uploads/raw/1f3c.jpg&#34;</span><span class="p">,</span> <span class="nt">&#34;size&#34;</span><span class="p">:</span> <span class="mi">48213</span><span class="p">,</span> <span class="nt">&#34;eTag&#34;</span><span class="p">:</span> <span class="s2">&#34;9b2c…&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>마주치게 될 이벤트 이름은 <code>OBJECT_CREATED_PUT</code>, <code>OBJECT_CREATED_MULTIPART</code>, <code>OBJECT_CREATED_COPY</code>, <code>OBJECT_DELETED</code>이고, 이름 변경과 스토리지 계층 전환 이벤트도 있습니다. 엔드포인트는 인증을 확인하고, 이벤트마다 키 프리픽스로 라우팅한 뒤, 10초 안에 2xx를 돌려주면 됩니다. 처음부터 염두에 둘 것이 세 가지 있습니다. 전달은 최소 한 번(at least once)이라 같은 이벤트를 다시 보게 되고, 이벤트는 배치로 오기 때문에 요청 하나에 여러 개가 실릴 수 있으며, 순서가 뒤바뀌어 도착할 수도 있으니 도착한 순서로 &ldquo;최신&quot;을 판단하면 안 됩니다.</p>

<h2 class="relative group">버킷 이벤트를 받는 Django 웹훅
    <div id="버킷-이벤트를-받는-django-웹훅" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b2%84%ed%82%b7-%ec%9d%b4%eb%b2%a4%ed%8a%b8%eb%a5%bc-%eb%b0%9b%eb%8a%94-django-%ec%9b%b9%ed%9b%85" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>뷰는 일부러 지루하게 만들었습니다. 인증을 확인하고, 본문을 파싱하고, 이벤트를 하나씩 라우터에 넘깁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">base64</span><span class="o">,</span> <span class="nn">hmac</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.http</span> <span class="kn">import</span> <span class="n">JsonResponse</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.csrf</span> <span class="kn">import</span> <span class="n">csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.http</span> <span class="kn">import</span> <span class="n">require_POST</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">.bucket_events</span> <span class="kn">import</span> <span class="n">handle</span><span class="p">,</span> <span class="n">parse</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">header</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Authorization&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">header</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;Basic &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">password</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="mi">6</span><span class="p">:])</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="o">.</span><span class="n">partition</span><span class="p">(</span><span class="s2">&#34;:&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">user</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="sa">b</span><span class="s2">&#34;tigris&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">password</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">secret</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="nd">@require_POST</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket_webhook</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">secret</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BUCKET_WEBHOOK_SECRET</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;unauthorised&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">body</span> <span class="ow">or</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;invalid json&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">400</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">event</span> <span class="ow">in</span> <span class="n">parse</span><span class="p">(</span><span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">handle</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;bucket event failed: </span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">})</span></span></span></code></pre></div></div>
<p><code>compare_digest</code>를 쓴 건 무엇을 보내든 확인에 걸리는 시간이 같게 하려는 겁니다(바이트로 비교하는 이유는, 문자열로 비교하면 ASCII가 아닌 입력에서 예외가 나서 조작된 헤더가 401 대신 500을 받기 때문입니다). 갓 만든 스테이징 서버처럼 시크릿이 설정돼 있지 않으면, 엔드포인트는 전부 받아들이는 대신 전부 거부합니다. 그리고 핸들러가 예외를 던져도 로그만 남기고 200을 돌려줍니다. Tigris는 2xx가 아니면 전부 재시도하는데, 재시도해 봐야 같은 버그에 부딪힐 테고, 그때쯤이면 이벤트는 이미 선점돼 있어서(두 섹션 아래에 나옵니다) 재시도가 아무 일도 하지 않을 테니까요. 고치게 만드는 건 그 로그 한 줄입니다. 다만 이건 실패가 매번 똑같이 재현된다는 가정 위에 서 있습니다. 스토리지가 잠깐 타임아웃된 거라면 그 이벤트는 영영 소비돼 버립니다. 사진은 상관없습니다. 저장 요청이 직접 처리할 수 있으니까요. 하지만 로그 파일 쪽 알림은 그대로 잃어버리게 됩니다. 그게 중요하다면 <code>except</code>에서 선점을 풀고 500을 돌려줘 Tigris가 재시도하게 하세요. 대신 진짜 버그는 고칠 때까지 계속 재시도된다는 건 감수해야 하고요.</p>

<h2 class="relative group">프리픽스로 라우팅하기
    <div id="프리픽스로-라우팅하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%84%eb%a6%ac%ed%94%bd%ec%8a%a4%eb%a1%9c-%eb%9d%bc%ec%9a%b0%ed%8c%85%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>버킷 안의 모든 것이 이미 프리픽스별로 정리돼 있으니, 라우팅도 그걸 기준으로 하는 게 자연스럽습니다. 용도 하나가 함수 하나, 테이블 한 줄입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">HANDLERS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;uploads/raw/&#34;</span><span class="p">,</span>   <span class="n">on_raw_photo</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;logs/api/&#34;</span><span class="p">,</span>      <span class="n">on_api_log_file</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;media/&#34;</span><span class="p">,</span>         <span class="n">on_media_change</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">HANDLERS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">prefix</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">claim</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>  <span class="c1"># 아래 참고</span>
</span></span><span class="line"><span class="cl">                <span class="n">fn</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span></span></span></code></pre></div></div>
<p>나중에 네 번째 용도를 추가하려면 함수 하나와 행 하나만 더하면 됩니다. 이게 생각보다 마음에 들더군요.</p>

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

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

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

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

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

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

<h2 class="relative group">presigned URL 업로드의 함정: CORS, 크기 제한, 만료, 여러 파일
    <div id="presigned-url-업로드의-함정-cors-크기-제한-만료-여러-파일" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#presigned-url-%ec%97%85%eb%a1%9c%eb%93%9c%ec%9d%98-%ed%95%a8%ec%a0%95-cors-%ed%81%ac%ea%b8%b0-%ec%a0%9c%ed%95%9c-%eb%a7%8c%eb%a3%8c-%ec%97%ac%eb%9f%ac-%ed%8c%8c%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>presigned 업로드에서 꼬이는 일은 대부분 알림이 나가기도 전에 꼬입니다. 한 페이지에 모아 두고 싶었던 것들입니다.</p>

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

<h3 class="relative group">Content-Type은 서명의 일부다
    <div id="content-type은-서명의-일부다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#content-type%ec%9d%80-%ec%84%9c%eb%aa%85%ec%9d%98-%ec%9d%bc%eb%b6%80%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>ContentType</code>을 넣어 서명하면 클라이언트는 정확히 그 헤더를 보내야 하고, 아니면 버킷이 403 <code>SignatureDoesNotMatch</code>로 답합니다. 슬롯이 URL과 함께 헤더까지 돌려주는 이유가 그겁니다. 일부러 넣어서 서명하되, 그게 어디까지 막아 주는지는 알아 두세요. 강제되는 건 헤더이지 내용이 아니라서, <code>image/jpeg</code>라고 이름표를 붙인 PDF는 통과합니다. 진짜 검사는 파이프라인입니다. Pillow는 이미지가 아닌 것은 열기를 거부하고, 행은 끝내 처리됨으로 표시되지 않습니다.</p>

<h3 class="relative group">크기 제한
    <div id="크기-제한" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%ea%b8%b0-%ec%a0%9c%ed%95%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>presigned PUT URL에는 크기 제한이 없습니다. 바이트가 떨어지기 전에 확실히 막아야 한다면 <code>content-length-range</code> 정책 조건을 건 presigned POST가 그 일을 합니다. 저는 굳이 그러지 않았습니다. 알림에 객체의 <code>size</code>가 실려 오니까요. 핸들러는 제한을 넘는 것은 읽기 전에 지워 버리고, 저장 시점 경로는 많아야 제한에 1바이트를 더한 만큼만 읽어서 넘었는지 판별합니다. 앱이 보내는 건 몇백 KB입니다. 15MB 근처라면 앱이 보낸 게 아닙니다.</p>

<h3 class="relative group">만료
    <div id="만료" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a7%8c%eb%a3%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>시계가 두 개입니다. presigned URL은 15분 동안 유효한데, PUT 한 번에는 충분하고 새어 나간 URL이 금방 쓸모없어질 만큼 짧습니다. 슬롯 행은 이틀 동안 유효해서, 사진을 골라 놓고 휴대폰을 내려놨다가 다음 날 게시글을 마무리하는 경우까지 감당합니다. 클라이언트는 하루가 지난 미완료 업로드는 낡은 것으로 보고, 요행을 바라는 대신 새로 시작합니다.</p>

<h3 class="relative group">여러 파일, 그리고 더 큰 파일
    <div id="여러-파일-그리고-더-큰-파일" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%97%ac%eb%9f%ac-%ed%8c%8c%ec%9d%bc-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%8d%94-%ed%81%b0-%ed%8c%8c%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>파일 하나에 슬롯 하나입니다. 사진 열 장이면 슬롯 열 개와 병렬 PUT 열 개이고, 각각 진행률이 따로 있습니다(<code>XMLHttpRequest</code>는 <code>upload.onprogress</code>를 주고, <code>fetch</code>는 주지 않습니다). 거대한 base64 POST 하나로는 얻을 수 없는 것이죠. 몇 MB를 넘어가면 멀티파트를 쓰세요(파트 하나가 최소 5 MiB라서, 그보다 한참 큰 파일에 쓰는 겁니다). 서버에서 멀티파트 업로드를 만들고, <code>upload_part</code>마다 presign한 뒤, 파트가 다 들어오면 완료하면 됩니다. 업로드가 완료되면 Tigris가 <code>OBJECT_CREATED_MULTIPART</code>를 보내니 같은 핸들러가 돌고 다른 건 아무것도 바뀌지 않습니다.</p>

<h3 class="relative group">순서가 뒤바뀔 때
    <div id="순서가-뒤바뀔-때" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%88%9c%ec%84%9c%ea%b0%80-%eb%92%a4%eb%b0%94%eb%80%94-%eb%95%8c" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>객체가 둘 이상의 리전에 쓰였을 수 있기 때문에 이벤트는 순서가 뒤바뀌어 도착할 수 있습니다. 업로드에는 상관없습니다. 키 하나에 이벤트 하나니까요. 덮어써지는 키라면 웹훅이 도착한 순서가 아니라 eTag와 객체의 <code>Last-Modified</code>를 믿으세요.</p>

<h2 class="relative group">같은 신호를 로그에도: Discord 알림
    <div id="같은-신호를-로그에도-discord-알림" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%99%ec%9d%80-%ec%8b%a0%ed%98%b8%eb%a5%bc-%eb%a1%9c%ea%b7%b8%ec%97%90%eb%8f%84-discord-%ec%95%8c%eb%a6%bc" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>버킷이 백엔드와 이야기할 수 있게 되면, 버킷에 떨어지는 무엇에든 같은 방식으로 반응할 수 있습니다. 제가 두 번째로 겨냥한 것은 로그였습니다.</p>
<p>앱 로그는 작은 gzip 파일로 버킷에 실려 옵니다. 앱마다 대략 5분에 하나씩이죠. 올해 대부분 동안은 15분 주기 cron으로 도는 GitHub Action이 DuckDB로 그 아카이브의 최근 20분을 읽어서 느린 요청 줄을 찾아 Discord에 요약을 올리고 있었습니다. 서류상으로는 문제없습니다. 그런데 실제로는 플랫폼이 바쁠 때 GitHub의 예약 워크플로가 제시간에 돌지 않습니다. 그래서 &ldquo;15분마다&quot;는 사실상 &ldquo;몇 시간마다&quot;에 가까웠고, 겨우 돌 때쯤엔 씹어야 할 구간이 커져 있어서 가끔 메모리가 바닥났습니다. 실행 사이에 빠지는 게 없도록 구간을 겹치게 잡아 뒀더니 같은 느린 요청이 두 번 올라오는 일도 생겼고요. &ldquo;요청이 느리면 알려 줘&rdquo; 하나 하자고 돌리기엔 장치가 너무 많았습니다.</p>
<p>이제는 로그 파일이 도착하는 순간 한 번씩만 읽습니다. gzip으로 몇 KB밖에 안 되는 파일들이죠. 핸들러가 느린 요청 줄을 뽑아내고, 그런 줄이 충분히 많거나 정말 느린 게 하나라도 있으면 Discord 웹훅으로 채널에 요약을 올립니다. 한 파일 안에 5xx 응답이 몰려 있을 때도 마찬가지입니다. Python에서 Discord 웹훅을 쏘는 건 호출 한 번입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discord_alert</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">settings</span><span class="o">.</span><span class="n">DISCORD_WEBHOOK_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">1900</span><span class="p">]},</span>  <span class="c1"># Discord 메시지 상한은 2000자</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>cron도 없고, 구간 겹침도 없고, 중복도 없습니다. 알림은 GitHub가 마음 내킬 때가 아니라 요청이 있고 몇 분 안에 도착합니다. 그 Action은 사라졌습니다.</p>
<p>어차피 파일을 열어 둔 김에, 안에 든 것(에러, 경고, 헬스 체크)을 세어서 시간별 집계 테이블에도 넣습니다. 일일 요약은 예전엔 하루치 아카이브를 통째로 훑었는데, 이제는 몇백 행만 읽고 집계에 빈 곳이 있을 때만 아카이브로 돌아갑니다.</p>

<h2 class="relative group">삭제 감시
    <div id="삭제-감시" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%82%ad%ec%a0%9c-%ea%b0%90%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이건 계획한 게 아닙니다. <code>OBJECT_DELETED</code> 이벤트가 생기니 저절로 따라 나온 거죠. 미디어 파일이 삭제되면 핸들러가 아직 그 파일을 가리키는 행이 있는지 확인합니다. 있다면 어딘가에 버그가 있는 거라서(아니면 버킷 콘솔에서 손이 미끄러진 사람이 있거나요), 해당 컬럼을 비워 앱이 깨진 이미지 대신 플레이스홀더를 보여 주게 하고, 보고를 올립니다.</p>
<p>시간당 삭제 수도 셉니다. 평소 정리 작업으로 지워지는 건 몇 개 정도입니다. 혹시라도 한 시간에 몇백 개가 지워진다면, 사용자들이 사진이 비어 있다고 신고하기 시작할 때가 아니라 바로 그 순간에 알고 싶습니다.</p>

<h2 class="relative group">알아 두면 좋은 것들
    <div id="알아-두면-좋은-것들" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%95%8c%ec%95%84-%eb%91%90%eb%a9%b4-%ec%a2%8b%ec%9d%80-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>빨리 응답하세요. Tigris는 전달 한 번에 10초를 줍니다. 5 KB짜리 로그 파일을 읽거나 휴대폰 사진 한 장을 처리하는 건 그 안에 들어갑니다. 더 걸릴 수 있는 건 큐로 보내고 뷰는 200을 돌려주세요. 안 그러면 Tigris가 타임아웃 후 재시도하고, 다시 중복 제거 문제로 돌아가게 됩니다.</p>
<p>버킷 단에서 거르세요. 제 로그 수집기는 앱마다 몇 분에 한 번씩, 끝없이 파일을 씁니다. 관심 있는 프리픽스가 하나뿐이라면 알림 필터에 그렇게 적어서, 나머지 일로 앱이 깨어나지 않게 하세요.</p>
<p>가짜 본문으로 테스트하세요. 페이로드가 작고 안정적이라서, 테스트는 올바른 <code>Authorization</code> 헤더와 함께 JSON을 뷰에 POST하고 핸들러가 한 일을 확인하기만 하면 됩니다. 실제 스토리지는 건드리지 않습니다.</p>
<p>첫날부터 모든 걸 걸지는 마세요. 여기 나온 용도는 전부 대체 수단이 있습니다. 저장이 사진을 직접 처리할 수 있고, 일일 요약은 여전히 아카이브를 훑을 수 있고, 앱은 여전히 base64를 보낼 수 있습니다. 알림 덕분에 하나하나 빨라지고 저렴해졌지만, 웹훅 하나가 빠졌다고 무너지는 건 없습니다.</p>
<p>이번 주의 백미는 그 워크플로 파일을 지운 순간이었습니다. YAML 40줄, Python 스크립트 하나, 저장소 시크릿 두 개, 그리고 주기적으로 찾아오던 &ldquo;알림이 왜 안 왔지?&ldquo;가 전부 사라졌고, 그 자리를 작은 파일 하나가 도착하면 그걸 읽는 핸들러 하나가 대신하고 있습니다. 그래도 핵심은 업로드였습니다. 사진은 네트워크가 허락하는 만큼 빠르게 올라가고, 서버는 여전히 사진 하나하나에 대해 마지막 결정권을 쥐고 있으며, 버킷과 백엔드 사이에 다리를 놓을 필요는 아무에게도 없었습니다. 이제는 버킷이 무슨 일이 있었는지 그냥 말해 주니까요.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>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>
