<?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>React Native · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/react-native/</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/react-native/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Sentry 셀프 호스팅 없이: 오픈소스 SDK로 React Native 크래시 리포트를 우리 서버에서 받기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>크래시 리포팅</category>
      <category>셀프 호스팅</category>
      <category>iOS</category>
      <category>Android</category>
      <category>개인정보</category>
      <description>Sentry를 셀프 호스팅하지도, sentry.io 비용을 내지도 않고 React Native 앱의 크래시 리포트를 받는 방법입니다. Sentry 오픈소스 SDK는 그대로 두고 DSN만 우리 Django API로 돌려, 묶기와 알림, 개인정보 제거까지 직접 합니다.</description>
      <content:encoded><![CDATA[<p>React Native 앱의 크래시 리포트를 직접 받고 싶다면 Sentry 서버를 셀프 호스팅할 필요도, sentry.io에 비용을 낼 필요도 없습니다. Sentry SDK는 오픈소스이고, DSN은 어떤 호스트든 가리킬 수 있습니다. 저는 우리 Django API의 엔드포인트로 돌려서 그곳에서 리포트를 받고, 묶고, 알림을 보내고, 보관합니다. 원래 돌던 API 말고는 따로 드는 비용이 없습니다. 아직 대체하지 못한 건 심볼리케이션이라, 스택 트레이스만으로는 소스의 어느 줄인지 알 수 없습니다.</p>
<p>이번 주 전까지 우리 앱이 크래시 났다는 걸 아는 방법은 누군가 알려 주는 것뿐이었습니다. 그런데 대부분은 알려 주지 않습니다. 앱을 다시 열고, 또 꺼지면 그냥 안 쓰게 되죠. API 쪽은 대비가 돼 있었습니다(ERROR 로그는 전부 팀 채팅 채널로 갑니다). 하지만 휴대폰에서 일어난 크래시는 제가 볼 수 있는 어디에도 흔적을 남기지 않았습니다.</p>
<p>사실 앱에 리포터가 하나 있긴 했습니다. 앱이 사용자에게 오류 메시지를 보여 주면 그 메시지를 API의 작은 엔드포인트로 보내고, 서버가 이를 ERROR로 기록하니 채팅 채널에도 올라옵니다. 삭제가 실제로는 <em>성공</em>했는데 목록에 행이 그대로 남아 있었고, 그걸 다시 탭하자 &ldquo;No Item matches the given query&quot;가 뜬 일이 있었습니다. 그때 만든 장치입니다. 서버는 평범한 404 두 개만 기록하고 아무 말도 하지 않았거든요. 이건 앱이 잡아서 보여 주는 오류만 다룹니다. 크래시는 다릅니다. 리포트를 보내야 할 JavaScript 자체가 방금 죽어 버린 거니까요.</p>
<p>뻔한 해법은 Sentry입니다. 저는 Sentry SDK를 꽤 좋아합니다. React Native에서 크래시 리포팅이 이미 해결된 문제처럼 느껴지는 건 이 SDK 덕분이죠. 다만 사용자 데이터를 쥐고 있는 업체가 하나 더 늘고, 개인정보 처리방침에 한 줄이 더 붙고, 우리 규모로는 하루에 많아야 몇 건인 일에 청구서가 또 하나 생기는 건 원하지 않았습니다. 그래서 SDK는 남기고 서비스만 바꿨습니다. 앱의 DSN은 우리 API를 가리키고, 그 Django 앱의 작은 한 부분이 SDK가 보내는 걸 받아서 묶고, 알리고, 보관합니다.</p>
<p>이 글은 그 구조와, 실제 네이티브 크래시가 정말 끝까지 도착하는지 확인한 과정에 대한 이야기입니다.</p>

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

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

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

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

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

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

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

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

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

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

<h2 class="relative group">아직 없는 것
    <div id="아직-없는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%95%84%ec%a7%81-%ec%97%86%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>심볼리케이션.</strong> 이게 진짜 빈자리이고, sentry.io가 해 주는 일 중 제가 대체하지 못한 단 하나입니다. JavaScript 스택은 minify돼 있고, 네이티브 스택은 심볼리케이션이 안 돼 있습니다.</p>
<ul>
<li><strong>JavaScript:</strong> 프레임이 Hermes 번들 안을 가리킵니다. 고치려면 모든 스토어 빌드와 모든 OTA 번들의 합성된 Hermes 소스 맵을 릴리스와 번들별로 보관해 두고, 페이지를 렌더링할 때 JS 프레임에 적용해야 합니다. 우리 크래시는 대부분 JavaScript일 테니 이게 다음 작업입니다.</li>
<li><strong>iOS 네이티브:</strong> 명령어 주소를 풀려면 해당 빌드의 dSYM이 필요합니다.</li>
<li><strong>Android:</strong> minify를 켜면 Java 프레임은 R8로 난독화되고(<code>mapping.txt</code>가 필요합니다), C++ 프레임은 NDK 심볼이 필요합니다.</li>
</ul>
<p>위에서 발견한 더 작은 빈자리도 있습니다. 네이티브 크래시에는 아직 OTA 태그가 붙지 않습니다. <code>beforeSend</code>에서만 붙이고 있기 때문이죠. 실행 중인 번들이 확정되면 스코프에 태그를 거는 건 몇 줄이면 되는데, 아직 쓰지 않았습니다.</p>
<p>지금으로서는 이슈를 보면 <em>무엇이</em> 크래시 났는지, 어떤 릴리스와 OTA 번들과 기기에서였는지, 그 사람이 직전에 뭘 하고 있었는지는 알 수 있습니다. 소스의 <em>어디에서</em>인지는 알 수 없습니다. 일주일에 몇 번 배포하는 작은 앱에서는 지금까지 타입, 메시지, breadcrumb만으로도 원인을 찾기에 충분했습니다.</p>
<p>수신기와 관리자 페이지, 테스트를 합쳐도 코드는 많지 않고, 이미 돌아가던 API 위에 추가로 드는 비용은 없습니다. 어려운 일은 SDK가 합니다. 휴대폰에서 크래시를 잡고, 다음 실행 때 그 리포트를 밖으로 내보내는 일이죠.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>직접 만든 Expo 업데이트 서버를 Codemagic Patch로 바꾸기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>자체 호스팅</category>
      <category>모바일 개발</category>
      <category>CI/CD</category>
      <description>네이티브 프로젝트까지 커밋된 실제 Expo 앱을 expo-updates에서 Codemagic Patch로 옮겼습니다. 로컬 스택, 마이그레이션 과정, 제가 만들고 OTA로 고친 버그, 그리고 릴리스가 실제로 크래시했을 때 롤백이 어떻게 되는지까지.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>밝혀둘 것.</strong> 이 마이그레이션과 글은 Codemagic의 의뢰로 비용을 받고 진행했습니다. Codemagic은 사실관계 확인을 위한 검토만 했을 뿐 어조에는 관여하지 않았고, 이 글의 어떤 부분도 그쪽 요청으로 바뀌지 않았습니다. 문제를 만나 보고한 곳은 그렇다고 적었고, 읽으실 시점에 고쳐져 있는 문제는 고쳐졌다고 적어두었습니다.</p>
</blockquote><blockquote><p><strong>2026년 9월 23일 업데이트.</strong> Codemagic 개발자들이 제가 보고한 문제들을 하나씩 처리했습니다. iOS 버전 감지 버그, 두 번 집계되던 설치, 아무 말 없던 첫 실행, 시끄러운 debug 명령을 비롯해 대부분이 이제 고쳐졌고, 문서에도 커밋된 네이티브 프로젝트와 크래시 롤백 예산 이야기가 들어갔습니다. 본문에서 각 문제가 나오는 자리마다 표시해두었고, 아직 열려 있는 몇 가지는 그대로 두었습니다.</p>
</blockquote><blockquote><p><strong>2026년 10월 16일 업데이트.</strong> Patch로 옮긴 뒤 첫 네이티브 업그레이드(Expo SDK 58)에서 Patch 설정이 하나도 들어가지 않은 Android 릴리스가 빌드됐는데, 어디서도 오류가 나지 않았습니다. 무슨 일이 있었는지, 그리고 파일 하나로 끝난 수정은 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-13-moving-to-codemagic-patch/#%ec%b2%ab-%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ec%97%85%ea%b7%b8%eb%a0%88%ec%9d%b4%eb%93%9c" >첫 네이티브 업그레이드</a>에 적었습니다.</p>
</blockquote><p>지난 2월에 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-02-22-self-hosted-ota-updates/" >Expo 업데이트 서버를 직접 만든 이야기</a>를 썼습니다. 그 서버는 지금도 잘 돌아갑니다. 제가 운영하는 커뮤니티 앱 maru는 그 뒤로 모든 OTA 수정을 두 플랫폼 모두 이 서버를 통해 받았고, 비용은 월 1달러 정도였습니다.</p>
<p>동시에 그 모든 조각을 제가 떠안고 있다는 뜻이기도 합니다. 데이터베이스 테이블, API 엔드포인트, 관리 명령, 퍼블리시 스크립트, 공유 시크릿, 그리고 업데이트가 하나씩 깨질 때마다 배운 함정 목록까지요. 이번 작업을 계획하려고 앉았다가 알게 된 건데, 퍼블리시 스크립트는 처음 쓴 날부터 프로덕션 퍼블리시가 끝날 때마다 &ldquo;Sent 0 push notifications&quot;를 출력하고 있었습니다. 이 서버가 한 번도 돌려준 적 없는 필드를 읽고 있었던 거죠. 아무도 눈치채지 못했고, 저부터가 그랬습니다. 직접 만든 걸 운영한다는 게 대개 이렇게 흘러갑니다.</p>
<p>그래서 Codemagic에서 CodePush를 <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">오픈소스</a>로 다시 만든, 직접 호스팅할 수 있는 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>를 써보지 않겠냐고 물었을 때, 조건 하나를 걸고 수락했습니다. 데모가 아니라 실제 앱으로 하겠다는 것. 실제 서명, 실제 릴리스 절차, 손으로 고친 부분이 들어 있는 커밋된 네이티브 프로젝트, 전부 다요. EAS Update에서 옮겨가는 사람 대부분은 새 템플릿이 아니라 그런 앱을 옮기니까요.</p>
<p>그 과정이 어땠는지, 거친 부분을 다듬지 않고 그대로 적었습니다.</p>

<h2 class="relative group">Patch가 뭔가
    <div id="patch가-뭔가" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#patch%ea%b0%80-%eb%ad%94%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Patch는 직접 운영하는 서버와 앱에 넣는 SDK로 이루어져 있습니다. 서버 쪽은 API, 백그라운드 워커, Postgres, S3 호환 오브젝트 스토리지, 웹 대시보드이고, Docker Compose 스택으로 제공됩니다. 클라이언트 쪽은 <code>@codemagic/react-native-patch</code>인데, iOS와 Android용 TurboModule에 Expo config plugin이 딸려 있고, 여기에 JavaScript를 번들링해서 퍼블리시하는 CLI <code>cmpatch</code>가 있습니다.</p>
<p>소스를 읽고 나니 설계 결정 두 가지가 눈에 들어왔습니다.</p>
<p>첫째, 앱은 업데이트가 있는지 API에 묻지 않습니다. 릴리스가 퍼블리시되면 워커가 오브젝트 스토리지에 평범한 JSON 매니페스트를 써두고, 앱은 그걸 가져오기만 합니다. 업데이트 확인은 스토리지나 CDN에 대한 정적 GET 두 번이고, API는 메트릭만 받습니다. 제 예전 엔드포인트는 앱이 실행될 때마다 요청 경로 위에 있었습니다. 이건 그럴 수가 없죠.</p>
<p>둘째, 모든 릴리스는 자기가 대상으로 하는 바이너리 버전을 정확히 명시하고, CLI는 네이티브 프로젝트의 핑거프린트를 계산해서 네이티브 코드가 일치하지 않는 바이너리에는 JavaScript 퍼블리시를 거부합니다. 제 서버에는 버전 문자열 하나와 많은 기대만 있었습니다.</p>

<h2 class="relative group">먼저 노트북에서 돌려보기
    <div id="먼저-노트북에서-돌려보기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a8%bc%ec%a0%80-%eb%85%b8%ed%8a%b8%eb%b6%81%ec%97%90%ec%84%9c-%eb%8f%8c%eb%a0%a4%eb%b3%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>문서는 로컬 평가용 스택부터 시작하라고 안내하는데, 시작점으로 맞습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI 설치는 2초 만에 끝났습니다. 스택은 처음에 3분 14초가 걸렸는데 대부분 서버와 대시보드 이미지를 로컬에서 빌드하는 시간이었고, 비대화형 모드에서는 그동안 아무것도 출력하지 않아서 딱 멈춘 것처럼 보입니다. (지금은 고쳐져서 단계마다 진행 상황을 한 줄씩 출력합니다.) 그러고 나면 정상 컨테이너 네 개가 전부 localhost에 바인딩되어 나타납니다. 대시보드는 8080, API는 3000, Postgres, 그리고 MinIO는 9100. 제 스택이 이미 쓰고 있던 포트는 알아서 피해 갔습니다.</p>
<p>이 모드에서는 로그인이 꺼져 있고, 페이지마다 외부에 노출하지 말라는 경고가 붙습니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="로컬 평가용 로그인 페이지. 이메일이 미리 채워져 있고 인증이 비활성화됐다는 경고가 보인다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>로컬 평가 모드에서는 GitHub 로그인 대신 미리 채워진 이메일이 나옵니다.</figcaption>
</figure>

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

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

<p>비교하자면 제 대시보드는 관리자 화면의 업데이트 목록에 &ldquo;활성&rdquo; 체크박스가 달린 게 전부였습니다. 실제로 몇 대의 폰이 업데이트를 받았는지는 전혀 몰랐고요.</p>
<p>CLI 로그인은 PKCE와 localhost 콜백을 쓰는 제대로 된 브라우저 플로우이고, 승인 페이지는 어느 계정이 요청하는지와 코드가 1분쯤 뒤에 만료된다는 걸 알려줍니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="CLI 로그인을 승인할지 묻는 대시보드"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login은 대시보드에 터미널을 승인해 달라고 요청합니다.</figcaption>
</figure>


<h2 class="relative group">실제 앱에 연결하기
    <div id="실제-앱에-연결하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8b%a4%ec%a0%9c-%ec%95%b1%ec%97%90-%ec%97%b0%ea%b2%b0%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Patch는 플랫폼마다 앱 하나를 원하고, 각 앱에는 Staging과 Production 배포(deployment)가 미리 만들어져 있습니다. 저는 <code>maru-ios</code>는 대시보드에서, <code>maru-android</code>는 <code>cmpatch app create</code>로 만들었습니다. 둘 다 써보고 싶어서요. 앱을 만들고 나서 뜨는 다이얼로그가 마이그레이션에는 이 제품에서 가장 유용한 화면입니다. 배포 키 두 개, SDK에 필요한 URL 두 개, 그리고 배포 키는 비밀이 아니라는 안내까지 한 화면에 있습니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="앱 생성 완료 다이얼로그. Staging과 Production 배포 키, SDK URL이 보인다"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK에 필요한 모든 것이 한 화면에.</figcaption>
</figure>

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

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

<h2 class="relative group">네이티브 쪽, ios/와 android/를 커밋해뒀다면
    <div id="네이티브-쪽-ios와-android를-커밋해뒀다면" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ec%aa%bd-ios%ec%99%80-android%eb%a5%bc-%ec%bb%a4%eb%b0%8b%ed%95%b4%eb%92%80%eb%8b%a4%eb%a9%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>마이그레이션 가이드는 config plugin을 추가한 뒤 <code>npx expo prebuild --clean</code>을 돌리라고 합니다. 별도 체크아웃에서 돌려봤더니 번들된 이미지들, Android 네트워크 보안 설정, iOS 프라이버시 매니페스트, <code>Podfile.lock</code>을 지우고, <code>build.gradle</code>의 테스트 러너용 수정과 <code>Info.plist</code> 수백 줄을 되돌려놨습니다. <code>--clean</code> 없이도 Expo는 두 디렉터리를 어차피 비웠고요.</p>
<p>Patch 잘못은 아닙니다. maru는 managed Expo 앱으로 시작해서 네이티브 커스터마이징이 늘어난 경우라 <code>ios/</code>와 <code>android/</code>가 커밋되어 있고, EAS는 그걸 그대로 빌드합니다. 하지만 이런 앱은 흔한데, 가이드는 경고해주지 않았습니다. 지금은 해줍니다. Expo 마이그레이션 가이드가 두 갈래로 나뉘어서, 네이티브 프로젝트를 생성하는 경우에는 prebuild를, 커밋해둔 경우에는 손으로 적용할 변경 사항을 정확히 안내합니다.</p>
<p>플러그인이 실제로 바꾸는 건 손으로 해도 될 만큼 적었습니다. iOS에서는 <code>AppDelegate.swift</code>에 임포트 하나와 한 줄:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android에서는 <code>MainApplication.kt</code>에 임포트 하나와 인자 하나(React Native 0.82 이상 형태이고, 그 전 버전은 대신 <code>getJSBundleFile()</code>을 오버라이드합니다):</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>여기에 플랫폼별 설정값 세 개와 기존 <code>expo-updates</code> 설정 제거까지. 두 프로젝트 합쳐 13줄 추가, 20줄 삭제였습니다.</p>
<p>플러그인은 그 설정값을 prebuild 시점에 리터럴로 써넣는데, 네이티브 프로젝트를 커밋해둔 상태에서는 모든 빌드에 배포 하나가 하드코딩된다는 뜻입니다. 저는 같은 프로젝트에서 프리뷰 빌드는 Staging으로, 스토어 빌드는 Production으로 가길 원했기 때문에, 값을 빌드 환경에서 가져오게 했습니다. <code>Info.plist</code>에서는 Xcode가 빌드 설정을 확장해줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p>그리고 <code>android/app/build.gradle</code>에서는:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>키는 각 <code>eas.json</code> 빌드 프로파일에 <code>EXPO_PUBLIC_</code> 값들 옆에 둡니다. 값이 비어 있으면 SDK는 조용히 아무것도 하지 않는데, 로컬 빌드라면 괜찮지만 스토어 빌드라면 재앙입니다. 그래서 <code>app.config.js</code>는 이제 preview나 production EAS 빌드에 키가 빠져 있거나 여전히 localhost를 가리키면 예외를 던집니다.</p>

<h2 class="relative group">첫 업데이트
    <div id="첫-업데이트" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b2%ab-%ec%97%85%eb%8d%b0%ec%9d%b4%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>첫 릴리스로는 로그인 없이도 볼 수 있는 온보딩 헤드라인을 바꿔서 퍼블리시했습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>30초 걸렸고 대부분 Metro와 Hermes 시간이었습니다. 서버 워커는 업로드가 끝나고 0.6초 뒤에 매니페스트를 퍼블리시했습니다. 업데이트는 압축된 7.3 MB tarball이었는데, 예전 export가 만들던 11 MB Hermes 번들과 에셋에 비하면 작습니다.</p>
<p>여기서 제대로 해야 할 게 하나 있습니다. JavaScript 번들은 빌드될 때 <code>EXPO_PUBLIC_</code> 값을 인라인하기 때문에, 업데이트는 그것이 올라갈 바이너리와 같은 값으로 빌드해야 합니다. 안 그러면 엉뚱한 백엔드에 말을 거는 JavaScript를 내보내게 되죠. 제 스크립트는 바이너리를 빌드한 것과 같은 <code>eas.json</code> 프로파일에서 값을 불러옵니다. 이제는 Patch의 CI 문서에도 이 내용이 적혀 있고, <code>release-react</code>가 EAS 프로파일을 직접 읽지 않는다는 점도 함께 나옵니다.</p>
<p>그런 다음 앱을 다시 실행했습니다. 기기 로그에는 업데이트 확인 전체가 오브젝트 스토리지로 가는 요청 두 개, <code>meta.json</code>과 <code>2.5.1/manifest.json</code>으로 찍혔습니다. SDK는 실행 3초 뒤에 <code>Downloaded</code> 이벤트를 기록하고 업데이트를 스테이징했고, 앱은 재시작을 제안했습니다.</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="번들 그대로 다시 실행된 앱이 업데이트를 제안하고 릴리스 v1로 재시작하는 모습">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>재실행, sync()가 update-installed를 반환해 뜬 앱 자체 프롬프트, 그리고 OTA로 전달된 릴리스 v1.</figcaption>
</figure>


<h2 class="relative group">버그는 제 것이었다
    <div id="버그는-제-것이었다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b2%84%ea%b7%b8%eb%8a%94-%ec%a0%9c-%ea%b2%83%ec%9d%b4%ec%97%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>그리고 대시보드를 봤습니다. 시뮬레이터 한 대가 릴리스 v1을 설치했는데, 성공 설치 2건에 활성 사용자 2명으로 표시됐습니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging 배포에 기기 한 대가 활성 사용자 2명, 성공 2건으로 표시된 화면"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>시뮬레이터 한 대가 두 번 집계됐습니다.</figcaption>
</figure>

<p>SDK는 상태와 아직 보내지 않은 메트릭 이벤트를 앱 컨테이너 안에 파일로 보관하기 때문에 추적이 쉬웠습니다. 타임스탬프는 같고 id는 다른 <code>Success</code> 이벤트가 두 개 있었습니다. 제 훅은 마운트 시 <code>notifyAppReady()</code>로 실행 중인 번들을 확인한 뒤 곧바로 <code>sync()</code>를 호출했는데, <code>sync()</code>는 내부에서 <code>notifyAppReady()</code>를 또 호출합니다. 두 호출이 겹치면서 각각 릴리스를 대기 중으로 보고, 각각 이벤트를 기록한 겁니다. 서버는 이벤트 id로 중복을 제거하지만 id가 달랐으니 둘 다 집계됐고, 그 중복은 영구적으로 남습니다.</p>
<p>제 쪽 수정은 실행마다 한 번의 공유 확인을 두고 나머지가 전부 그걸 await하게 하는 것이었습니다. SDK 쪽에서도 겹치는 호출을 막을 수 있을 텐데, 그렇게 제안했습니다. 지금은 실제로 막아줍니다. <code>sync()</code> 안의 호출까지 포함해 겹치는 <code>notifyAppReady()</code> 호출들이 확인 하나를 공유하기 때문에, 설치는 한 번만 기록됩니다. SDK 레퍼런스에도 이제 <code>sync()</code>가 준비 확인을 스스로 한다고 적혀 있어서, <code>sync()</code>를 쓰는 앱은 <code>notifyAppReady()</code>를 따로 호출할 필요가 없습니다.</p>
<p>기분 좋았던 건 수정을 배포하는 과정이었습니다. JavaScript만 바뀐 변경이라 릴리스 v2로 내보냈고, v1을 돌리는 기기에는 바이너리 diff를 보낼 수 있어서 v2는 7.3 MB 번들 대신 543 KB 패치로 내려갔습니다. 크기의 약 7%죠. 재시작 후: 다운로드 1, 설치 1, 성공 1, 활성 기기 1.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="v1은 부풀려진 숫자, v2는 정확한 숫자를 보여주는 릴리스 이력"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1의 중복은 남고, v2는 제대로 집계됩니다.</figcaption>
</figure>


<h2 class="relative group">일부러 망가뜨리기
    <div id="일부러-망가뜨리기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%bc%eb%b6%80%eb%9f%ac-%eb%a7%9d%ea%b0%80%eb%9c%a8%eb%a6%ac%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>롤백은 제가 가장 신경 쓴 기능입니다. 예전 구성에는 기기 쪽 롤백이 전혀 없었거든요. 나쁜 업데이트가 나가면 제가 알아채고 체크박스를 해제할 때까지 실행할 때마다 크래시가 났을 겁니다.</p>
<p>그래서 첫 렌더 전에 예외를 던지는 v3를 릴리스했습니다. 즉 <code>notifyAppReady()</code>를 절대 호출할 수 없는 번들이죠. 앱은 내려받고 재시작을 제안한 뒤 홈 화면으로 죽었습니다.</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="잘못된 릴리스로 앱이 크래시한 뒤의 시뮬레이터 홈 화면">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3가 실행 직후 크래시.</figcaption>
</figure>

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

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

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


<h2 class="relative group">Android
    <div id="android" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#android" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Android에서는 두 가지가 놀라웠는데, 둘 다 Patch 탓은 아닙니다. SDK용으로 추가한 R8 keep 규칙이 <code>app.config.js</code>에 있었는데, 이건 prebuild를 통해서만 네이티브 프로젝트에 반영되기 때문에, 첫 릴리스 빌드는 본의 아니게 SDK가 keep 규칙 없이 R8을 견디는지 테스트한 셈이 됐습니다. 견딥니다. R8이 클래스 이름을 전부 바꿨는데도 업데이트 다운로드, 설치, 롤백이 문제없이 됐습니다. 그리고 막 콜드 부팅한 에뮬레이터는 Gradle이 아직 메모리를 쥐고 있어서 너무 느렸고, 첫 테스트는 SDK가 이미 시작한 다운로드를 끝내기 45초 전에 포기해버렸습니다.</p>
<p>릴리스 v1은 성공 1, 활성 기기 1로 적용됐고, 크래시하는 v2는 같은 세 번의 실행 예산을 쓴 뒤 롤백됐습니다.</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="OTA로 전달된 헤드라인을 보여주는 Android 앱"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android: 자체 롤백 후 다시 v1으로.</figcaption>
</figure>


<h2 class="relative group">해피 패스 너머
    <div id="해피-패스-너머" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%b4%ed%94%bc-%ed%8c%a8%ec%8a%a4-%eb%84%88%eb%a8%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>업데이트가 도착하고 롤백되는 게 핵심이지만, 팀은 나머지 기능 속에서 살아가니 하나씩 짚어봤습니다.</p>
<p>단계적 롤아웃은 결정론적인데, 이 점이 마음에 들었습니다. <code>md5(deviceId + &quot;-&quot; + releaseLabel)</code>의 앞 여덟 자리 16진수를 100으로 나눈 나머지가 롤아웃 퍼센트보다 작으면 포함되므로, 같은 폰은 한 릴리스에 대해 계속 포함되거나 계속 제외됩니다. 제 시뮬레이터가 다음 릴리스에서 91번 버킷에 있다는 걸 계산해두고 25%로 퍼블리시했더니, 시뮬레이터는 정확히 아무것도 받지 않았습니다. <code>cmpatch release patch</code>로 95%까지 올리는 데 1초도 안 걸렸고, 다음 실행에서 업데이트됐습니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="대시보드에서 릴리스의 롤아웃을 25%에서 95%로 올리는 모습">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>부분 롤아웃을 다시 퍼블리시하지 않고 25%에서 95%로 올리기.</figcaption>
</figure>

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

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

<p>팀과 CI도 갖춰져 있습니다. 멤버는 팀 전체에 적용되는 네 역할(owner, admin, developer, viewer) 중 하나를 받고, 초대는 그 사람이 처음 로그인할 때까지 대기합니다. 개인 액세스 토큰은 홈 디렉터리가 비어 있는 상태에서도 <code>--token</code>이나 환경 변수로 잘 동작했고, 토큰 목록에는 각 토큰이 마지막으로 쓰인 시점이 나옵니다. 같은 퍼블리시 스크립트를 Linux 러너에서 시크릿에 든 서명 키로 돌리는 수동 실행 CI 워크플로우도 써뒀는데, 호스팅된 서버가 필요해서 아직 돌려보지는 않았습니다.</p>
<p>그 당시라면 건너뛰었을 도구가 하나 있는데, <code>cmpatch debug ios</code>입니다. 시스템 로그를 &ldquo;OTA&quot;로 대소문자 구분 없이 필터링해서 스트리밍했는데, 이러면 &ldquo;Rotation&quot;에도 매칭돼서 20초짜리 앱 실행에 SpringBoard 메시지가 화면 한가득 나오고 앱에서 나온 건 하나도 없었습니다. 이건 고쳐져서, 이제는 SDK 마커가 붙은 줄만 보여줍니다. SDK 자체는 로그를 많이 남기지 않고 대부분 에러와 개발 빌드 경고 정도라, 스트림은 조용한 편이라고 생각하시면 됩니다. 혹시 보이는 <code>getpwuid_r</code> 경고는 Patch가 아니라 <code>xcrun simctl</code>에서 나오는 겁니다.</p>

<h2 class="relative group">그래서 얼마나 어려운가
    <div id="그래서-얼마나-어려운가" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b7%b8%eb%9e%98%ec%84%9c-%ec%96%bc%eb%a7%88%eb%82%98-%ec%96%b4%eb%a0%a4%ec%9a%b4%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Codemagic이 제 의견을 물었으니, 최대한 담백하게 적겠습니다.</p>
<p>써보는 건 쉽습니다. 명령 두 개, 몇 분이면 실제 서버와 데모 데이터가 든 대시보드, 그리고 자기 앱에 연결할 수 있는 CLI가 생깁니다. 첫 한 시간 중 가장 좋은 부분이었습니다.</p>
<p>SDK 통합은 중간 정도의 작업이고, 어려움의 대부분은 Patch가 아니라 자기 프로젝트 안에 있습니다. 네이티브 폴더가 생성된 새 Expo 앱이라면 정말로 플러그인 하나에 <code>expo-updates</code> 호출을 바꾸는 몇 줄이 전부입니다. maru처럼 커밋된 네이티브 프로젝트, 위젯 익스텐션, 웹 빌드, R8, 빌드 프로파일이 있는 앱에서는 작업이 가장자리에 있었습니다. prebuild를 피하려고 네이티브 변경을 손으로 적용해야 했고, Staging과 Production 빌드가 다를 수 있도록 설정을 빌드 시점으로 옮겨야 했고, SDK를 웹에서 떼어놔야 했습니다. 업데이트의 JavaScript가 바이너리와 같은 환경으로 빌드되도록 하는 것도요. 이해하고 나면 어려운 건 없었습니다. 제가 작업할 때는 그 대부분이 문서에 없었는데, 커밋된 네이티브 프로젝트 경로와 번들 시점의 환경 문제는 이제 문서에 들어가 있습니다.</p>
<p>일상적인 사용은 좋습니다. 퍼블리시는 명령 하나에 30초 남짓. 롤아웃, 승격, 비활성화, 롤백은 CLI에서든 대시보드에서든 전부 1초 이내이고, 에러는 뭐가 잘못됐고 뭘 해야 하는지 말해줍니다. 서버의 가드(핑거프린트 검사, 한 번에 카나리 하나, 서명)는 엄격한데, 그 덕분에 제가 잘못된 일을 하는 걸 여러 번 막아줬습니다.</p>
<p>프로덕션에서 운영하는 건 더 큰 결심이 필요합니다. Postgres와 오브젝트 스토리지가 딸린 Docker Compose 스택을 직접 호스팅하고 백업하고 업그레이드해야 하고, 도메인 두 개와 OAuth 앱이 필요합니다. 이미 서버를 운영하는 팀에게는 조용한 오후 한나절이고요. 서버를 원치 않아서 EAS를 쓰는 팀에게는 이게 전환의 진짜 비용이고, SDK 작업보다 큽니다.</p>
<p>실제 시간으로 따지면 12:29에 CLI를 설치했고 13:34에 시뮬레이터에서 첫 업데이트가 적용됐는데, 그중 30분은 Patch와 무관한 이유로 멈춰 있던 Xcode 빌드였습니다. 이 글에 있는 모든 것은 두 플랫폼 모두 오후 중반까지 끝났습니다. 호스팅 서버는 빼고요.</p>
<p>시간을 가장 많이 잡아먹은 것들을 순서대로 꼽으면 이렇습니다. 커밋된 네이티브 프로젝트를 날려버린 <code>expo prebuild --clean</code>과 그 뒤 모든 걸 손으로 연결하는 방법을 알아내는 일. 아무도 돌리지 않는 버전으로 업데이트를 보낼 뻔한 iOS 버전 감지 버그. 두 번 집계된 설치. 이건 제 잘못이지만 <code>sync()</code>가 스스로 <code>notifyAppReady()</code>를 호출하는 이상 누구나 되풀이하기 쉬운 실수입니다. 그리고 테스트 도중에 저를 놀라게 한, 문서에 없는 크래시 3회 예산. 그다음은 맞긴 하지만 계획이 필요한 것들입니다. 무해한 네이티브 인접 편집에도 발동하는 핑거프린트 가드, 배포당 카나리 하나, 그리고 겹칠 수 없는 라이프사이클 작업(스크립트가 여러 일을 연달아 하는 순간 문제가 됩니다). 나머지는 사소했습니다. 아무 말 없는 첫 실행 설정, 시끄러운 debug 명령, 코드와 맞지 않던 문서 예제 몇 개.</p>
<p>이 모든 걸 상세 내용과 함께 Codemagic에 정리해서 보냈고, 9월 23일 기준으로 대부분이 고쳐졌습니다. 버전 감지, 겹치는 확인, prebuild 경고, 크래시 예산, 아무 말 없던 설정, debug 명령, <code>InstallMode</code> 예제, 그리고 릴리스 작업이나 카나리가 가로막을 때 나오는 에러까지요. 아직 남아 있는 건 더 작은 것들입니다. 다른 곳은 전부 <code>:3000</code>이라고 하는데 대시보드만 API를 <code>:8080</code>으로 보여주는 것, SDK가 웹과 Jest에서 임포트 시점에 예외를 던지는 것, 그리고 문서의 빈틈 몇 군데.</p>

<h2 class="relative group">프로덕션으로 가기
    <div id="프로덕션으로-가기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98%ec%9c%bc%eb%a1%9c-%ea%b0%80%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>위의 모든 것은 평가용 스택에서 돌렸습니다. 프로덕션은 같은 앱과 같은 스크립트를 실제 서버에 연결하는 것이고, 이게 maru의 계획입니다.</p>
<!-- 편집자 노트: 이 섹션은 절차입니다. 실제 전환 후에 실제로 일어난 일(호스트, 소요 시간, 깨진 것)로 교체할 것. -->
<p>서버를 운영하는 공식 지원 방식은 Docker Compose 설치입니다. 80, 443 포트가 열린 Linux 머신, 도메인 두 개(API와 대시보드용 하나, 다운로드용 하나), 로그인용 GitHub OAuth 앱(이게 없으면 서버가 시작하지 않습니다)이 필요합니다. 나머지는 TLS까지 포함해 설치 스크립트가 처리합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>데이터베이스와 스토리지 시크릿이 담긴 <code>.env.selfhost</code>를 써주는데, 이건 비밀번호 관리자에 넣어둬야 합니다.</p>
<p>다만 번들된 데이터베이스와 스토리지를 꼭 써야 하는 건 아니고, 그래서 제 스택 같은 구성에도 맞습니다. maru의 API는 이미 Fly.io에서 돌고 파일은 Tigris에 있는데, Patch는 둘 다에서 동작합니다. Compose 파일 아래를 보면 서버는 <code>MODE=all</code>(API와 릴리스 워커 함께)로 도는 컨테이너 하나이고, Postgres 데이터베이스, S3 호환 스토리지, 파일용 공개 HTTPS URL이 필요합니다. 즉 저장소의 Dockerfile로 빌드한 Fly 앱(아직 공개된 이미지가 없어서 <code>fly deploy</code>가 빌드합니다), Fly Postgres 데이터베이스, Tigris 버킷이면 됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>주의할 점 둘. Compose 밖에서 운영하는 건 저장소에 참고 자료로 문서화되어 있을 뿐 이번 첫 오픈소스 릴리스에서 지원되는 경로는 아니라서, Fly에서는 업그레이드와 백업을 알아서 챙겨야 합니다. 저도 아직 그렇게 배포해보지는 않았습니다. 이건 보고가 아니라 계획입니다. 그리고 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly의 0으로 스케일 다운</a> 글을 읽으셨다면, 머신은 최소 한 대는 켜두세요. 매니페스트가 스토리지에서 바로 오기 때문에 폰이 업데이트 확인에서 API를 기다리는 일은 없지만, 그렇지 않으면 모든 메트릭 업로드와 모든 릴리스 작업이 콜드 부팅으로 시작하게 됩니다.</p>
<p>Tigris가 제가 가장 기대하는 부분입니다. maru의 파일이 이미 거기 있고, 앱이 실행될 때마다 때리는 URL 앞에 CDN을 세워주니까요. 이렇게 할 거라면 매니페스트 캐싱에 주의하세요. 기본 base-URL 전달 어댑터에서 Patch는 매니페스트를 <code>no-cache</code>로 서빙하는데, CDN을 퍼지할 수 없기 때문입니다. 매니페스트가 자체 TTL로 캐시되면 방금 롤백한 릴리스를 계속 내보내게 됩니다. 이 내용은 이제 설정 레퍼런스의 <code>MANIFEST_CACHE_CONTROL</code> 항목에 문서화되어 있고, 문서 사이트에서도 S3 호환 스토리지를 다룹니다.</p>
<p>호스팅된 서버에서는 앱을 다시 만들고, 새 키를 <code>eas.json</code>의 <code>preview</code>와 <code>production</code> 프로파일에 넣고(Staging 키와 Production 키), 퍼블리시용 토큰을 만듭니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>코드 서명을 쓸 거라면 지금 정하세요. 프로덕션 키 쌍을 생성하고, 개인 키는 CI 시크릿으로 보관하고, 공개 키는 빌드 프로파일에 넣고, 두 앱 모두 &ldquo;require code signing&quot;을 켭니다. 첫 Patch 바이너리에는 새 버전 번호도 필요합니다. 스토어에 이미 올라간 바이너리는 <code>expo-updates</code>를 돌리고 있으니까요. 먼저 preview 프로파일로 TestFlight과 Play 내부 테스트용으로 빌드하고, 누군가에게 건네기 전에 키가 실제로 빌드된 <code>Info.plist</code>와 Android 리소스에 들어갔는지 확인하세요. 제 로컬 테스트가 검증하지 못한 유일한 고리가 이 부분입니다. 환경을 손으로 export했으니까요. 그다음 Staging에 퍼블리시하고, 테스터마다 다운로드 1, 성공 1을 확인하고, 그제야 스토어용으로 빌드합니다.</p>
<p>프로덕션 릴리스는 작게 시작해서 키워갑니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>그동안 기존 서버는 계속 켜둡니다. 사람들이 아직 업데이트하지 않은 2.5.1 사본은 전부 여전히 그 서버를 확인하고, 그들을 위한 핫픽스도 여전히 예전 스크립트로 나갑니다. 로그에서 그 요청이 멈춘 게 보이면 기존 서버와 그 퍼블리시 스크립트, 시크릿까지 전부 지울 수 있습니다.</p>

<h2 class="relative group">첫 네이티브 업그레이드
    <div id="첫-네이티브-업그레이드" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b2%ab-%eb%84%a4%ec%9d%b4%ed%8b%b0%eb%b8%8c-%ec%97%85%ea%b7%b8%eb%a0%88%ec%9d%b4%eb%93%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><em>2026년 10월 16일 추가.</em></p>
<p>Patch는 10월 초부터 maru의 프로덕션 업데이트를 맡고 있습니다. 그 뒤 첫 네이티브 릴리스는 Expo SDK 58로의 이전이었습니다. React Native 0.88, 그리고 Android에서는 Gradle 플러그인의 새 메이저 버전입니다. 네이티브 변경은 OTA로 보낼 수 없으니 새 바이너리 2.6.0이 되고, 제 퍼블리시 스크립트는 그 바이너리가 나오기 전까지 2.6.0용 업데이트를 보내지 않습니다. 두 플랫폼 모두 첫 시도에 깔끔하게 빌드됐습니다.</p>
<p>위의 프로덕션 섹션에서, 빌드를 누군가에게 건네기 전에 키가 실제로 빌드된 <code>Info.plist</code>와 Android 리소스에 들어갔는지 확인하라고 했습니다. 이번에는 정말 확인했고, 이 섹션이 있는 이유가 바로 그 확인입니다. iOS 빌드에는 Patch 설정 네 개가 다 있었습니다. Android 빌드에는 하나도 없었습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>이 명령은 2.5.11 빌드에서는 키 네 개를 출력했고 2.6.0에서는 아무것도 출력하지 않았습니다. 환경 변수는 문제가 아니었습니다. Gradle의 <code>generateReleaseResValues</code>는 네 개를 다 씁니다. 지우는 건 그 뒤에 도는 릴리스 리소스 축소기(resource shrinker)인데, Android Gradle Plugin 9에서 훨씬 공격적으로 바뀌었습니다. 같은 앱의 리소스가 9,240개에서 7,116개로 줄었습니다. SDK는 설정을 <code>getIdentifier</code>로 이름을 써서 읽는데, 축소기는 이걸 볼 수 없으니 네 문자열을 쓰이지 않는 것으로 보고 지웠습니다. 없는 문자열은 빈 값으로 읽히고, 빈 값은 &ldquo;업데이트 없음&quot;이라는 뜻입니다. 실패하는 건 아무것도 없었을 겁니다. Android 2.6.0은 설치되고, 평소처럼 돌아가고, 업데이트는 한 번도 받지 못했을 것이고, 유일한 신호는 대시보드에 Android 2.6.0 기기가 하나도 없다는 것뿐이었을 겁니다.</p>
<p>수정은 축소기에게 네 문자열을 남기라고 알려주는 리소스 파일 하나입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p>이름을 <code>keep.xml</code>로 짓지는 마세요. React Native의 번들 태스크가 JavaScript 에셋용으로 이미 <code>raw/keep.xml</code>을 생성하고(이미지와 폰트가 살아남은 게 그 덕분입니다), 두 파일이 충돌합니다. 라이브러리의 keep 파일은 앱에 자동으로 병합되니 이 규칙이 있어야 할 더 나은 자리는 SDK 자체이고, 배포 키 없이 릴리스 빌드가 시작될 때 경고 하나만 나와도 조용히 지나가던 상황이 눈에 보이게 될 겁니다. 앞에서 나온 iOS 버전 감지 버그와 같은 종류의 실패입니다. 오류는 나지 않고, 업데이트가 그냥 도착하지 않습니다.</p>
<p>같은 확인에서 Patch와는 상관없는 두 번째 문제도 드러났는데, 이건 다른 이유로 알아둘 만합니다. Android 릴리스 빌드가 실행하자마자 크래시했는데, 그것도 두 번, Expo 자체 모듈 안의 서로 다른 두 가지 이유 때문이었습니다. 먼저 Expo의 위젯 모듈이 네이티브 클래스를 등록하지 못해서, JavaScript가 돌기도 전에 앱이 죽었습니다. 그걸 고치자 앱은 JavaScript를 시작하는 데까지는 갔다가 멈췄는데, Expo의 새 모듈 시스템으로 옮겨간 모듈 세 개가 등록되지 않았기 때문입니다. 둘 다 Patch의 크래시 롤백으로는 도움이 안 됩니다. 롤백할 업데이트가 없으니까요. 크래시 롤백은 나쁜 업데이트로부터 지켜주는 것이지, 나쁜 바이너리로부터 지켜주는 게 아닙니다. Patch가 있든 없든, 네이티브 업그레이드는 스토어에 올리기 전에 실제 기기나 에뮬레이터에 설치해서 열어봐야 합니다.</p>
<p>같은 테스트에서 작은 것이 하나 더 나왔습니다. <code>sync()</code>는 실패하면 <code>&quot;error&quot;</code>를 반환하고, 앱이 개발 모드로 돌고 있지 않으면 이유는 알려주지 않습니다. 그래서 릴리스 빌드는 뭔가 잘못됐다는 것밖에 로그로 남길 수 없습니다. 이제 제 훅은 sync가 실패하면 <code>checkForUpdate()</code>를 한 번 호출해서 거기서 던지는 오류를 로그로 남기는데, 덕분에 문제가 서버가 아니라 로컬 시뮬레이터 빌드의 Patch URL이 비어 있던 것(<code>unsupported URL</code>)이라는 걸 알게 됐습니다. 마지막 오류를 릴리스 빌드에서도 읽을 수 있다면 도움이 될 겁니다.</p>
<p>이제 제 네이티브 릴리스 체크리스트에는 전에 없던 세 줄이 있습니다. 빌드된 <code>.ipa</code>와 <code>.aab</code>에서 Patch 설정을 덤프해볼 것, 배포 키를 빌드 프로파일과 비교할 것, 그리고 제출하기 전에 debug 빌드가 아니라 릴리스 빌드 자체를 실행해볼 것.</p>

<h2 class="relative group">Expo 청구서를 받고 있는 분에게
    <div id="expo-청구서를-받고-있는-분에게" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expo-%ec%b2%ad%ea%b5%ac%ec%84%9c%eb%a5%bc-%eb%b0%9b%ea%b3%a0-%ec%9e%88%eb%8a%94-%eb%b6%84%ec%97%90%ea%b2%8c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>서버를 만드는 게 너무 큰 일처럼 보여서 EAS Update에 돈을 내고 있다면, Patch는 실질적인 답입니다. 서버를 운영하긴 하지만, 그건 Compose 스택과 설치 스크립트이지 여러분이 유지보수할 코드가 아닙니다. 제가 만든 것과 비교하면 단계적 롤아웃, 릴리스별 설치·실패 숫자, 바이너리 패치, 핑거프린트 가드, 코드 서명, 기기에서의 크래시 롤백, 롤백 버튼이 따라옵니다. 저는 그중 하나도 만들지 않았습니다.</p>
<p>비용은 이렇습니다. SDK가 앱에 들어 있어야 하니 누구든 Patch 업데이트를 받기 전에 새 바이너리가 필요하고, 계속 업그레이드하고 백업해야 할 서버가 생기고, 릴리스가 충분히 나쁘면 기본값 기준으로 사용자마다 크래시 세 번을 겪고, 네이티브 프로젝트가 커밋되어 있다면 손이 좀 갑니다. 아직 어리기도 합니다. 이번은 0.3.0 버전이었고, 버전 감지 버그, 시끄러운 debug 명령, 문서의 빈틈 몇 군데를 만났고, SDK가 막아줄 수 있었을 버그를 제가 직접 하나 만들었습니다. 버전 감지와 debug 명령은 그 뒤 고쳐졌고, 문서의 빈틈도 대부분 메워졌고, SDK는 이제 제가 한 실수를 막아줍니다. 그중 어떤 것도 마이그레이션을 멈추지는 못했고, 전부 CLI에서, 대시보드에서, 혹은 기기 안 SDK 파일에서 눈에 보였습니다. 제 예전 구성에 대해서는 할 수 없는 말이죠.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web SEO와 네이버 크롤러: Cloudflare가 Googlebot에게 빈 페이지를 내줬다</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>React Native Web으로 만든 SPA를 구글과 네이버 크롤러에 보이게 만든 다이내믹 렌더링, 그리고 Googlebot에게 빈 SPA 셸을 내준 Cloudflare 캐시 규칙 이야기입니다.</description>
      <content:encoded><![CDATA[<p>React나 React Native Web으로 만든 싱글 페이지 앱은 그대로 두면 네이버 크롤러(Yeti)도 Bing도 자바스크립트를 실행하지 않고, 구글도 실행할 때가 있을 뿐입니다. 해결책은 다이내믹 렌더링입니다. User-Agent로 크롤러를 감지해서, 앱이 그렸을 것과 같은 내용의 서버 렌더링 HTML을 내주면 됩니다. Cloudflare에서 피해야 할 실수는 그 URL을 모든 방문자용으로 캐시하는 것입니다. Cloudflare는 HTML의 <code>Vary</code>를 무시하기 때문에 먼저 도착한 본문이 이후 모든 방문자에게 나가고, Googlebot이 빈 셸을 받을 수도 있습니다. 크롤러 요청만 캐시하고, 규칙을 좁힌 뒤에는 퍼지하세요.</p>
<p>제가 만드는 마켓플레이스 앱은 React Native Web으로 만든 싱글 페이지 앱입니다. 모든 리스팅 페이지는 자바스크립트가 실행되기 전까지 스크립트 태그 하나와 빈 div일 뿐입니다. 사람에게는 아무 문제가 없지만 검색에는 치명적입니다. 구글은 그 JS를 실행할 때도 있지만, Bing은 하지 않고, 리스팅이 거의 다 한국어인 사이트에 가장 중요한 네이버 크롤러는 확실히 하지 않습니다.</p>
<p>이 글은 그 페이지들을 보이게 만든 과정과, 그다음에 제가 예상하지 못한 방식으로 그걸 되돌려버린 캐싱 규칙에 대한 이야기입니다.</p>

<h2 class="relative group">클로킹이 아닌 다이내믹 렌더링
    <div id="클로킹이-아닌-다이내믹-렌더링" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%a1%9c%ed%82%b9%ec%9d%b4-%ec%95%84%eb%8b%8c-%eb%8b%a4%ec%9d%b4%eb%82%b4%eb%af%b9-%eb%a0%8c%eb%8d%94%eb%a7%81" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>해결책은 오래됐고 구글도 인정하는 방식입니다. 크롤러를 감지해서, SPA가 그렸을 바로 그 콘텐츠를 담은 서버 렌더링 HTML을 내주는 것. 다른 콘텐츠가 아니라, 같은 콘텐츠를 더 일찍.</p>
<p>그래서 <code>/listings/view?id=...</code> 같은 리스팅 URL은 분기합니다. 브라우저는 SPA 셸을 받습니다. 크롤러는 진짜 문서를 받습니다. 제목, 리스팅의 핵심 정보, alt 텍스트가 붙은 사진별 이미지, 본문 발췌, 그리고 이 페이지가 고아가 되지 않도록 관련 리스팅 몇 개로 가는 링크까지.</p>
<p>마지막 항목이 생각보다 훨씬 중요했습니다. 내부 링크가 생기기 전에는 리스팅 페이지 약 1,000개를 가리키는 것이 아무것도 없었고, 서치 콘솔에서 크롤이 하루 7~11회로 가라앉는 게 보였습니다. 페이지를 찾지 못하는 크롤러는 그 페이지가 얼마나 좋은지 신경 쓰지 않습니다.</p>

<h2 class="relative group">함정: 벤더 이름은 그 회사 봇의 이름이 아니다
    <div id="함정-벤더-이름은-그-회사-봇의-이름이-아니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%a8%ec%a0%95-%eb%b2%a4%eb%8d%94-%ec%9d%b4%eb%a6%84%ec%9d%80-%ea%b7%b8-%ed%9a%8c%ec%82%ac-%eb%b4%87%ec%9d%98-%ec%9d%b4%eb%a6%84%ec%9d%b4-%ec%95%84%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 감지는 user-agent 매칭이고, 바로 여기서 발목을 잡혔습니다.</p>
<p>한국 플랫폼들은 회사 이름과 전혀 닮지 않은 이름으로 크롤링합니다. 네이버의 크롤러는 <strong>Yeti</strong>. 다음은 <strong>Daumoa</strong>. 카카오의 링크 스크래퍼는 <strong>kakaotalk-scrap</strong>. 셋 중 어느 것도 벤더 이름이 아닙니다.</p>
<p>한편 같은 벤더들이 인앱 브라우저를 탑재한 앱을 내놓고, 그 브라우저는 진짜 브라우저 UA 뒤에 앱 이름을 붙여 보냅니다. 카카오톡 인앱 브라우저는 <code>KAKAOTALK/10.5.1</code>이라고 말합니다. 카카오의 스크래퍼는 <code>kakaotalk-scrap/1.0</code>이라고 말하고요.</p>
<p><code>kakao</code>로 매칭하면 둘 다 걸립니다. 제가 정확히 그렇게 했고, 그래서 링크를 연 카카오톡·네이버앱·다음앱 사용자 전원이 크롤러 페이지를 받았습니다. 자바스크립트도, 내비게이션도, 로그인도 없는 페이지를요. 문서로서는 훌륭하고, 사람에게는 완전히 쓸모없는.</p>
<p>해결책은 순서 규칙이고, 규칙으로 적어둘 만합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 인앱 브라우저는 평범한 브라우저 UA 뒤에 앱 이름을 붙인다.</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 벤더가 아니라 봇 이름으로 매칭한다. 네이버는 Yeti, 다음은 Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 카카오의 링크 미리보기 스크래퍼는 kakaotalk-scrap.</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># 인앱 브라우저는 절대 크롤러가 아니다. 그래서 이 검사가 먼저 온다.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>인앱 브라우저를 먼저 검사하고, 그쪽이 이깁니다. 그리고 모든 크롤러 토큰은 벤더가 아니라 <strong>봇</strong>을 지목해야 합니다.</p>

<h2 class="relative group">그다음 앞단에 Cloudflare를 놓았다
    <div id="그다음-앞단에-cloudflare를-놓았다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b7%b8%eb%8b%a4%ec%9d%8c-%ec%95%9e%eb%8b%a8%ec%97%90-cloudflare%eb%a5%bc-%eb%86%93%ec%95%98%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>오리진은 시드니에 있는 공유 CPU 머신 한 대이고, 초당 20~30 요청을 감당합니다. Googlebot이 걸어야 할 URL은 약 995개입니다. 그 전부가 시드니까지 도달하고 있었습니다. 모든 URL에서 <code>cf-cache-status</code>가 <code>DYNAMIC</code>이었기 때문입니다. <strong>Cloudflare는 규칙이 시키지 않으면 HTML을 캐싱하지 않습니다.</strong> 존의 약 16%만 캐싱되고 있었고, 정작 중요한 부분은 하나도 포함되지 않았습니다.</p>
<p>그래서 크롤러 페이지에 캐시 규칙을 걸었습니다. 간단한 일이죠. 리스팅 URL의 본문이 하나가 아니라는 점만 빼면. 본문은 셋입니다.</p>
<ul>
<li>HTML을 요청하는 크롤러는 서버 렌더링 페이지를 받습니다.</li>
<li>사람은 SPA 셸을 받습니다.</li>
<li><code>Accept: text/markdown</code>을 보내는 에이전트는 데이터베이스에서 만든 마크다운을 받습니다. 빈 HTML 셸을 변환해봐야 빈 문서가 나올 뿐이니까요.</li>
</ul>
<p>오리진은 <code>Vary: Accept</code>를 보내고, user-agent에 대한 <code>Vary</code>는 어차피 가망이 없습니다. 그리고 제가 몰랐던 게 여기 있습니다.</p>
<blockquote><p><strong>Cloudflare는 HTML에서 <code>Vary</code>를 무시합니다.</strong> 세 본문이 하나의 캐시 키를 공유합니다.</p>
</blockquote><p>먼저 도착한 본문이 그 URL을 차지하고, 해당 엣지 위치의 이후 모든 방문자에게 오리진의 <code>s-maxage</code>가 허용하는 한 시간 동안, 거기에 <code>stale-while-revalidate</code> 하루를 더해 그대로 나갑니다.</p>
<p>프로덕션에서 양방향으로 다 목격했습니다.</p>
<ul>
<li>네이버앱 사용자가 크롤러 페이지로 리스팅 URL을 워밍했고, 그다음 평범한 아이폰 사파리가 HIT으로 그 페이지를 받았습니다. 자바스크립트 없는 문서를 들여다보는 사람이 된 거죠.</li>
<li>사람이 SPA 셸로 URL을 워밍했고, Googlebot이 빈 셸을 받았습니다. SEO 작업 전체가 막으려던 바로 그 실패가, 이제 더 빠르게 엣지에서 제공된 겁니다.</li>
</ul>

<h2 class="relative group">좁혀서 고치는 규칙
    <div id="좁혀서-고치는-규칙" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a2%81%ed%98%80%ec%84%9c-%ea%b3%a0%ec%b9%98%eb%8a%94-%ea%b7%9c%ec%b9%99" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare가 HTML에서 <code>Vary</code>를 존중하게 만들 수는 없습니다. 대신 할 수 있는 건, 세 본문 중 <strong>하나만</strong> 캐시에 들어갈 자격을 갖게 만드는 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>크롤러 UA만 매칭하고, 마크다운 요청은 제외합니다. 이제 사람의 요청은 어떤 규칙에도 매칭되지 않으므로 예전처럼 <code>DYNAMIC</code>을 읽고 오리진으로 갑니다. 캐시를 읽고 쓰는 건 크롤러 요청뿐이고, 거기 들어갈 수 있는 본문은 크롤러용 하나뿐입니다.</p>
<p>엣지 TTL은 <code>respect_origin</code>입니다. 수명은 대시보드에 박히는 대신 페이지를 렌더링하는 코드가 계속 소유합니다.</p>
<p>놓치기 쉬운 운영 디테일 하나. <strong>규칙을 좁힌다고 이미 캐시에 있는 것이 사라지지는 않습니다.</strong> 퍼지해야 합니다. 안 그러면 방금 생성을 멈춘 그 오염된 항목을 계속 내주게 됩니다.</p>

<h2 class="relative group">두 목록, 그리고 그 오류가 대칭이 아닌 이유
    <div id="두-목록-그리고-그-오류가-대칭이-아닌-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%91%90-%eb%aa%a9%eb%a1%9d-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%ea%b7%b8-%ec%98%a4%eb%a5%98%ea%b0%80-%eb%8c%80%ec%b9%ad%ec%9d%b4-%ec%95%84%eb%8b%8c-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 규칙에는 크롤러 UA 목록이 필요하고, Django에는 이미 하나 있습니다. 같은 지식이 두 시스템에 두 벌 있는 건 대개 냄새나는 일이지만, 여기서 흥미로운 건 둘이 어긋났을 때 벌어지는 일입니다. 대칭이 아니거든요.</p>
<ul>
<li><strong>Cloudflare의 봇 목록은 Django 쪽의 부분집합으로 유지되어야 합니다.</strong> Cloudflare는 봇이라 여기지만 Django는 아니라고 보는 토큰이 있으면, 크롤러 전용 키 아래에 SPA 셸이 캐싱되고 그게 진짜 크롤러에게 나갑니다.</li>
<li><strong>인앱 브라우저 제외 목록은 Django 쪽의 상위집합이어야 합니다.</strong> 너무 많이 제외해봐야 캐시 히트를 잃을 뿐입니다.</li>
</ul>
<p>어느 쪽이든 안전한 방향으로 틀리면 캐싱을 조금 잃습니다. 첫 번째를 반대 방향으로 틀리면 다시 Googlebot에게 빈 페이지를 내주게 됩니다. 참고로 이 비대칭 때문에 목록이 정규식 대신 <code>contains</code>를 씁니다. <code>matches</code>는 Business 플랜이 필요하고, 알려진 안전한 방향으로 대충이어도 되는 목록에는 <code>contains</code>로 충분합니다.</p>

<h2 class="relative group">페이지 대신 빌드를 실패시키는 불변식
    <div id="페이지-대신-빌드를-실패시키는-불변식" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8e%98%ec%9d%b4%ec%a7%80-%eb%8c%80%ec%8b%a0-%eb%b9%8c%eb%93%9c%eb%a5%bc-%ec%8b%a4%ed%8c%a8%ec%8b%9c%ed%82%a4%eb%8a%94-%eb%b6%88%eb%b3%80%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>크롤러 페이지에는 7일간 유효한 presigned 이미지 URL이 들어갑니다. 페이지 자체는 엣지에서 한 시간, 거기에 stale-while-revalidate 하루가 붙습니다.</p>
<p>이 두 숫자는 서로 관련이 있고, 캐시 수명이 이미지 수명을 넘어서는 순간 엣지는 모든 사진이 403인 페이지를 내주기 시작합니다. 이걸 알려주는 알림은 없습니다. HTML 자체는 멀쩡하니까요.</p>
<p>그래서 그 숫자들을 정의하는 코드는 관계가 깨지면 임포트 자체를 거부합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># presigned 이미지 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 엣지 캐시가 이미지 URL보다 오래 살 수 있으면 임포트 시점에 실패시킨다.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>서로 다른 파일에 있는 두 상수 사이의 제약은 코드 리뷰에서는 보이지 않고, 프로세스 시작 시점에는 명백합니다. 프로세스가 시작하는 곳에 두세요.</p>

<h2 class="relative group">Cloudflare가 한 일 중 되돌려야 했던 두 가지
    <div id="cloudflare가-한-일-중-되돌려야-했던-두-가지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare%ea%b0%80-%ed%95%9c-%ec%9d%bc-%ec%a4%91-%eb%90%98%eb%8f%8c%eb%a0%a4%ec%95%bc-%ed%96%88%eb%8d%98-%eb%91%90-%ea%b0%80%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code>이 번들을 캐시 밖으로 밀어냈습니다.</strong> <code>django-cors-headers</code>는 자기가 건드리는 모든 응답에 <code>Vary: Origin</code>을 찍고, Cloudflare는 <code>Accept-Encoding</code> 외의 헤더로 <code>Vary</code>가 걸린 응답은 캐싱하지 않습니다. 그래서 크리티컬 패스에서 가장 큰 1.6MB 자바스크립트 번들이 통째로 엣지에서 조용히 제외되고 있었습니다. 해결책은 미들웨어를 목록 맨 앞에 두어 나가는 길에 마지막으로 실행되게 하고, 정적 자산 경로의 <code>Vary</code>를 다시 쓰는 것입니다.</p>
<p><strong>엣지 압축이 오리진 압축보다 나빴습니다.</strong> Cloudflare가 즉석에서 Brotli를 하게 두면 2,041,742바이트가 나왔고, 오리진의 gzip은 1,658,115바이트였습니다. 생각하지 않아도 되는 대가로 23% 더 큰 셈입니다. 온더플라이 압축은 압축률이 아니라 CPU를 최적화합니다. 오리진에서 미리 압축한 자산이 이깁니다.</p>

<h2 class="relative group">크롤링을 기다리지 않고 검색엔진에 알리기
    <div id="크롤링을-기다리지-않고-검색엔진에-알리기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%eb%a1%a4%eb%a7%81%ec%9d%84-%ea%b8%b0%eb%8b%a4%eb%a6%ac%ec%a7%80-%ec%95%8a%ea%b3%a0-%ea%b2%80%ec%83%89%ec%97%94%ec%a7%84%ec%97%90-%ec%95%8c%eb%a6%ac%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>프로토콜 두 개, 그리고 둘을 합쳐도 커버리지는 실망스럽습니다.</p>
<p><strong>IndexNow</strong>는 핑 하나를 받고 Bing, Yandex, Seznam을 커버합니다. 구글과 네이버는 참여하지 않습니다. 리스팅이 저장될 때마다 백그라운드에서 짧은 타임아웃으로 핑을 보내고, 판매완료·마감된 리스팅은 건너뜁니다. 네트워크 실패는 경고를 남길 뿐 그걸 촉발한 저장을 절대 실패시키지 않습니다. 부가적인 베스트 에포트 작업에 허용할 수 있는 동작은 이것뿐이죠.</p>
<p><strong>구글의 Indexing API</strong>는 정확히 두 가지 스키마 타입만 받고, 그중 하나가 제게 있는 <code>JobPosting</code>입니다. 살아 있는 공고는 <code>URL_UPDATED</code>, 마감된 공고는 <code>URL_DELETED</code>를 보냅니다. 그 삭제 신호가 정직한 이유는 마감된 리스팅이 <code>noindex</code>도 함께 내보내기 때문이고, 코드에 그렇게 적어두었습니다. 나중에 noindex를 걷어내는 사람이 무엇을 같이 망가뜨리는지 알 수 있도록.</p>
<p>거기서 시간을 두 번 썼습니다. 할당량은 하루 200 URL인데, 리스팅을 일괄 임포트하면 한 번에 수백 번이 호출됩니다. 그래서 임포트된 리스팅은 건너뜁니다. 안 그러면 임포트 한 번이 하루치 예산을 전부 씁니다. 그리고 엔드포인트는 콜론이 들어간 <code>urlNotifications:publish</code>입니다. 슬래시를 쓰면 구글은 평범한 HTML 404를 반환하는데, 그건 API가 비활성화된 것과 똑같아 보입니다.</p>

<h2 class="relative group">구조화 데이터가 가르쳐준 것
    <div id="구조화-데이터가-가르쳐준-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b5%ac%ec%a1%b0%ed%99%94-%eb%8d%b0%ec%9d%b4%ed%84%b0%ea%b0%80-%ea%b0%80%eb%a5%b4%ec%b3%90%ec%a4%80-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>규칙 셋, 각각 서치 콘솔에게 혼나면서 배웠습니다.</p>
<ol>
<li><strong>파싱할 수 없는 건 빼세요.</strong> 급여는 자유 텍스트로 들어옵니다. 예전에는 원문 문자열을 <code>baseSalary.value</code>로 내보냈는데, 그건 유효하지 않고 모든 공고에 단위 누락 플래그가 붙었습니다. 지금은 파싱 안 되는 급여는 그냥 없습니다.</li>
<li><strong>스키마를 만족시키려고 필드를 지어내지 마세요.</strong> 구인 공고에 상세 주소가 있는 경우는 드뭅니다. <code>PostalAddress</code>에 그럴듯한 걸 넣고 싶어지죠. 구인 공고에 지어낸 주소는 불완전한 주소보다 나쁩니다. 그래서 지역과 국가만 들어갑니다.</li>
<li><strong>만료일은 선택이 아닙니다.</strong> 구글은 <code>validThrough</code>가 없는 <code>JobPosting</code>을 버립니다. 그래서 하나를 유도하되, 오래된 공고가 어제 만료됐다고 주장하지 않도록 하한을 둡니다.</li>
</ol>
<p>그리고 사이트맵에 대한 네 번째. <strong>전부에 <code>changefreq: daily</code>를 주장하지 마세요.</strong> 리스팅 천 개를 매일 다시 크롤링해달라는 요청은 과부하 상태의 호스트로 읽히고, 구글은 사이트 전체의 크롤 속도를 낮추는 것으로 응답합니다. 실제로 얼마나 최근에 바뀌었는지에서 유도하세요.</p>
<p>사이트맵에는 품질 하한선도 있습니다. 제목과 본문을 합쳐 40자. 서치 콘솔이 약 1,000개 리스팅을 &ldquo;크롤링됨 – 현재 색인이 생성되지 않음&quot;으로 보고했었고, 어떤 중고 페이지의 고유 콘텐츠 전부가 한 단어짜리 제목과 전화번호 하나였습니다. 40자는 일부러 낮게 잡았습니다. 484개 리스팅 중 471개가 한국어이고 한국어는 글자당 담는 정보가 많으니까요. 영어에 맞춘 기준이었다면 마켓플레이스의 절반이 날아갔을 겁니다. 얇은 리스팅도 인덱스 페이지를 통해 크롤 가능한 상태로 남고, 누군가 수정하면 자동으로 사이트맵에 다시 들어옵니다.</p>

<h2 class="relative group">여기서 가져갈 것
    <div id="여기서-가져갈-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%97%ac%ea%b8%b0%ec%84%9c-%ea%b0%80%ec%a0%b8%ea%b0%88-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li><strong>한 URL에서 서로 다른 본문을 내주는 일은, 무엇보다 먼저 캐시 키 문제입니다.</strong> CDN이 무엇을 키로 삼는지 알아내고, 바로 그 콘텐츠 타입에서 증명하기 전까지는 <code>Vary</code>를 무시한다고 가정하세요.</li>
<li><strong>두 시스템이 하나의 목록에 합의해야 한다면, 어느 방향의 오류가 안전한지 알아내서</strong> 양쪽 사본 옆에 적어두세요. &ldquo;이건 어긋날 수 있고, 허용되는 어긋남은 이 방향뿐이다&quot;가 어긋나지 않는 척하는 것보다 낫습니다.</li>
<li><strong>봇은 벤더 이름이 아니라 봇 이름으로 매칭하세요.</strong> 특히 영어권 밖에서는, 그 벤더가 사용자 손에 들린 브라우저도 만들고 있습니다.</li>
<li><strong>멀리 떨어진 상수 사이의 불변식은 시끄럽게 실패하는 곳에 두세요.</strong> 임포트 시점은 공짜이고 아무도 건너뛸 수 없습니다.</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io에서 Django 오토스케일링: 부하 테스트로 작은 머신 한 대의 한계 찾기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>Django 앱 두 개를 Fly.io의 shared-cpu-1x 머신에서 돌립니다. 머신 한 대를 빠르게 만들고, 부하 테스트로 무너지는 지점을 찾고, 그 앞에 오토스케일 임계값을 두는 방법입니다.</description>
      <content:encoded><![CDATA[<p>Fly.io에서 Django를 싸게 돌리려고, 저는 shared-cpu-1x 작은 머신 한 대를 최대한 빠르게 만들고, 지연 시간이 무너질 때까지 부하 테스트를 한 뒤, 그 직전에 Fly 오토스케일러가 두 번째 머신을 띄우도록 설정합니다. 부하 테스트는 단계적으로 부하를 올리는 Locust(다른 앱에서는 동시성을 바꿔 가며 돌리는 작은 Python 스크립트)로 하고, p95 지연 시간과 에러율을 봅니다. 트래픽이 줄면 Fly가 유휴 머신을 다시 멈추기 때문에, 평소 비용은 앱당 작은 VM 한 대입니다.</p>
<p>저는 현재 1인 개발자로 두 개의 프로덕트를 운영하고 있습니다. 하나는 현장에서 견적을 내는 기술자들을 위한 <strong>현장 견적 앱</strong>이고, 다른 하나는 게시글 등록과 실시간 메시징을 갖춘 <strong>마켓플레이스 겸 커뮤니티 앱</strong>입니다.</p>
<p>프로덕트도 다르고 사용자도 다르지만, 기술 스택은 거의 동일합니다. 그리고 둘 다 Fly.io에서 가장 저렴한 티어인 <strong>shared-cpu-1x 머신</strong>에서만 돌아갑니다. 이 제약은 의도적인 것입니다. 피크 부하에 맞춰 서버를 크게 잡는 대신, 작은 머신 한 대에서 뽑아낼 수 있는 성능을 최대한 끌어올리고, 정확히 어디서 무너지는지 측정한 다음, 그 측정된 한계를 기준으로 Fly의 오토스케일러가 머신을 추가하고 제거하도록 맡깁니다. 이 글에서는 이를 가능하게 하는 최적화들, 임계값의 근거가 된 부하 테스트 수치, 그리고 Fly.io에서 스케일 업/다운이 실제로 어떻게 동작하는지 다룹니다.</p>

<h2 class="relative group">철학: 머신 한 대를 측정하고, 그다음 곱하기
    <div id="철학-머신-한-대를-측정하고-그다음-곱하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b2%a0%ed%95%99-%eb%a8%b8%ec%8b%a0-%ed%95%9c-%eb%8c%80%eb%a5%bc-%ec%b8%a1%ec%a0%95%ed%95%98%ea%b3%a0-%ea%b7%b8%eb%8b%a4%ec%9d%8c-%ea%b3%b1%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>두 앱 모두 스케일링 전략은 같은 세 단계입니다.</p>
<ol>
<li>작은 머신 한 대를 합리적인 수준에서 최대한 빠르게 만든다.</li>
<li>무너질 때까지 부하 테스트를 하고, 지연 시간이 급격히 나빠지는 동시성 지점을 기록한다.</li>
<li>그 지점 직전에 두 번째 머신이 시작되도록 Fly 오토스케일러를 설정하고, 트래픽이 줄면 유휴 머신을 정지시킨다.</li>
</ol>
<p>그러면 용량은 머신 수에 거의 선형으로 비례해서 늘어나고, 기본 비용은 앱당 작은 VM 한 대입니다. 아래의 모든 내용은 이 세 단계 중 하나를 위한 것입니다.</p>

<h2 class="relative group">작은 머신 한 대를 빠르게 만들기
    <div id="작은-머신-한-대를-빠르게-만들기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9e%91%ec%9d%80-%eb%a8%b8%ec%8b%a0-%ed%95%9c-%eb%8c%80%eb%a5%bc-%eb%b9%a0%eb%a5%b4%ea%b2%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>

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

<h3 class="relative group">핫 패스에는 Rust
    <div id="핫-패스에는-rust" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%ab-%ed%8c%a8%ec%8a%a4%ec%97%90%eb%8a%94-rust" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>Python 핫 패스에 Rust 기반 대체재가 있으면 무조건 씁니다.</p>
<ul>
<li><strong>orjson</strong> (<code>drf-orjson-renderer</code> 경유) — 모든 API 응답이 표준 라이브러리 JSON 인코더 대신 Rust로 직렬화됩니다.</li>
<li><strong>blurhash-rs</strong> — 업로드 시점에 이미지 플레이스홀더를 계산합니다.</li>
<li><strong>uuid-utils</strong> — Rust 기반 UUID 생성.</li>
<li><strong>psycopg 3 (binary)</strong> — C로 가속된 Postgres 드라이버.</li>
</ul>
<p>이것들은 아키텍처를 바꾸지 않습니다. 두 번째 머신이 필요해지기 전까지 vCPU 하나가 해낼 수 있는 한계치를 끌어올릴 뿐입니다.</p>

<h3 class="relative group">숨어 있는 지연 비용 없애기
    <div id="숨어-있는-지연-비용-없애기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%88%a8%ec%96%b4-%ec%9e%88%eb%8a%94-%ec%a7%80%ec%97%b0-%eb%b9%84%ec%9a%a9-%ec%97%86%ec%95%a0%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>두 코드베이스를 통틀어 가장 큰 성능 개선은 알고리즘이 아니라 커넥션 재사용이었습니다. Granian의 스레드 풀에서는 새 스레드마다 boto3 S3 클라이언트를 새로 만들고 있었고, 사진이나 PDF를 반환하는 모든 엔드포인트가 그 비용을 지불하고 있었습니다. 작은 커스텀 스토리지 클래스로 클라이언트를 프로세스 단위로 캐싱하도록 바꾸자:</p>
<blockquote><p>새 스레드에서의 서명된 URL 생성이 <strong>~150ms에서 ~1ms</strong>로 줄었습니다.</p>
</blockquote><p>데이터베이스에도 같은 교훈이 적용됩니다. Fly의 Postgres는 PgBouncer 뒤에 있으므로 앱은 <code>conn_max_age=0</code>으로 두고 커넥션 재사용을 풀러에 맡기며, 오래된 SSL 커넥션에 워커가 매달리지 않도록 <code>connect_timeout</code>을 5초로 잡아 빠르게 실패시킵니다.</p>

<h3 class="relative group">진짜 병목이 있는 곳에 캐싱하기
    <div id="진짜-병목이-있는-곳에-캐싱하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a7%84%ec%a7%9c-%eb%b3%91%eb%aa%a9%ec%9d%b4-%ec%9e%88%eb%8a%94-%ea%b3%b3%ec%97%90-%ec%ba%90%ec%8b%b1%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>마켓플레이스를 부하 테스트하면서 새겨둘 만한 사실을 발견했습니다. <strong>한계는 CPU가 아니라 Postgres 커넥션이었습니다.</strong> 둘러보기 트래픽은 압도적으로 익명이고 사용자 간에 동일한데, 부하가 걸리면 그 동일한 목록 쿼리들이 CPU는 놀고 있는 동안 커넥션 풀을 포화시킵니다.</p>
<p>해결책은 익명 목록 페이지를 위한 Redis 캐시입니다. 무효화는 네임스페이스별 버전 카운터로 처리합니다. 쓰기가 발생하면 카운터 하나만 올리면 되고(O(1), 키 관리 불필요), 해당 리소스의 캐시된 페이지 전체가 즉시 무효화됩니다. 짧은 TTL이 놓친 것들을 보완합니다. 둘러보기 페이지를 Redis에서 바로 서빙하면 진짜 병목이 제거되고, 작은 머신은 커넥션을 로그인 트래픽에 쓸 수 있게 됩니다.</p>

<h3 class="relative group">브로커 없는 백그라운드 작업
    <div id="브로커-없는-백그라운드-작업" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b8%8c%eb%a1%9c%ec%bb%a4-%ec%97%86%eb%8a%94-%eb%b0%b1%ea%b7%b8%eb%9d%bc%ec%9a%b4%eb%93%9c-%ec%9e%91%ec%97%85" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>두 앱 모두 Celery 대신 Django의 데이터베이스 기반 태스크 프레임워크인 <strong>django-tasks-db</strong>를 씁니다. 별도의 <code>db_worker</code> 프로세스가 데이터베이스를 폴링하며 이미지 처리, PDF 생성, 기계 번역, 리마인더 같은 작업을 처리합니다. 브로커도 없고, 크기를 정하고 모니터링해야 할 추가 서비스도 없으며, API 프로세스는 무거운 작업에 절대 블로킹되지 않습니다. 심지어 워커는 PgBouncer를 우회해 Postgres에 직접 연결하는데, 오래 걸리는 작업은 트랜잭션 풀링과 잘 맞지 않기 때문입니다.</p>

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

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

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

<h3 class="relative group">WebSocket은 다른 축으로 스케일된다
    <div id="websocket은-다른-축으로-스케일된다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#websocket%ec%9d%80-%eb%8b%a4%eb%a5%b8-%ec%b6%95%ec%9c%bc%eb%a1%9c-%ec%8a%a4%ec%bc%80%ec%9d%bc%eb%90%9c%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>마켓플레이스는 WebSocket 서버를 같은 Docker 이미지를 공유하는 <strong>두 번째 Fly 앱</strong>으로 분리합니다. API 앱은 WSGI로 워커 1개 × 블로킹 스레드 16개를 돌리며 <em>요청</em>을 셉니다(soft limit 12). WebSocket 앱은 <strong>512MB</strong> 머신에서 ASGI로 uvloop와 함께 워커 1개를 돌리며 <em>커넥션</em>을 셉니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>오래 유지되는 소켓은 열어 두는 비용은 거의 없지만 슬롯을 몇 시간씩 차지하고, HTTP 요청은 슬롯을 밀리초 단위로 차지합니다. 같은 오토스케일링 신호로 둘 다 감당할 수는 없습니다. 분리하면 각 플릿이 자기 부하를 제대로 반영하는 지표로 스케일되고, 유휴 WebSocket 용량은 2GB가 아니라 512MB만 차지합니다. Channels 쪽에서는 Redis 레이어가 <code>capacity: 1500</code>과 60초 메시지 만료로 돌아가고, 30초마다 소켓 keepalive를 보냅니다. Fly의 Redis 프록시가 Channels가 의존하는 장수명 BRPOP 소켓을 유휴 상태로 간주해 끊어버리기 때문입니다.</p>

<h2 class="relative group">컴퓨팅을 엣지와 클라이언트로 오프로드하기
    <div id="컴퓨팅을-엣지와-클라이언트로-오프로드하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%bb%b4%ed%93%a8%ed%8c%85%ec%9d%84-%ec%97%a3%ec%a7%80%ec%99%80-%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8%eb%a1%9c-%ec%98%a4%ed%94%84%eb%a1%9c%eb%93%9c%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>가장 저렴한 서버 작업은 서버가 아예 하지 않는 작업입니다.</p>
<ul>
<li><strong>정적 파일과 미디어</strong>는 Fly의 S3 호환 오브젝트 스토리지에 두고, 비공개 미디어는 서명된 URL로 CDN을 통해 서빙합니다. 이미지 바이트는 Django 머신을 전혀 거치지 않습니다.</li>
<li><strong>AI 추론은 사용자의 폰에서 돌아갑니다.</strong> 두 앱 모두 작은 로컬 LLM(양자화된 4B 모델, 약 2.7GB)을 내장해 기기에서 직접 돌립니다. 서버 측 추론 비용은 0이고, 오프라인에서도 동작합니다.</li>
<li><strong>오프라인 우선 모바일</strong>(기기의 SQLite와 재시도가 있는 동기화 큐) 덕분에 앱은 키 입력마다 수다스러운 요청으로 API를 두들기는 대신, 쓰기를 배치로 모으고 통신 음영 지역을 견딥니다.</li>
</ul>

<h2 class="relative group">나머지 스택, 간단히
    <div id="나머지-스택-간단히" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%82%98%eb%a8%b8%ec%a7%80-%ec%8a%a4%ed%83%9d-%ea%b0%84%eb%8b%a8%ed%9e%88" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>인증은 호스팅형 ID 공급자와 WebAuthn 패스키를 쓰고, WebSocket 커넥션은 업그레이드 완료 전에 인증합니다. 프로덕션은 Postgres, 개발은 SQLite. 관리자 화면과 감사 이력에는 기성 Django 패키지 몇 개를 씁니다. 모바일은 Expo 위의 React Native와 TypeScript에 Expo Router와 Tailwind를 쓰고, OTA 업데이트는 자체 호스팅 Expo Updates 서버에서 내보내며, 인앱 구독은 구독 관리 서비스에 맡깁니다. 품질 게이트는 백엔드에 pytest와 mypy, 클라이언트에 단위 테스트와 E2E 테스트입니다.</p>

<h2 class="relative group">가져가서 쓸 만한 것
    <div id="가져가서-쓸-만한-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%80%ec%a0%b8%ea%b0%80%ec%84%9c-%ec%93%b8-%eb%a7%8c%ed%95%9c-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이식 가능한 아이디어는 특정 라이브러리가 아니라 루프 자체입니다. 저렴한 머신 한 대를 진짜로 빠르게 만들고(Rust 핫 패스, 커넥션 재사용, 진짜 병목 앞의 캐시, CDN과 클라이언트로 오프로드), 정확히 어느 동시성에서 무너지는지 찾을 때까지 부하 테스트를 하고, 오토스케일러의 soft limit을 그 변곡점에 놓고, 유휴 머신은 정지시키고, 최소 플릿은 1로 유지합니다. 프로덕션 앱 두 개, 실시간 기능, AI 어시스턴트 — 그런데 평상시 청구서는 플랫폼에서 가장 저렴한 VM 두어 대 값입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Zustand vs React Query (TanStack Query) vs AsyncStorage: 어디에 뭘 넣어야 할까</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-05-05-zustand-vs-react-query/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-05-05-zustand-vs-react-query/</guid>
      <pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React</category>
      <category>React Native</category>
      <category>Zustand</category>
      <category>React Query</category>
      <category>TanStack Query</category>
      <category>AsyncStorage</category>
      <category>State Management</category>
      <category>Mobile Development</category>
      <category>Web Development</category>
      <description>Zustand와 React Query는 양자택일이 아닙니다. React Query(TanStack Query)는 서버 상태를 캐싱하고, Zustand는 클라이언트 상태를 들고, AsyncStorage는 재시작 후에도 남아야 하는 것을 보관합니다. React와 React Native에서 상태를 셋에 어떻게 나누는지, Zustand와 React Query를 함께 쓰는 법, 그리고 셋을 조합해 오프라인 모드를 동작시키는 방법을 정리했습니다.</description>
      <content:encoded><![CDATA[<p>Zustand와 React Query는 경쟁 관계가 아닙니다. 그래서 &ldquo;Zustand vs React Query&quot;에 대한 진짜 답은 대개 &ldquo;둘 다&quot;입니다. React Query는 지금 TanStack Query라는 이름으로 배포되는데, 서버 상태를 위한 도구입니다. 서버가 소유한 데이터를 가져오고, 캐싱하고, 중복 요청을 하나로 합치고, 낡으면 다시 가져옵니다. Zustand는 클라이언트 상태를 위한 도구입니다. 어떤 탭이 열려 있는지, 반쯤 쓰다 만 폼에 뭐가 들어 있는지처럼 내 앱만 아는 것들이죠. 대부분의 앱은 둘을 함께 쓰고, React Native에서는 재시작 후에도 남아야 하는 것을 맡는 세 번째 조각으로 AsyncStorage가 붙습니다. 이 글의 나머지는 그 경계선을 어디에 긋는지 코드와 함께 보여 주고, 셋이 합쳐져 오프라인에서도 동작하는 앱이 되는 과정을 다룹니다.</p>
<p>지금 만들고 있는 견적 앱에는 절대 양보할 수 없는 요구사항이 하나 있습니다. 신호가 없어도 동작해야 한다는 것. 견적은 현장에 나가서 그 자리에서 작성하는데, 현장이라는 게 신축 건물이거나 지하라서 전파가 잡힌다는 보장이 없거든요. 이 제약 덕분에 많은 React 프로젝트가 얼버무리고 넘어가는 질문, &ldquo;어떤 상태를 어디에 둘 것인가&quot;를 정면으로 마주하게 됐습니다.</p>
<p>흔한 실패 패턴은 스토어 하나에 전부 밀어 넣는 겁니다. API 캐싱, 사용자 설정, 폼 상태, 인증 토큰까지 같은 Redux 덩어리 안에 뒤엉켜 있죠. 하지만 이것들은 성격도 수명도 다른 상태이고, 각자에게 맞는 도구가 따로 있습니다. Zustand, React Query, AsyncStorage는 각각 딱 하나의 문제를 제대로 풀어줍니다. 경계선만 잘 그으면 아키텍처는 거의 알아서 정리됩니다.</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%83%81%ed%83%9c%eb%8a%94-%ec%84%b8-%ec%a2%85%eb%a5%98%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>라이브러리 얘기를 하기 전에, 지금 관리하려는 게 뭔지부터 분명히 해두는 게 좋습니다.</p>
<p>클라이언트 상태는 앱 런타임에만 존재하는 데이터입니다. UI 토글, 선택된 탭, 폼 입력, 모달 열림 여부. 상위에 소유자가 없고, 프로세스가 끝나면 같이 사라집니다.</p>
<p>서버 상태는 사정이 다릅니다. 소유자는 서버이고, 앱이 들고 있는 건 로컬 복사본일 뿐이죠. 사용자 프로필, 상품 목록, 알림, 피드. 내 복사본은 낡아가고, 다시 가져와야 하고, 지금 이 순간 다른 클라이언트는 다른 버전을 보고 있을지도 모릅니다.</p>
<p>영속 상태는 재시작을 버텨야 하는 데이터입니다. 인증 토큰, 온보딩 완료 플래그, 캐시된 설정, 오프라인 데이터. 기기 자체에 저장됩니다.</p>
<p>대부분의 앱에는 셋 다 있습니다. 그리고 대부분의 사고는 셋을 도구 하나로 처리하려다 생깁니다.</p>

<h2 class="relative group">Zustand: 클라이언트 상태 담당
    <div id="zustand-클라이언트-상태-담당" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#zustand-%ed%81%b4%eb%9d%bc%ec%9d%b4%ec%96%b8%ed%8a%b8-%ec%83%81%ed%83%9c-%eb%8b%b4%eb%8b%b9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Zustand는 격식 차릴 게 없는 작은 상태 라이브러리입니다. Provider도, 보일러플레이트도, 몇 겹씩 쌓인 Context 래퍼도 없습니다. 스토어 만들고 컴포넌트에서 쓰면 끝. 배울 게 사실상 그게 전부입니다.</p>
<p>여러 컴포넌트가 같은 UI 상태를 공유할 때(사이드바 열림/닫힘, 활성 필터, 선택된 항목), API에서 오지 않는 앱 레벨 상태가 있을 때(테마, 언어, 시작 시 읽는 기능 플래그), 장바구니 계산이나 멀티 스텝 위저드처럼 진짜 클라이언트 로직이 있을 때가 Zustand의 자리입니다. 동기적이고 예측 가능하게 업데이트돼야 하는 것들이죠.</p>
<p>기본 스토어는 이렇게 생겼습니다.</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="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">AppState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">sidebarOpen</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">selectedFilters</span>: <span class="kt">string</span><span class="p">[]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setTheme</span><span class="o">:</span> <span class="p">(</span><span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">toggleSidebar</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setFilters</span><span class="o">:</span> <span class="p">(</span><span class="nx">filters</span>: <span class="kt">string</span><span class="p">[])</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">useAppStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">AppState</span><span class="p">&gt;((</span><span class="kr">set</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">sidebarOpen</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">selectedFilters</span><span class="o">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setTheme</span><span class="o">:</span> <span class="p">(</span><span class="nx">theme</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">theme</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">toggleSidebar</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">((</span><span class="nx">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="nx">sidebarOpen</span><span class="o">:</span> <span class="o">!</span><span class="nx">state</span><span class="p">.</span><span class="nx">sidebarOpen</span> <span class="p">})),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setFilters</span><span class="o">:</span> <span class="p">(</span><span class="nx">filters</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">selectedFilters</span>: <span class="kt">filters</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-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">Sidebar() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">{</span> <span class="nx">sidebarOpen</span><span class="p">,</span> <span class="nx">toggleSidebar</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useAppStore</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sidebarOpen</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">
</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="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="o">=</span><span class="s">&#34;sidebar&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">button</span> <span class="na">onClick</span><span class="o">=</span><span class="p">{</span><span class="nx">toggleSidebar</span><span class="p">}&gt;</span><span class="nx">Close</span><span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="cm">/* sidebar content */</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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>

<h3 class="relative group">Zustand에 넣지 않는 것
    <div id="zustand에-넣지-않는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#zustand%ec%97%90-%eb%84%a3%ec%a7%80-%ec%95%8a%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>우선 API 응답입니다. API를 호출할 때마다 Zustand 스토어에 쓰고, 컴포넌트는 쿼리 대신 스토어를 읽는 프로젝트를 본 적이 있습니다. 그 끝은 캐시 무효화, 로딩 상태, 에러 처리, 리페치, 페이지네이션을 전부 손으로 다시 구현하는 것이었죠. 전부 React Query가 공짜로 해주는 일들입니다.</p>
<p>또 하나는 재시작 후에도 남아야 하는 데이터입니다. Zustand 상태는 메모리에 있어서 앱을 닫으면 사라집니다. persist 미들웨어로 AsyncStorage에 다리를 놓을 수는 있지만(뒤에서 다룹니다), 그건 의식적으로 내리는 결정이어야지 습관이어서는 안 됩니다.</p>

<h2 class="relative group">React Query: 서버 상태 담당
    <div id="react-query-서버-상태-담당" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#react-query-%ec%84%9c%eb%b2%84-%ec%83%81%ed%83%9c-%eb%8b%b4%eb%8b%b9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>React Query(요즘은 TanStack Query죠)는 원격 데이터의 라이프사이클 전체를 관리합니다. 페칭, 캐싱, 동기화, 업데이트, 가비지 컬렉션까지. 발상의 전환 포인트는 서버 데이터를 &ldquo;내가 소유한 상태&quot;가 아니라 &ldquo;신선하게 유지해야 할 캐시&quot;로 취급한다는 점입니다. 소유자는 서버고, 나는 복사본을 맡아둔 것뿐입니다.</p>
<p>API에서 오는 데이터라면 저는 전부 여기에 둡니다. 같은 엔드포인트를 여러 컴포넌트가 쓰는 데이터(요청은 자동으로 중복 제거됩니다), 페이지네이션과 무한 스크롤, 앱에 돌아왔을 때 백그라운드에서 다시 가져와야 하는 데이터, UI를 먼저 바꾸고 서버가 거부하면 되돌리는 낙관적 업데이트까지 전부요.</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="p">{</span> <span class="nx">useQuery</span><span class="p">,</span> <span class="nx">useMutation</span><span class="p">,</span> <span class="nx">useQueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">useUser</span><span class="p">(</span><span class="nx">userId</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">userId</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">fetch</span><span class="p">(</span><span class="sb">`/api/users/</span><span class="si">${</span><span class="nx">userId</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">then</span><span class="p">(</span><span class="nx">res</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="nx">staleTime</span>: <span class="kt">5</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 5분 동안 fresh로 간주
</span></span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">useUpdateUser() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="nx">useQueryClient</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="nx">useMutation</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">mutationFn</span><span class="o">:</span> <span class="p">(</span><span class="nx">data</span><span class="o">:</span> <span class="p">{</span> <span class="nx">id</span>: <span class="kt">string</span><span class="p">;</span> <span class="nx">name</span>: <span class="kt">string</span> <span class="p">})</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">fetch</span><span class="p">(</span><span class="sb">`/api/users/</span><span class="si">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">id</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">method</span><span class="o">:</span> <span class="s1">&#39;PATCH&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">body</span>: <span class="kt">JSON.stringify</span><span class="p">(</span><span class="nx">data</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="nx">onSuccess</span><span class="o">:</span> <span class="p">(</span><span class="nx">_</span><span class="p">,</span> <span class="nx">variables</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">queryClient</span><span class="p">.</span><span class="nx">invalidateQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">variables</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><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-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">UserProfile</span><span class="p">({</span> <span class="nx">userId</span> <span class="p">}</span><span class="o">:</span> <span class="p">{</span> <span class="nx">userId</span>: <span class="kt">string</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">{</span> <span class="nx">data</span>: <span class="kt">user</span><span class="p">,</span> <span class="nx">isLoading</span><span class="p">,</span> <span class="nx">error</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useUser</span><span class="p">(</span><span class="nx">userId</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">updateUser</span> <span class="o">=</span> <span class="nx">useUpdateUser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isLoading</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">Spinner</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">ErrorMessage</span> <span class="na">error</span><span class="o">=</span><span class="p">{</span><span class="nx">error</span><span class="p">}</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">
</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="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;{</span><span class="nx">user</span><span class="p">.</span><span class="nx">name</span><span class="p">}&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">button</span>
</span></span><span class="line"><span class="cl">        <span class="na">onClick</span><span class="o">=</span><span class="p">{()</span> <span class="o">=&gt;</span> <span class="nx">updateUser</span><span class="p">.</span><span class="nx">mutate</span><span class="p">({</span> <span class="nx">id</span>: <span class="kt">userId</span><span class="p">,</span> <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;New Name&#39;</span> <span class="p">})}</span>
</span></span><span class="line"><span class="cl">        <span class="na">disabled</span><span class="o">=</span><span class="p">{</span><span class="nx">updateUser</span><span class="p">.</span><span class="nx">isPending</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nx">Update</span> <span class="nx">Name</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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>

<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%88-%ec%a7%9c%eb%8f%84-%eb%90%98%eb%8a%94-%ec%bd%94%eb%93%9c%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>서버 데이터를 여기 두라고 제가 고집하는 이유는, 공짜로 딸려오는 장치가 그만큼 많아서입니다. 컴포넌트 다섯 개가 같은 사용자를 요청해도 네트워크 요청은 한 번입니다. 창이 포커스를 되찾거나 네트워크가 재연결되면 알아서 리페치합니다. 오래된 캐시는 알아서 가비지 컬렉션되고요. 모든 쿼리가 <code>isLoading</code>, <code>isError</code>, <code>data</code> 같은 값을 넘겨주니 로딩·에러 UI를 매번 새로 만들 필요가 없습니다. 실패한 요청은 지수 백오프로 재시도되고, 롤백까지 포함된 낙관적 업데이트도 내장 패턴으로 제공됩니다. 직접 만들면 하루가 통째로 날아가는 것들이죠.</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%96%b4%ec%9a%b8%eb%a6%ac%ec%a7%80-%ec%95%8a%eb%8a%94-%ea%b3%b3" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>클라이언트 전용 상태입니다. 서버를 거치지 않는 데이터에 React Query를 쓰면 절차만 늘어납니다. 모달 열림 상태에 캐시 무효화나 리페치 간격이 필요할 리 없으니까요.</p>
<p>장기 영속 레이어도 아닙니다. 캐시는 메모리에 있으니 재시작하면 텅 비고, 전부 다시 가져옵니다. 캐시를 영속화할 수는 있고 React Native에서는 그래야 하지만(아래 오프라인 섹션에서 다룹니다), 원본은 언제나 서버입니다.</p>

<h2 class="relative group">AsyncStorage: 재시작을 버티는 것들 담당
    <div id="asyncstorage-재시작을-버티는-것들-담당" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#asyncstorage-%ec%9e%ac%ec%8b%9c%ec%9e%91%ec%9d%84-%eb%b2%84%ed%8b%b0%eb%8a%94-%ea%b2%83%eb%93%a4-%eb%8b%b4%eb%8b%b9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>AsyncStorage는 React Native의 키-값 저장소입니다. 웹에서의 대응물은 <code>localStorage</code>(동기)나 <code>IndexedDB</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="#%ed%94%8c%eb%9e%ab%ed%8f%bc%eb%a7%88%eb%8b%a4-%ec%86%8d%ec%9d%80-%eb%8b%a4%eb%a5%b4%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p><code>@react-native-async-storage/async-storage</code>를 쓰면 API는 하나지만, 그 아래 백엔드는 플랫폼마다 꽤 다릅니다.</p>
<p>Android에서는 <code>RKStorage</code>를 통한 SQLite입니다. 앱 내부 저장소 디렉터리의 데이터베이스에 저장되고, 빠르고 안정적이며 샌드박스 처리돼 있어 다른 앱이 건드릴 수 없습니다. 다만 아래 표에 있는 기본 용량 제한은 염두에 두세요.</p>
<p>iOS에서는 작은 값은 <code>NSUserDefaults</code>, 큰 값은 직렬화된 파일에 들어갑니다. 역시 앱 컨테이너 안에 샌드박스 처리됩니다. Apple이 <code>NSUserDefaults</code>에 하드 리미트를 두진 않지만, 값 하나를 몇백 KB 이하로 유지하는 게 상식선입니다. 그보다 크다면 애초에 WatermelonDB나 Realm 같은 제대로 된 데이터베이스를 검토하는 게 맞습니다.</p>
<p>웹(React Native Web / Expo Web)에서는 <code>localStorage</code>로 폴백되는데, 브라우저에 따라 5~10MB 정도가 한계입니다. 웹 전용 React 앱이라면 <code>localStorage</code>를 직접 쓰거나, 데이터가 커지면 <code>idb-keyval</code> 같은 래퍼로 <code>IndexedDB</code>를 쓰면 됩니다.</p>
<table>
	<thead>
			<tr>
					<th>플랫폼</th>
					<th>백엔드</th>
					<th>용량 제한</th>
					<th>저장 위치</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Android</td>
					<td>SQLite (RKStorage)</td>
					<td>기본 약 6MB (변경 가능)</td>
					<td>앱 내부 저장소</td>
			</tr>
			<tr>
					<td>iOS</td>
					<td>NSUserDefaults / 파일</td>
					<td>하드 리미트 없음 (값은 작게 유지)</td>
					<td>앱 샌드박스 컨테이너</td>
			</tr>
			<tr>
					<td>Web</td>
					<td>localStorage</td>
					<td>약 5~10MB (브라우저마다 다름)</td>
					<td>브라우저 오리진 저장소</td>
			</tr>
	</tbody>
</table>

<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%82%b4%ea%b0%80-%eb%84%a3%ec%96%b4%eb%91%90%eb%8a%94-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>인증 토큰과 세션 데이터, 계속 유지돼야 할 사용자 설정(언어, 테마, 알림 설정), 온보딩 완료 플래그, 오프라인용 캐시 데이터. 요컨대 &ldquo;재시작을 버텨야 하는 작은 키-값&quot;이 이 카테고리의 전부입니다.</p>
<p>API는 저장소가 가질 수 있는 가장 단순한 모양입니다.</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="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 값 저장
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">,</span> <span class="nx">token</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 값 읽기
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 객체 저장 (직렬화 필요)
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;user_preferences&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;dark&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">language</span><span class="o">:</span> <span class="s1">&#39;en&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">notifications</span>: <span class="kt">true</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="c1">// 객체 읽기
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">prefs</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;user_preferences&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;{}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 값 삭제
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 전부 삭제 (주의해서 사용)
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">clear</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%9b%b9%ec%97%90%ec%84%9c%eb%8a%94" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>웹 전용 React 앱에는 AsyncStorage가 아예 필요 없습니다. 단순한 키-값이면 <code>localStorage</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="c1">// 동기 - 메인 스레드를 블로킹하지만 작은 데이터에는 괜찮다
</span></span></span><span class="line"><span class="cl"><span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;theme&#39;</span><span class="p">,</span> <span class="s1">&#39;dark&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">theme</span> <span class="o">=</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;theme&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 구조화된 데이터용
</span></span></span><span class="line"><span class="cl"><span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;Jared&#39;</span><span class="p">,</span> <span class="nx">role</span><span class="o">:</span> <span class="s1">&#39;admin&#39;</span> <span class="p">}))</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;user&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;{}&#39;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>데이터셋이 커지면 <code>IndexedDB</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="p">{</span> <span class="kr">get</span><span class="p">,</span> <span class="kr">set</span><span class="p">,</span> <span class="nx">del</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;idb-keyval&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="kr">set</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">,</span> <span class="nx">hugeArray</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="kr">get</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">del</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">)</span></span></span></code></pre></div></div>

<h3 class="relative group">어울리지 않는 곳
    <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="#%ec%96%b4%ec%9a%b8%eb%a6%ac%ec%a7%80-%ec%95%8a%eb%8a%94-%ea%b3%b3-1" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>키-값 저장소지 데이터베이스가 아닙니다. 관계형 데이터, 수천 건짜리 배열, 인덱싱이나 쿼리가 필요한 것들은 SQLite(<code>expo-sqlite</code>), WatermelonDB, Realm의 영역입니다.</p>
<p>보안 저장소도 아닙니다. 루팅되거나 탈옥된 기기에서는 AsyncStorage 내용이 그대로 읽힙니다. 민감한 토큰은 <code>expo-secure-store</code>나 <code>react-native-keychain</code>에 두세요.</p>

<h2 class="relative group">셋을 엮는 방법
    <div id="셋을-엮는-방법" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%85%8b%ec%9d%84-%ec%97%ae%eb%8a%94-%eb%b0%a9%eb%b2%95" aria-label="앵커">#</a>
    </span>
    
</h2>
<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="#%ec%9d%b8%ec%a6%9d-%ed%9d%90%eb%a6%84" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="c1">// 1. AsyncStorage: 인증 토큰 영속화
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">saveToken</span><span class="p">(</span><span class="nx">token</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">,</span> <span class="nx">token</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="kr">async</span> <span class="kd">function</span> <span class="nx">getToken</span><span class="p">()</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">string</span> <span class="err">|</span> <span class="na">null</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</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="c1">// 2. Zustand: 메모리에서 인증 상태 추적
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">AuthState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isAuthenticated</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">token</span>: <span class="kt">string</span> <span class="o">|</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setAuth</span><span class="o">:</span> <span class="p">(</span><span class="nx">token</span>: <span class="kt">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">clearAuth</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">useAuthStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">AuthState</span><span class="p">&gt;((</span><span class="kr">set</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isAuthenticated</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">token</span>: <span class="kt">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setAuth</span><span class="o">:</span> <span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isAuthenticated</span>: <span class="kt">true</span><span class="p">,</span> <span class="nx">token</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">clearAuth</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isAuthenticated</span>: <span class="kt">false</span><span class="p">,</span> <span class="nx">token</span>: <span class="kt">null</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl"><span class="p">}))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 3. React Query: 토큰을 사용해 사용자 프로필 가져오기
</span></span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">useCurrentUser() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="nx">useAuthStore</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">token</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="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;currentUser&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;/api/me&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="nx">Authorization</span><span class="o">:</span> <span class="sb">`Bearer </span><span class="si">${</span><span class="nx">token</span><span class="si">}</span><span class="sb">`</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">}).</span><span class="nx">then</span><span class="p">(</span><span class="nx">res</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enabled</span><span class="o">:</span> <span class="o">!!</span><span class="nx">token</span><span class="p">,</span> <span class="c1">// 토큰이 있을 때만 패치
</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>시작할 때는 이렇게요.</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="c1">// 앱 초기화
</span></span></span><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">initializeApp() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getToken</span><span class="p">()</span> <span class="c1">// AsyncStorage에서 읽기
</span></span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">useAuthStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">setAuth</span><span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="c1">// 빠른 접근을 위해 Zustand에 넣기
</span></span></span><span class="line"><span class="cl">    <span class="c1">// React Query가 자동으로 사용자 프로필을 가져온다
</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>AsyncStorage가 토큰을 재시작 너머까지 지켜주고, Zustand가 어디서든 싸게 읽을 수 있게 해주고, React Query는 토큰이 생기는 순간 프로필을 가져옵니다. 각자 잘하는 일 하나씩만 하는 거죠.</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%9e%ac%ec%8b%9c%ec%9e%91%ed%95%b4%eb%8f%84-%eb%82%a8%eb%8a%94-%ed%85%8c%eb%a7%88-%ec%84%a4%ec%a0%95" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">persist</span><span class="p">,</span> <span class="nx">createJSONStorage</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand/middleware&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Zustand의 persist 미들웨어가 간극을 메워준다
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">useThemeStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nx">persist</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="kr">set</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">      <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="kr">as</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">toggleTheme</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="kr">set</span><span class="p">((</span><span class="nx">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">          <span class="nx">theme</span>: <span class="kt">state.theme</span> <span class="o">===</span> <span class="s1">&#39;light&#39;</span> <span class="o">?</span> <span class="s1">&#39;dark&#39;</span> <span class="o">:</span> <span class="s1">&#39;light&#39;</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><span class="line"><span class="cl">      <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;theme-storage&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">storage</span>: <span class="kt">createJSONStorage</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">AsyncStorage</span><span class="p">),</span> <span class="c1">// React Native
</span></span></span><span class="line"><span class="cl">      <span class="c1">// storage: createJSONStorage(() =&gt; localStorage), // Web
</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>런타임 상태는 Zustand가 관리하고, persist 미들웨어가 뒤에서 AsyncStorage(웹이라면 localStorage)와 동기화합니다. 컴포넌트는 영속 레이어가 있는지도 모르고, 알 필요도 없습니다.</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%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8-%ec%9a%b0%ec%84%a0-%ec%9d%bd%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<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="p">{</span> <span class="nx">useQuery</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">useProducts() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;products&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span>: <span class="kt">async</span> <span class="p">()</span> <span class="o">=&gt;</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="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="s1">&#39;/api/products&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1">// 오프라인 사용을 위해 AsyncStorage에 캐시
</span></span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;cached_products&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">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">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 네트워크 실패 - 캐시 데이터 시도
</span></span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">cached</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;cached_products&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">cached</span><span class="p">)</span> <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">cached</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="nx">error</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="nx">staleTime</span>: <span class="kt">10</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>페칭과 인메모리 캐싱은 React Query, 네트워크가 없을 때의 폴백은 AsyncStorage. 그런 분담입니다.</p>

<h2 class="relative group">오프라인 모드, 단계별로
    <div id="오프라인-모드-단계별로" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8-%eb%aa%a8%eb%93%9c-%eb%8b%a8%ea%b3%84%eb%b3%84%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>여기가 제 진짜 관심사입니다. 사용자가 지하철, 엘리베이터, 비행기에서 앱을 연다는 일반론도 맞지만, 제 경우는 더 절박합니다. 견적은 현장에서 쓰는 건데, 현장에 신호가 있으리란 보장이 없거든요. 지하에서 스피너만 돌고 있다는 건 견적 한 건을 놓친다는 뜻입니다.</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%eb%8b%a8%ea%b3%84-%ec%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8%ec%9d%b8%ec%a7%80-%ec%95%8c%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>먼저 기기가 네트워크 상태를 앱에 알려주고, 앱은 그 답을 어딘가에 담아둬야 합니다. 이벤트는 <code>@react-native-community/netinfo</code>가 쏴주니, 작은 Zustand 스토어에 담아 어디서든 읽을 수 있게 합니다.</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="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">NetInfo</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-community/netinfo&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">NetworkState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isOnline</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setOnline</span><span class="o">:</span> <span class="p">(</span><span class="nx">online</span>: <span class="kt">boolean</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">useNetworkStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">NetworkState</span><span class="p">&gt;((</span><span class="kr">set</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isOnline</span>: <span class="kt">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setOnline</span><span class="o">:</span> <span class="p">(</span><span class="nx">online</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isOnline</span>: <span class="kt">online</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="c1">// 앱 시작 시 한 번만 구독
</span></span></span><span class="line"><span class="cl"><span class="nx">NetInfo</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">((</span><span class="nx">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">useNetworkStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">setOnline</span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">isConnected</span> <span class="o">??</span> <span class="kc">false</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>useNetworkStore((s) =&gt; s.isOnline)</code>을 확인해서 오프라인 배너를 띄우거나, 전송 버튼을 비활성화하거나, &ldquo;다시 연결되면 동기화됩니다&quot;라고 안내할 수 있습니다.</p>

<h3 class="relative group">2단계: React Query에 오프라인 알려주기
    <div id="2단계-react-query에-오프라인-알려주기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%eb%8b%a8%ea%b3%84-react-query%ec%97%90-%ec%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8-%ec%95%8c%eb%a0%a4%ec%a3%bc%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>React Query에는 오프라인일 때 쿼리와 뮤테이션이 어떻게 움직일지 정하는 <code>networkMode</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="p">{</span> <span class="nx">QueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">QueryClient</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">defaultOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queries</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">networkMode</span><span class="o">:</span> <span class="s1">&#39;offlineFirst&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="c1">// 캐시 데이터를 즉시 반환하고, 온라인일 때 백그라운드에서 리패치
</span></span></span><span class="line"><span class="cl">      <span class="nx">staleTime</span>: <span class="kt">5</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">gcTime</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 캐시를 24시간 유지
</span></span></span><span class="line"><span class="cl">      <span class="nx">retry</span><span class="o">:</span> <span class="p">(</span><span class="nx">failureCount</span><span class="p">,</span> <span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="c1">// 오프라인이면 재시도하지 않음 - 어차피 또 실패할 뿐
</span></span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">useNetworkStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">isOnline</span><span class="p">)</span> <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="nx">failureCount</span> <span class="o">&lt;</span> <span class="mi">3</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="nx">mutations</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">networkMode</span><span class="o">:</span> <span class="s1">&#39;offlineFirst&#39;</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>세 가지 모드는 이렇습니다.</p>
<table>
	<thead>
			<tr>
					<th>모드</th>
					<th>동작</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>online</code> (기본값)</td>
					<td>온라인일 때만 쿼리를 실행하고, 오프라인이면 일시 정지합니다.</td>
			</tr>
			<tr>
					<td><code>always</code></td>
					<td>네트워크 상태와 상관없이 쿼리를 실행합니다. 실패 처리는 <code>queryFn</code>이 맡습니다.</td>
			</tr>
			<tr>
					<td><code>offlineFirst</code></td>
					<td>캐시 데이터를 위해 쿼리를 한 번 실행한 뒤, 리페치는 온라인이 될 때까지 멈춥니다.</td>
			</tr>
	</tbody>
</table>
<p>대부분의 모바일 앱에는 <code>offlineFirst</code>가 정답입니다. 캐시 데이터는 즉시 보이고, 새 데이터는 네트워크가 허락할 때 도착합니다.</p>

<h3 class="relative group">3단계: 쿼리 캐시 영속화
    <div id="3단계-쿼리-캐시-영속화" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%eb%8b%a8%ea%b3%84-%ec%bf%bc%eb%a6%ac-%ec%ba%90%ec%8b%9c-%ec%98%81%ec%86%8d%ed%99%94" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>기본 상태에서는 캐시가 메모리에만 있어서, 재시작하면 온 화면이 스피너가 됩니다. 오프라인 모드라면 캐시를 AsyncStorage에 영속화해야 합니다.</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="p">{</span> <span class="nx">QueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">PersistQueryClientProvider</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query-persist-client&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">createAsyncStoragePersister</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/query-async-storage-persister&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">QueryClient</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">defaultOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queries</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">gcTime</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 24시간 - maxAge 이상이어야 함
</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><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">asyncStoragePersister</span> <span class="o">=</span> <span class="nx">createAsyncStoragePersister</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">storage</span>: <span class="kt">AsyncStorage</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key</span><span class="o">:</span> <span class="s1">&#39;react-query-cache&#39;</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="c1">// App 컴포넌트에서
</span></span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">App() {</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="p">&lt;</span><span class="nt">PersistQueryClientProvider</span>
</span></span><span class="line"><span class="cl">      <span class="na">client</span><span class="o">=</span><span class="p">{</span><span class="nx">queryClient</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="na">persistOptions</span><span class="o">=</span><span class="p">{{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">persister</span>: <span class="kt">asyncStoragePersister</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">maxAge</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 24시간보다 오래된 데이터는 복원하지 않음
</span></span></span><span class="line"><span class="cl">        <span class="nx">dehydrateOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nx">shouldDehydrateQuery</span><span class="o">:</span> <span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="c1">// 성공한 쿼리만 영속화
</span></span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="nx">query</span><span class="p">.</span><span class="nx">state</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="s1">&#39;success&#39;</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><span class="line"><span class="cl">    <span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">YourApp</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">PersistQueryClientProvider</span><span class="p">&gt;</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>이제 신호가 없는 곳에서 앱을 열어도 빈 화면 대신 마지막으로 받아온 데이터가 바로 보이고, 네트워크가 돌아오면 React Query가 조용히 뒤에서 리페치합니다.</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%eb%8b%a8%ea%b3%84-%ec%98%a4%ed%94%84%eb%9d%bc%ec%9d%b8-%ec%a4%91%ec%9d%98-%ec%93%b0%ea%b8%b0%eb%a5%bc-%ed%81%90%ec%97%90-%ec%8c%93%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>읽기는 쉬운 절반입니다. 재미있어지는 건 쓰기 쪽인데, 신호가 없는 곳에서 댓글을 달거나 주문을 넣거나 프로필을 고치면, 그 변경을 큐에 쌓아뒀다가 나중에 재실행해야 하죠.</p>
<p>React Query의 답은 <code>useMutation</code>과 <code>onMutate</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="p">{</span> <span class="nx">useMutation</span><span class="p">,</span> <span class="nx">useQueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">Comment</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">id</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">text</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">postId</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">createdAt</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">pending?</span>: <span class="kt">boolean</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="kd">function</span> <span class="nx">useAddComment</span><span class="p">(</span><span class="nx">postId</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="nx">useQueryClient</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="nx">useMutation</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">mutationFn</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">text</span>: <span class="kt">string</span><span class="p">)</span> <span class="o">=&gt;</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="sb">`/api/posts/</span><span class="si">${</span><span class="nx">postId</span><span class="si">}</span><span class="sb">/comments`</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">method</span><span class="o">:</span> <span class="s1">&#39;POST&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="s1">&#39;Content-Type&#39;</span><span class="o">:</span> <span class="s1">&#39;application/json&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="nx">body</span>: <span class="kt">JSON.stringify</span><span class="p">({</span> <span class="nx">text</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl">      <span class="p">})</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</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="c1">// 낙관적 업데이트 - 댓글을 즉시 표시
</span></span></span><span class="line"><span class="cl">    <span class="nx">onMutate</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">text</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">await</span> <span class="nx">queryClient</span><span class="p">.</span><span class="nx">cancelQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">]</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="kr">const</span> <span class="nx">previous</span> <span class="o">=</span> <span class="nx">queryClient</span><span class="p">.</span><span class="nx">getQueryData</span><span class="p">&lt;</span><span class="nt">Comment</span><span class="err">[]</span><span class="p">&gt;([</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="kr">const</span> <span class="nx">optimisticComment</span>: <span class="kt">Comment</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">id</span><span class="o">:</span> <span class="sb">`temp-</span><span class="si">${</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">text</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">postId</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">createdAt</span>: <span class="kt">new</span> <span class="nb">Date</span><span class="p">().</span><span class="nx">toISOString</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="nx">pending</span>: <span class="kt">true</span><span class="p">,</span> <span class="c1">// UI에서 &#34;전송 중...&#34; 표시
</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="nx">queryClient</span><span class="p">.</span><span class="nx">setQueryData</span><span class="p">&lt;</span><span class="nt">Comment</span><span class="err">[]</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">        <span class="p">(</span><span class="nx">old</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">[...(</span><span class="nx">old</span> <span class="o">??</span> <span class="p">[]),</span> <span class="nx">optimisticComment</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">return</span> <span class="p">{</span> <span class="nx">previous</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="c1">// 실패 시 롤백
</span></span></span><span class="line"><span class="cl">    <span class="nx">onError</span><span class="o">:</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">text</span><span class="p">,</span> <span class="nx">context</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nx">context</span><span class="o">?</span><span class="p">.</span><span class="nx">previous</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">queryClient</span><span class="p">.</span><span class="nx">setQueryData</span><span class="p">([</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">],</span> <span class="nx">context</span><span class="p">.</span><span class="nx">previous</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// 서버에서 실제 데이터를 가져오기 위해 리패치
</span></span></span><span class="line"><span class="cl">    <span class="nx">onSettled</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">queryClient</span><span class="p">.</span><span class="nx">invalidateQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">]</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>뮤테이션에 <code>networkMode: 'offlineFirst'</code>가 설정돼 있으면, 네트워크가 돌아올 때까지 <code>mutationFn</code>이 일시 정지됩니다. 낙관적 업데이트 덕에 댓글은 화면에 바로 뜨고, 실제 API 호출은 재연결 시점에 나갑니다.</p>
<p>순서대로 재실행해야 하는 보류 변경이 수십 건씩 쌓이는, 더 본격적인 큐가 필요하다면 뮤테이션 큐 자체도 영속화할 수 있습니다.</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="p">{</span> <span class="nx">MutationCache</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 보류 중인 뮤테이션을 AsyncStorage에 저장
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">mutationCache</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationCache</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">onError</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">error</span><span class="p">,</span> <span class="nx">variables</span><span class="p">,</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">mutation</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// 디버깅을 위해 실패한 뮤테이션 기록
</span></span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">pending</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;pending_mutations&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;[]&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nx">pending</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">      <span class="nx">key</span>: <span class="kt">mutation.options.mutationKey</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">variables</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">timestamp</span>: <span class="kt">Date.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">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;pending_mutations&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">pending</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span></span></span></code></pre></div></div>

<h3 class="relative group">5단계: 네트워크가 돌아오면 맞춰주기
    <div id="5단계-네트워크가-돌아오면-맞춰주기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#5%eb%8b%a8%ea%b3%84-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac%ea%b0%80-%eb%8f%8c%ec%95%84%ec%98%a4%eb%a9%b4-%eb%a7%9e%ec%b6%b0%ec%a3%bc%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>재연결 처리의 대부분은 React Query가 알아서 합니다. 멈춰 있던 뮤테이션은 재개되고, 낡은 쿼리는 리페치됩니다. 우리가 할 일은 React Query의 매니저들을 React Native 이벤트에 연결해주는 것뿐입니다.</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="nx">NetInfo</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-community/netinfo&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">onlineManager</span><span class="p">,</span> <span class="nx">focusManager</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">AppState</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;react-native&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// React Query에 네트워크 상태 변경을 알려주기
</span></span></span><span class="line"><span class="cl"><span class="nx">onlineManager</span><span class="p">.</span><span class="nx">setEventListener</span><span class="p">((</span><span class="nx">setOnline</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">NetInfo</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">((</span><span class="nx">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">setOnline</span><span class="p">(</span><span class="o">!!</span><span class="nx">state</span><span class="p">.</span><span class="nx">isConnected</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 앱이 포그라운드로 돌아올 때 리패치
</span></span></span><span class="line"><span class="cl"><span class="nx">focusManager</span><span class="p">.</span><span class="nx">setEventListener</span><span class="p">((</span><span class="nx">setFocused</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">AppState</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">status</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">setFocused</span><span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s1">&#39;active&#39;</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 class="o">=&gt;</span> <span class="nx">subscription</span><span class="p">.</span><span class="nx">remove</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>focusManager</code>가 리페치를 돌려 최신 데이터를 보여주고, 터널을 빠져나와 신호가 잡히면 <code>onlineManager</code>가 멈춰 있던 것들을 다시 굴립니다.</p>

<h3 class="relative group">6단계: UI로 알려주기
    <div id="6단계-ui로-알려주기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#6%eb%8b%a8%ea%b3%84-ui%eb%a1%9c-%ec%95%8c%eb%a0%a4%ec%a3%bc%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>뭘 하든, 네트워크 에러를 조용히 삼키는 것만은 하지 마세요. 지금 오프라인이라는 것, 어떤 변경이 보류 중인지 사용자에게 보여줘야 합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useNetworkStore</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;./stores/network&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">OfflineBanner() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">isOnline</span> <span class="o">=</span> <span class="nx">useNetworkStore</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">isOnline</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isOnline</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">
</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="p">&lt;</span><span class="nt">View</span> <span class="na">style</span><span class="o">=</span><span class="p">{</span><span class="nx">styles</span><span class="p">.</span><span class="nx">banner</span><span class="p">}&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">Text</span><span class="p">&gt;</span><span class="err">오프라인</span> <span class="err">상태입니다</span><span class="p">.</span> <span class="err">다시</span> <span class="err">연결되면</span> <span class="err">변경사항이</span> <span class="err">동기화됩니다</span><span class="p">.&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">View</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">CommentItem</span><span class="p">({</span> <span class="nx">comment</span> <span class="p">}</span><span class="o">:</span> <span class="p">{</span> <span class="nx">comment</span>: <span class="kt">Comment</span> <span class="p">})</span> <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="p">&lt;</span><span class="nt">View</span> <span class="na">style</span><span class="o">=</span><span class="p">{[</span><span class="nx">styles</span><span class="p">.</span><span class="nx">comment</span><span class="p">,</span> <span class="nx">comment</span><span class="p">.</span><span class="nx">pending</span> <span class="o">&amp;&amp;</span> <span class="nx">styles</span><span class="p">.</span><span class="nx">pending</span><span class="p">]}&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">Text</span><span class="p">&gt;{</span><span class="nx">comment</span><span class="p">.</span><span class="nx">text</span><span class="p">}&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="nx">comment</span><span class="p">.</span><span class="nx">pending</span> <span class="o">&amp;&amp;</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">&lt;</span><span class="nt">Text</span> <span class="na">style</span><span class="o">=</span><span class="p">{</span><span class="nx">styles</span><span class="p">.</span><span class="nx">pendingLabel</span><span class="p">}&gt;</span><span class="err">전송</span> <span class="err">중</span><span class="p">...&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)}</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">View</span><span class="p">&gt;</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>

<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%84%ec%b2%b4-%ea%b7%b8%eb%a6%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<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">│                   Components                     │
</span></span><span class="line"><span class="cl">│  useQuery() for reads    useMutation() for writes│
</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">     │ React Query │        │ React Query │
</span></span><span class="line"><span class="cl">     │   Cache     │        │  Mutation   │
</span></span><span class="line"><span class="cl">     │ (in-memory) │        │   Queue     │
</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">     │     AsyncStorage Persister         │
</span></span><span class="line"><span class="cl">     │  (survives app restart)            │
</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">     │   Zustand   │        │   Network  │
</span></span><span class="line"><span class="cl">     │ (isOnline,  │◄───────│   NetInfo  │
</span></span><span class="line"><span class="cl">     │  UI state)  │        │            │
</span></span><span class="line"><span class="cl">     └────────────┘        └────────────┘</span></span></code></pre></div></div>
<table>
	<thead>
			<tr>
					<th>레이어</th>
					<th>도구</th>
					<th>역할</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>네트워크 감지</td>
					<td>Zustand + NetInfo</td>
					<td>온라인/오프라인 추적, UI 배너 표시</td>
			</tr>
			<tr>
					<td>데이터 페칭</td>
					<td>React Query</td>
					<td>온라인이면 가져오고, 오프라인이면 캐시를 제공</td>
			</tr>
			<tr>
					<td>캐시 영속화</td>
					<td>React Query + AsyncStorage</td>
					<td>앱 재시작 시 캐시 복원</td>
			</tr>
			<tr>
					<td>오프라인 쓰기</td>
					<td>React Query 뮤테이션</td>
					<td>뮤테이션을 큐에 쌓았다가 재연결 시 재실행</td>
			</tr>
			<tr>
					<td>낙관적 UI</td>
					<td>React Query <code>onMutate</code></td>
					<td>변경을 즉시 보여주고, 실패하면 롤백</td>
			</tr>
			<tr>
					<td>앱 포커스 동기화</td>
					<td>React Query <code>focusManager</code></td>
					<td>앱이 포그라운드로 돌아오면 낡은 데이터를 리페치</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">진짜 동기화 엔진이 필요해지는 순간
    <div id="진짜-동기화-엔진이-필요해지는-순간" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a7%84%ec%a7%9c-%eb%8f%99%ea%b8%b0%ed%99%94-%ec%97%94%ec%a7%84%ec%9d%b4-%ed%95%84%ec%9a%94%ed%95%b4%ec%a7%80%eb%8a%94-%ec%88%9c%ea%b0%84" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>지금까지의 구성은 서버가 원본이고 오프라인은 일시적이라는 전제 위에 서 있습니다. 충돌 해결까지 포함된 진짜 오프라인 우선(두 기기가 같은 문서를 오프라인에서 고치는 메모 앱 같은 세계)이 필요하다면, 이 스택으로는 부족하고 전용 동기화 엔진이 필요합니다. WatermelonDB는 React Native용으로 만들어져 SQLite 위에서 돌고, 충돌 해결용 동기화 프로토콜을 갖췄습니다. Realm은 Atlas Device Sync와 묶으면 MongoDB Atlas를 통한 자동 충돌 해결이 되는 완전한 오프라인 우선 데이터베이스가 되고요. PowerSync는 기존 Postgres 백엔드와 붙는 SQLite 기반 동기화 레이어입니다. 완전한 제어가 필요하면 Expo SQLite에 직접 만든 동기화 로직이라는 길도 있습니다.</p>
<p>Zustand + React Query + AsyncStorage로 모바일 앱의 80% 정도는 커버됩니다. 나머지 20%, 그러니까 협업 편집이나 멀티 디바이스 동기화 같은 오프라인 중심 워크플로우에는 전용 동기화 데이터베이스가 필요합니다.</p>

<h2 class="relative group">어디에 둘지 헷갈릴 때
    <div id="어디에-둘지-헷갈릴-때" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%96%b4%eb%94%94%ec%97%90-%eb%91%98%ec%a7%80-%ed%97%b7%ea%b0%88%eb%a6%b4-%eb%95%8c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>저는 애매하면 이 질문들을 순서대로 던져봅니다.</p>
<table>
	<thead>
			<tr>
					<th>질문</th>
					<th>&lsquo;예&rsquo;라면 →</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>서버/API에서 오는 데이터인가?</td>
					<td>React Query</td>
			</tr>
			<tr>
					<td>여러 컴포넌트가 공유하는 클라이언트 전용 UI 상태인가?</td>
					<td>Zustand</td>
			</tr>
			<tr>
					<td>앱을 재시작해도 남아 있어야 하나?</td>
					<td>AsyncStorage (+ 필요하면 Zustand persist)</td>
			</tr>
			<tr>
					<td>민감한 데이터인가(토큰, 비밀번호)?</td>
					<td>expo-secure-store / react-native-keychain</td>
			</tr>
			<tr>
					<td>쿼리가 필요한 대용량 구조화 데이터인가?</td>
					<td>SQLite / WatermelonDB / Realm</td>
			</tr>
			<tr>
					<td>컴포넌트 하나에서만 쓰는 단순한 폼 입력인가?</td>
					<td>useState</td>
			</tr>
	</tbody>
</table>

<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%9e%90%ec%a3%bc-%eb%82%98%ec%98%a4%eb%8a%94-%ed%8c%a8%ed%84%b4" aria-label="앵커">#</a>
    </span>
    
</h3>
<table>
	<thead>
			<tr>
					<th>상태</th>
					<th>도구</th>
					<th>이유</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>API 응답 데이터</td>
					<td>React Query</td>
					<td>캐싱, 중복 제거, 리페치, 로딩 상태</td>
			</tr>
			<tr>
					<td>선택된 탭 / 활성 필터</td>
					<td>Zustand</td>
					<td>클라이언트 전용이고, 여러 컴포넌트가 관심을 가짐</td>
			</tr>
			<tr>
					<td>인증 토큰</td>
					<td>AsyncStorage + Zustand</td>
					<td>재시작 후에도 유지되고, 메모리에서 빠르게 접근</td>
			</tr>
			<tr>
					<td>테마 설정</td>
					<td>Zustand + persist 미들웨어</td>
					<td>재시작 후에도 남아야 하는 클라이언트 상태</td>
			</tr>
			<tr>
					<td>장바구니</td>
					<td>Zustand + persist 미들웨어</td>
					<td>복잡한 클라이언트 로직, 재시작 후에도 유지</td>
			</tr>
			<tr>
					<td>폼 입력</td>
					<td>useState</td>
					<td>컴포넌트 하나에서만 쓰고 공유할 필요 없음</td>
			</tr>
			<tr>
					<td>API에서 받은 사용자 프로필</td>
					<td>React Query</td>
					<td>서버 상태이고, 낡았을 수 있음</td>
			</tr>
			<tr>
					<td>온보딩 완료 플래그</td>
					<td>AsyncStorage</td>
					<td>유지되기만 하면 되는 불리언 하나</td>
			</tr>
			<tr>
					<td>오프라인 캐시 피드</td>
					<td>React Query + AsyncStorage</td>
					<td>서버에서 가져오고, 안 되면 캐시로 폴백</td>
			</tr>
	</tbody>
</table>

<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%8c%eb%9e%ab%ed%8f%bc%eb%b3%84-%ec%84%a4%ec%b9%98" aria-label="앵커">#</a>
    </span>
    
</h2>

<h3 class="relative group">React Native (Android + iOS)
    <div id="react-native-android--ios" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#react-native-android--ios" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>셋 다 한 번에 설치합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install zustand @tanstack/react-query @react-native-async-storage/async-storage</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">npx expo install expo-secure-store
</span></span><span class="line"><span class="cl"><span class="c1"># or</span>
</span></span><span class="line"><span class="cl">npm install react-native-keychain</span></span></code></pre></div></div>

<h3 class="relative group">Expo
    <div id="expo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expo" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>AsyncStorage는 Expo에서 그대로 동작합니다. 네이티브 링킹도 필요 없습니다.</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">npx expo install @react-native-async-storage/async-storage</span></span></code></pre></div></div>

<h3 class="relative group">웹 전용 React
    <div id="웹-전용-react" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9b%b9-%ec%a0%84%ec%9a%a9-react" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>AsyncStorage는 건너뛰고 <code>localStorage</code>나 <code>IndexedDB</code>를 직접 쓰면 됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install zustand @tanstack/react-query
</span></span><span class="line"><span class="cl"><span class="c1"># Optional for IndexedDB</span>
</span></span><span class="line"><span class="cl">npm install idb-keyval</span></span></code></pre></div></div>
<p>Zustand의 persist 미들웨어는 웹에서 기본으로 <code>localStorage</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="nx">persist</span><span class="p">(</span><span class="nx">storeConfig</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;my-store&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// 웹에서 localStorage가 기본값 - 추가 설정 필요 없음
</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="#%ec%9e%90%ea%be%b8-%eb%b3%b4%ea%b2%8c-%eb%90%98%eb%8a%94-%ec%8b%a4%ec%88%98%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>API 데이터를 Zustand에 넣는 것. 액션 안에 <code>setUsers(apiResponse.users)</code>가 있다면 일단 손을 멈추세요. 그건 React Query의 일이고, 그 길 끝에는 어설픈 캐시 무효화 재발명이 기다리고 있습니다.</p>
<p>클라이언트 상태에 React Query를 쓰는 것. <code>queryFn</code>이 네트워크 요청을 안 한다면 도구를 잘못 골랐습니다.</p>
<p>AsyncStorage를 데이터베이스처럼 쓰는 것. 10,000건짜리 배열을 키-값 저장소에 직렬화하고 있다면, 그냥 데이터베이스를 쓰는 게 맞습니다.</p>
<p>토큰을 평문으로 두는 것. AsyncStorage는 보안 저장소가 아닙니다. 인증 토큰, API 키, 자격 증명은 <code>expo-secure-store</code>나 플랫폼 키체인으로 보내세요.</p>
<p>영속화를 손으로 만드는 것. 마운트 때 AsyncStorage를 읽고 상태가 바뀔 때마다 쓰는 코드를 짜고 있다면, Zustand의 persist 미들웨어가 하이드레이션과 직렬화까지 이미 다 해줍니다. 코드는 줄고 버그도 줄어들죠.</p>
<hr>
<p>서버 데이터는 React Query로. 클라이언트 상태는 Zustand에. 재시작을 버텨야 하는 건 AsyncStorage에, 민감한 건 보안 저장소에.</p>
<p>제가 겪어본 최악의 아키텍처들은 하나같이 거대한 스토어 하나로 모든 게 흘러들었습니다. 좋은 아키텍처에는 서버 상태, 클라이언트 상태, 영속화할 것 사이에 또렷한 선이 있었고요. 그 선을 일찍 그어두면 그다음 일이 전부 쉬워집니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-05-05-zustand-vs-react-query/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>
    <item>
      <title>React Native 온디바이스 LLM: llama.rn과 llama.cpp로 Qwen을 오프라인에서 돌리기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-01-30-on-device-ai/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-01-30-on-device-ai/</guid>
      <pubDate>Fri, 30 Jan 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>AI</category>
      <category>LLM</category>
      <category>llama.cpp</category>
      <category>Qwen</category>
      <category>On-Device AI</category>
      <category>Privacy</category>
      <category>Mobile Development</category>
      <description>React Native·Expo 앱에서 소형 LLM을 폰 안에서만 돌리는 방법입니다. llama.rn, Q4_K_M으로 양자화한 Qwen GGUF 모델, 스트리밍, 소형 모델도 감당하는 함수 호출, 그리고 iPhone과 Galaxy S23에서 잰 실측치까지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>제가 만들고 있는 견적 앱의 AI 어시스턴트는 전부 휴대폰 안에서 돌아갑니다. 데이터가 기기 밖으로 나가는 일이 없죠. 사용자는 자연어로 작업을 만들고, 고객을 검색하고, 프로젝트를 관리할 수 있는데, 비행기 모드에서도 그대로 됩니다.</p>
<p>이 글에서는 <strong>llama.rn</strong>(llama.cpp의 React Native 바인딩)과 <strong>Qwen 1.7B</strong>로 이걸 어떻게 만들었는지 정리했습니다. 이 크기의 모델치고는 기대 이상으로 일을 잘합니다.</p>
<p>요약하면 이렇습니다. llama.rn을 설치하고, 앱을 처음 열 때 4비트 GGUF 모델(1 GB 정도)을 내려받고, <code>initLlama</code>로 로드한 뒤 응답을 스트리밍으로 받으면 됩니다. iPhone 14 Pro에서 네트워크 없이 초당 15토큰 정도 나옵니다. 이 글의 나머지는 그걸 쓸모 있게 만들기까지의 과정입니다. 모델 고르기, 함수 호출을 안정적으로 끌어내기, 메모리 관리하기.</p>
<blockquote><p><strong>📝 업데이트:</strong> 지금은 처음의 Qwen3-1.7B 대신 <a href="https://huggingface.co/unsloth/Qwen3.5-2B-GGUF"  target="_blank" rel="noreferrer">Qwen3.5-2B-GGUF</a>를 쓰고 있습니다. 또 <code>/no_think</code> 메시지 꼼수를 버리고 <code>chat_template_kwargs</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">chat_response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</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">model</span><span class="o">=</span><span class="s2">&#34;Qwen/Qwen3.5-27B&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="n">messages</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">32768</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">temperature</span><span class="o">=</span><span class="mf">0.7</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">top_p</span><span class="o">=</span><span class="mf">0.8</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">presence_penalty</span><span class="o">=</span><span class="mf">1.5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">extra_body</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;top_k&#34;</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;chat_template_kwargs&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;enable_thinking&#34;</span><span class="p">:</span> <span class="kc">False</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
<p>프롬프트에 꼼수를 심는 것보다 훨씬 깔끔하게 제어됩니다.</p>
</blockquote>
<h2 class="relative group">클라우드 AI를 쓰기 싫었던 이유
    <div id="클라우드-ai를-쓰기-싫었던-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%b4%eb%9d%bc%ec%9a%b0%eb%93%9c-ai%eb%a5%bc-%ec%93%b0%ea%b8%b0-%ec%8b%ab%ec%97%88%eb%8d%98-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>모바일 앱의 AI 기능은 대개 공식이 같습니다. 사용자가 메시지를 입력하면, 앱이 클라우드 모델 업체로 보내고, 응답이 돌아오고, 청구서가 쌓입니다.</p>
<p>시공 업체가 업무용으로 쓰는 앱에서 이 공식을 따르면 곤란한 점이 한둘이 아닙니다. 고객의 이름, 주소, 전화번호가 제3자에게 넘어가고, 프로젝트 내용과 견적, 메모가 남의 서버에 쌓입니다. GDPR이니 데이터 레지던시니 하는, 엮이고 싶지 않은 문제도 따라오고요. 게다가 API 비용은 사용자가 늘수록 커집니다.</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%9d%b4%ec%a0%9c%ec%95%bc-%ea%b0%80%eb%8a%a5%ed%95%b4%ec%a7%84-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>1년 전이었으면 시도하지 않았을 텐데, 비슷한 시기에 세 가지가 바뀌었습니다. 우선 양자화 모델이 작아졌습니다. Q4_K_M 양자화를 거친 Qwen 1.7B는 1.1 GB로 웬만한 게임보다 작고, 한 번 받으면 앱 저장소에 그대로 있습니다. 다음으로 모바일 GPU가 빨라졌습니다. llama.cpp는 iOS에서는 Metal, 최근 Qualcomm GPU를 단 Android에서는 OpenCL을 쓰는데, iPhone 14 Pro 기준 초당 15토큰 정도 나옵니다. 실시간 스트리밍에는 충분하죠. 마지막으로 소형 모델 자체가 쓸 만해졌습니다. Qwen 1.7B는 구조화된 지시를 따르고, JSON을 파싱하고, 여러 단계의 추론도 이어갑니다. 비즈니스 로직에 필요한 게 딱 이 정도입니다. 시를 쓰라는 게 아니니까요.</p>

<h2 class="relative group">모델 고르기
    <div id="모델-고르기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%aa%a8%eb%8d%b8-%ea%b3%a0%eb%a5%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Qwen3-1.7B로 정착하기 전에 소형 모델 네 개를 테스트했습니다.</p>
<p><strong>TinyLlama 1.1B</strong>(637 MB)는 제일 빨랐지만 고객 ID를 지어내거나 필수 필드를 빼먹는 등 구조화된 출력이 도무지 안정되지 않았습니다. <strong>Phi-3-mini</strong>(1.8 GB)는 추론은 괜찮은데 말이 너무 많아요. 20단어면 될 답이 200단어짜리 작문으로 돌아옵니다. <strong>Gemma-2B</strong>(1.2 GB)는 분류에는 빠르고 정확했지만 함수 호출이 약해서, 제 도구 시스템에 필요한 <code>&lt;action&gt;</code> 태그를 안정적으로 내놓지 못했습니다. <strong>Qwen3-1.7B</strong>(1.1 GB)가 딱 적당했습니다. 구조화된 출력이 믿을 만하고, 지시를 정확히 따르고, <code>&lt;think&gt;</code> 태그로 사고 연쇄(chain-of-thought)까지 지원합니다.</p>
<p>Q4_K_M은 llama.cpp의 &ldquo;k-quant&rdquo; 양자화 포맷 중 하나입니다. 가중치를 블록 단위로 묶어 4비트 값으로 저장하되 블록마다 자체 스케일을 두고, M 조합에서는 민감한 텐서 몇 개를 6비트로 남겨 둡니다. 그래서 파일은 16비트 가중치보다 70% 정도 작아지고, 품질 손실은 크지 않습니다.</p>

<h2 class="relative group">llama.rn 설정
    <div id="llamarn-설정" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#llamarn-%ec%84%a4%ec%a0%95" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>llama.rn은 llama.cpp를 React Native용으로 감싼 라이브러리인데, 설치 자체는 쉬운 축에 듭니다:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install llama.rn
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ios <span class="o">&amp;&amp;</span> pod install</span></span></code></pre></div></div>
<p>Expo라면, llama.rn은 네이티브 모듈이라 Expo Go에서는 돌아가지 않습니다. config plugin을 추가하고 개발 빌드를 쓰세요:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="c1">// app.config.js
</span></span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expo</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">plugins</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;llama.rn&#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></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npx expo install llama.rn
</span></span><span class="line"><span class="cl">npx expo run:ios</span></span></code></pre></div></div>
<p>프로덕션 빌드에서는 이 플러그인이 llama.rn이 큰 모델용으로 권장하는 iOS entitlement 두 개(Extended Virtual Addressing과 Increased Memory Limit)도 함께 넣어 줍니다.</p>
<p>모델 본체는 첫 사용 시에 내려받습니다:</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">const</span> <span class="nx">MODEL_URL</span> <span class="o">=</span> <span class="s2">&#34;https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MODEL_PATH</span> <span class="o">=</span> <span class="nx">FileSystem</span><span class="p">.</span><span class="nx">documentDirectory</span> <span class="o">+</span> <span class="s2">&#34;llama-models/Qwen3-1.7B-Q4_K_M.gguf&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">downloadModel</span> <span class="o">=</span> <span class="kr">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">downloadResumable</span> <span class="o">=</span> <span class="nx">FileSystem</span><span class="p">.</span><span class="nx">createDownloadResumable</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nx">MODEL_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">MODEL_PATH</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 class="nx">progress</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="kr">const</span> <span class="nx">pct</span> <span class="o">=</span> <span class="nx">progress</span><span class="p">.</span><span class="nx">totalBytesWritten</span> <span class="o">/</span> <span class="nx">progress</span><span class="p">.</span><span class="nx">totalBytesExpectedToWrite</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">setDownloadProgress</span><span class="p">(</span><span class="nx">pct</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">await</span> <span class="nx">downloadResumable</span><span class="p">.</span><span class="nx">downloadAsync</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div></div>
<p>WiFi에서는 2<del>3분, LTE에서는 5</del>8분쯤 걸립니다. 다 받았으면 GPU 가속을 켜고 로드합니다:</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">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">initLlama</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">model</span>: <span class="kt">MODEL_PATH</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_ctx</span>: <span class="kt">8192</span><span class="p">,</span>      <span class="c1">// 8K context window
</span></span></span><span class="line"><span class="cl">  <span class="nx">n_gpu_layers</span>: <span class="kt">99</span><span class="p">,</span> <span class="c1">// Use GPU for all layers
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p><code>n_gpu_layers: 99</code>는 빼먹지 마세요. 연산을 CPU 대신 Metal이나 OpenCL로 넘기는 것만으로 5배쯤 빨라집니다.</p>

<h2 class="relative group">스트리밍 추론
    <div id="스트리밍-추론" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8a%a4%ed%8a%b8%eb%a6%ac%eb%b0%8d-%ec%b6%94%eb%a1%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>10초 동안 스피너만 쳐다보고 싶은 사람은 없으니, 응답은 토큰 단위로 스트리밍합니다:</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="kd">let</span> <span class="nx">fullResponse</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">llamaContext</span><span class="p">.</span><span class="nx">completion</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="nx">messages</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;system&#34;</span><span class="p">,</span> <span class="nx">content</span>: <span class="kt">systemPrompt</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span><span class="o">:</span> <span class="s2">&#34;Create a job for John Smith&#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="nx">n_predict</span>: <span class="kt">512</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">temperature</span>: <span class="kt">0.7</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">top_p</span>: <span class="kt">0.8</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 class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// Called for each token
</span></span></span><span class="line"><span class="cl">    <span class="nx">fullResponse</span> <span class="o">+=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">setStreamingText</span><span class="p">(</span><span class="nx">fullResponse</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">);</span></span></span></code></pre></div></div>
<p>요즘 폰이라면 첫 토큰까지 200ms 정도(프롬프트 처리 시간입니다), 이후로는 토큰당 50~80ms입니다. 네트워크를 거치는 API와 비교하면 체감상 즉각적이죠.</p>

<h2 class="relative group">소형 모델도 해낼 수 있는 함수 호출
    <div id="소형-모델도-해낼-수-있는-함수-호출" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%86%8c%ed%98%95-%eb%aa%a8%eb%8d%b8%eb%8f%84-%ed%95%b4%eb%82%bc-%ec%88%98-%ec%9e%88%eb%8a%94-%ed%95%a8%ec%88%98-%ed%98%b8%ec%b6%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>대형 클라우드 모델이 쓰는 함수 호출 방식은 JSON 스키마에 기대는데, 소형 모델은 여기서 넘어집니다. JSON이 깨지고, 필수 필드가 빠지고요. 그래서 더 단순한 XML 기반 프로토콜을 씁니다:</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">const</span> <span class="nx">systemPrompt</span> <span class="o">=</span> <span class="sb">`You are an AI assistant. When you need to take an action, output:
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;action&gt;{&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;Smith&#34;}&lt;/action&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Available actions:
</span></span></span><span class="line"><span class="cl"><span class="sb">- search_customers: {&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;John&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">- create_job: {&#34;type&#34;:&#34;create_job&#34;,&#34;customer_id&#34;:5,&#34;alias&#34;:&#34;Living Room&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">- update_job: {&#34;type&#34;:&#34;update_job&#34;,&#34;job_id&#34;:&#34;J-0042&#34;,&#34;status&#34;:&#34;quoting&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Rules:
</span></span></span><span class="line"><span class="cl"><span class="sb">1. ONE action tag per response, at the very end
</span></span></span><span class="line"><span class="cl"><span class="sb">2. When searching/creating → end with &lt;action&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">3. When just chatting → no action tag
</span></span></span><span class="line"><span class="cl"><span class="sb">`</span><span class="p">;</span></span></span></code></pre></div></div>
<p>XML 태그를 고른 이유는 사실 소박합니다. <code>&lt;action&gt;</code>과 <code>&lt;/action&gt;</code>은 시작과 끝이 헷갈릴 여지가 없고, 파싱은 정규식 하나면 끝나고, 프롬프트 안의 예시가 그대로 포맷 문서 역할을 하고, 모델이 태그 앞뒤에 잡소리를 붙여도 잘 동작합니다.</p>
<p>파싱 코드는 보이는 그대로 단순합니다:</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="kd">function</span> <span class="nx">parseAction</span><span class="p">(</span><span class="nx">text</span>: <span class="kt">string</span><span class="p">)</span><span class="o">:</span> <span class="nx">Action</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">match</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="sr">/&lt;action&gt;([\s\S]*?)&lt;\/action&gt;/</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">match</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">match</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nx">trim</span><span class="p">());</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>action이 나오면 실행하고, 결과를 대화에 다시 넣어줍니다:</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">const</span> <span class="nx">action</span> <span class="o">=</span> <span class="nx">parseAction</span><span class="p">(</span><span class="nx">modelResponse</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">action</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">executeAction</span><span class="p">(</span><span class="nx">action</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1">// Inject result as a system message
</span></span></span><span class="line"><span class="cl">  <span class="nx">conversationHistory</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">content</span><span class="o">:</span> <span class="sb">`[TOOL_RESULT] </span><span class="si">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">message</span><span class="si">}</span><span class="err">\</span><span class="sb">n</span><span class="err">\</span><span class="sb">n→ NEXT: Tell the user what happened.`</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">// Continue generation
</span></span></span><span class="line"><span class="cl">  <span class="k">await</span> <span class="nx">generateNextResponse</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>
<p><strong>사용자</strong>: &ldquo;Create a job for Smith&rdquo;</p>
<ol>
<li><strong>모델</strong>: <code>&lt;action&gt;{&quot;type&quot;:&quot;search_customers&quot;,&quot;query&quot;:&quot;Smith&quot;}&lt;/action&gt;</code></li>
<li><strong>시스템</strong>: <code>[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)</code></li>
<li><strong>모델</strong>: &ldquo;Smith가 두 명 있네요. Jane과 Bob 중 누구인가요?&rdquo;</li>
<li><strong>사용자</strong>: &ldquo;Jane&rdquo;</li>
<li><strong>모델</strong>: <code>&lt;action&gt;{&quot;type&quot;:&quot;create_job&quot;,&quot;customer_id&quot;:42}&lt;/action&gt;</code></li>
<li><strong>시스템</strong>: <code>[TOOL_RESULT] Job J-0073 created</code></li>
<li><strong>모델</strong>: &ldquo;완료했습니다! Jane Smith의 작업 J-0073을 생성했습니다.&rdquo;</li>
</ol>

<h2 class="relative group">&lt;think&gt; 태그로 사고 연쇄 끌어내기
    <div id="think-태그로-사고-연쇄-끌어내기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#think-%ed%83%9c%ea%b7%b8%eb%a1%9c-%ec%82%ac%ea%b3%a0-%ec%97%b0%ec%87%84-%eb%81%8c%ec%96%b4%eb%82%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>소형 모델은 추론을 소리 내서 하게 만들면 눈에 띄게 안정됩니다. Qwen에는 씽킹 모드가 기본으로 들어 있습니다:</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">const</span> <span class="nx">systemPrompt</span> <span class="o">=</span> <span class="sb">`Before each response, wrap your reasoning in &lt;think&gt; tags:
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;think&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">INTENT: what does the user want?
</span></span></span><span class="line"><span class="cl"><span class="sb">HAVE: what data do I already have?
</span></span></span><span class="line"><span class="cl"><span class="sb">NEED: what&#39;s still missing?
</span></span></span><span class="line"><span class="cl"><span class="sb">DECISION: call tool | ask user | just respond
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;/think&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Then output your actual response.`</span><span class="p">;</span></span></span></code></pre></div></div>
<p>모델 내부에서는 이런 식으로 굴러갑니다:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&lt;think&gt;
</span></span><span class="line"><span class="cl">INTENT: create a job
</span></span><span class="line"><span class="cl">HAVE: customer query &#34;Smith&#34;
</span></span><span class="line"><span class="cl">NEED: exact customer_id
</span></span><span class="line"><span class="cl">DECISION: search first
</span></span><span class="line"><span class="cl">&lt;/think&gt;
</span></span><span class="line"><span class="cl">&lt;action&gt;{&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;Smith&#34;}&lt;/action&gt;</span></span></code></pre></div></div>
<p><code>&lt;think&gt;</code> 태그는 UI에 내보내기 전에 걷어내지만, 개발 중에는 디버깅용으로 그대로 보이게 둡니다. 효과가 정말 큽니다. 모델이 액션을 확정하기 전에 스스로 논리를 정리하거든요.</p>

<h2 class="relative group">긴 대화를 8K 안에 담기
    <div id="긴-대화를-8k-안에-담기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b8%b4-%eb%8c%80%ed%99%94%eb%a5%bc-8k-%ec%95%88%ec%97%90-%eb%8b%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>8K 컨텍스트 윈도우는 도구 결과가 쌓이기 시작하면 생각보다 빨리 찹니다. 제 경우 30턴쯤에서 한계에 부딪혔습니다.</p>
<p>해결책은 대화 압축입니다. 모델에게 스스로를 요약하게 시킵니다:</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">const</span> <span class="nx">compactPrompt</span> <span class="o">=</span> <span class="sb">`Summarize this conversation in under 200 words.
</span></span></span><span class="line"><span class="cl"><span class="sb">Include: user&#39;s goal, customers/jobs created (with IDs), and any unfinished tasks.
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="si">${</span><span class="nx">conversationHistory</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">m</span> <span class="o">=&gt;</span> <span class="sb">`</span><span class="si">${</span><span class="nx">m</span><span class="p">.</span><span class="nx">role</span><span class="si">}</span><span class="sb">: </span><span class="si">${</span><span class="nx">m</span><span class="p">.</span><span class="nx">content</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="s1">&#39;\n\n&#39;</span><span class="p">)</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">summary</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">chat</span><span class="p">([{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span>: <span class="kt">compactPrompt</span> <span class="p">}]);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Replace history with summary
</span></span></span><span class="line"><span class="cl"><span class="nx">conversationHistory</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span><span class="o">:</span> <span class="sb">`[CONTEXT SUMMARY]</span><span class="err">\</span><span class="sb">n</span><span class="si">${</span><span class="nx">summary</span><span class="si">}</span><span class="err">\</span><span class="sb">n[END SUMMARY]`</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">];</span></span></span></code></pre></div></div>
<p>메시지 50개짜리 대화가 150토큰 정도로 줄어들고, 모델은 만들어 둔 작업이나 고객 ID를 잊지 않고 이어갑니다.</p>

<h2 class="relative group">세션 저장
    <div id="세션-저장" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%84%b8%ec%85%98-%ec%a0%80%ec%9e%a5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>채팅 세션은 앱 백엔드에도 저장합니다. 따라 해 볼 만한 건 어시스턴트 메시지를 두 벌로 저장하는 겁니다. 화면에 보여 줄 정리된 버전 하나, <code>&lt;think&gt;</code>와 <code>&lt;action&gt;</code> 태그를 그대로 남긴 원본 하나죠. 이렇게 해 두면 채팅 히스토리는 UI에서 깔끔하게 재생되고, 세션을 다시 열 때는 액션까지 포함한 전체 컨텍스트가 복원됩니다. 사용자는 여느 채팅 앱처럼 히스토리 드롭다운에서 대화를 오갈 수 있습니다.</p>

<h2 class="relative group">프로덕션 벤치마크
    <div id="프로덕션-벤치마크" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98-%eb%b2%a4%ec%b9%98%eb%a7%88%ed%81%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>iPhone 14 Pro</strong> (A16 Bionic, 6GB RAM):</p>
<ul>
<li>모델 로드: 약 3초</li>
<li>첫 번째 토큰: 180-220ms</li>
<li>스트리밍: 14-16 tokens/초</li>
<li>메모리: 약 1.8 GB</li>
</ul>
<p><strong>Samsung Galaxy S23</strong> (Snapdragon 8 Gen 2, 8GB RAM):</p>
<ul>
<li>모델 로드: 약 4초</li>
<li>첫 번째 토큰: 250-300ms</li>
<li>스트리밍: 10-12 tokens/초</li>
<li>메모리: 약 2.1 GB</li>
</ul>
<p><strong>iPhone 11</strong> (A13, 4GB RAM):</p>
<ul>
<li>모델 로드: 약 6초</li>
<li>첫 번째 토큰: 400-500ms</li>
<li>스트리밍: 6-8 tokens/초</li>
<li>메모리: 약 2.2 GB (저메모리에서 간헐적 크래시)</li>
</ul>
<p>제 기준으로는 쾌적하게 쓰려면 RAM 6GB 이상. 4GB에서도 돌긴 하지만 앱이 백그라운드로 갈 때 모델을 내려야 할 겁니다.</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%a9%94%eb%aa%a8%eb%a6%ac-%ea%b4%80%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>메모리를 움켜쥐고 있는 앱은 iOS가 가차 없이 죽이기 때문에, 포그라운드를 벗어나면 모델을 해제합니다:</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="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">AppState</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;change&#34;</span><span class="p">,</span> <span class="p">(</span><span class="nx">nextAppState</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">nextAppState</span> <span class="o">===</span> <span class="s2">&#34;background&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">llamaContext</span><span class="o">?</span><span class="p">.</span><span class="nx">release</span><span class="p">();</span> <span class="c1">// Free ~2 GB
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">nextAppState</span> <span class="o">===</span> <span class="s2">&#34;active&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">loadModel</span><span class="p">();</span> <span class="c1">// Reload when foregrounded
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">subscription</span><span class="p">.</span><span class="nx">remove</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">},</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%b0%b0%ed%84%b0%eb%a6%ac-%ec%98%81%ed%96%a5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>온디바이스 추론이 전력 면에서 공짜는 아닙니다. 실측 결과는 이렇습니다:</p>
<table>
	<thead>
			<tr>
					<th>사용 패턴</th>
					<th>추가 배터리 소모</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>가벼운 사용 (하루 5-10개 쿼리)</td>
					<td>하루 &lt;1%</td>
			</tr>
			<tr>
					<td>보통 사용 (하루 20-30개 쿼리)</td>
					<td>하루 약 3-4%</td>
			</tr>
			<tr>
					<td>많은 사용 (하루 50개+ 쿼리)</td>
					<td>하루 약 6-8%</td>
			</tr>
	</tbody>
</table>
<p>GPU 추론이 CPU보다 전력은 더 먹지만 훨씬 빨리 끝납니다. 사용자들은 확실히 즉각적인 응답을 선호해서, GPU를 그대로 쓰고 있습니다.</p>

<h2 class="relative group">두 달 운영, 베타 사용자 약 200명
    <div id="두-달-운영-베타-사용자-약-200명" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%91%90-%eb%8b%ac-%ec%9a%b4%ec%98%81-%eb%b2%a0%ed%83%80-%ec%82%ac%ec%9a%a9%ec%9e%90-%ec%95%bd-200%eb%aa%85" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>쿼리 분포는 조금 의외였습니다:</p>
<ol>
<li>&ldquo;Create a job for [customer name]&rdquo; - 쿼리의 68%</li>
<li>&ldquo;Find jobs for [customer]&rdquo; - 15%</li>
<li>&ldquo;Update job [ID] to [status]&rdquo; - 9%</li>
<li>앱에 대한 일반적인 질문 - 8%</li>
</ol>
<p>쿼리의 91%는 첫 시도에 성공합니다. 실패는 사용자가 이름 철자를 틀려서 고객을 못 찾는 경우 4%, 아주 긴 대화에서 컨텍스트가 넘치는 경우 3%, 모델이 고객 ID를 지어내는 경우 2%로 나뉩니다.</p>
<p>마지막 ID 날조 문제는 예전엔 훨씬 심했는데, 도구 결과를 산문이 아니라 <code>customer_id=42</code> 같은 명시적 필드로 돌려주게 바꾸자 거의 사라졌습니다. 소형 모델에게는 이 정도까지 떠먹여 주는 구조가 필요합니다.</p>

<h2 class="relative group">비용 계산
    <div id="비용-계산" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b9%84%ec%9a%a9-%ea%b3%84%ec%82%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>온디바이스 방식은 인프라 비용 $0/월. 사용자당 비용도 최초 1.1 GB 다운로드를 빼면 $0이고, 사용자가 아무리 늘어도 그대로입니다.</p>
<p>클라우드 방식을 대충 계산해 보면, 평균 대화가 약 2,000토큰이고 200명이 월 30회 대화하면 6,000회, 즉 월 1,200만 토큰입니다. 저렴한 클라우드 모델이면 월 $18, 최상위 모델이면 월 $180~360 정도죠. 이 규모에선 망할 금액은 아니지만, 온디바이스 방식은 첫날부터 본전을 뽑았고 사용자가 10,000명이 되어도 여전히 $0입니다.</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%ec%96%b4%ec%95%bc-%ed%95%a0-%ed%95%9c%ea%b3%84" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>모델 능력.</strong> Qwen 1.7B는 구조화된 작업엔 강하지만 여러 단계를 거치는 복잡한 추론, 사실 지식(검색 엔진이 아닙니다), 창작, 미묘한 뉘앙스 파악은 못합니다. 소형 모델이 잘하는 쪽으로 기능을 설계하면 문제 될 게 없습니다.</p>
<p><strong>기기 요구사항.</strong> 최소 사양은 RAM 3 GB에 여유 저장공간 2 GB. 현실적으로는 RAM 6 GB, 64비트 프로세서, GPU 지원이 필요합니다. iPhone 8이나 Galaxy S9 같은 구형 기기는 버거우니, 기능 감지를 해서 자연스럽게 폴백시키세요.</p>
<p><strong>모델 업데이트.</strong> 모델을 갈아끼우면 사용자는 1.1 GB를 다시 받아야 합니다. 저는 저장 경로에 버전을 넣어 뒀습니다:</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">const</span> <span class="nx">MODEL_PATH</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">FileSystem</span><span class="p">.</span><span class="nx">documentDirectory</span><span class="si">}</span><span class="sb">llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`</span><span class="p">;</span></span></span></code></pre></div></div>
<p>새 모델을 낼 때 경로만 올리면 됩니다. 옛 모델은 앱 삭제 시 자동으로 지워집니다.</p>

<h2 class="relative group">iOS와 Android의 차이
    <div id="ios와-android의-차이" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ios%ec%99%80-android%ec%9d%98-%ec%b0%a8%ec%9d%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Metal을 쓰는 iOS가 Android보다 20%쯤 빠르고, 메모리 관리도 낫고, 모델이 기본적으로 iCloud에 백업됩니다(사용자 백업 용량을 잡아먹기 싫다면 <code>NSURLIsExcludedFromBackupKey</code>로 제외하세요). Android는 기기별 편차가 훨씬 큽니다. llama.rn의 GPU 경로는 Qualcomm Adreno 700 시리즈 GPU에서 도는 OpenCL이라, 그런 GPU가 없는 폰은 CPU로 돌아갑니다. 저장소 자동 백업도 없고요. 양쪽 다 테스트하세요. 좋은 Android 기기와 나쁜 Android 기기의 격차가 플랫폼 간 격차보다 클 정도입니다.</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%94%94%eb%b2%84%ea%b9%85-%ed%8c%81" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><strong>1. 상세 로깅 켜기</strong></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">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">initLlama</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">model</span>: <span class="kt">MODEL_PATH</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_ctx</span>: <span class="kt">8192</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_gpu_layers</span>: <span class="kt">99</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">verbose</span>: <span class="kt">true</span><span class="p">,</span> <span class="c1">// Logs every token + timings
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p><strong>2. 토큰 수 추적</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">tokens</span> <span class="o">=</span> <span class="nx">fullResponse</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">).</span><span class="nx">length</span> <span class="o">*</span> <span class="mf">1.3</span><span class="p">;</span> <span class="c1">// Rough estimate
</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="sb">`Generated </span><span class="si">${</span><span class="nx">tokens</span><span class="si">}</span><span class="sb"> tokens in </span><span class="si">${</span><span class="nx">duration</span><span class="si">}</span><span class="sb">ms`</span><span class="p">);</span></span></span></code></pre></div></div>
<p><strong>3. 메모리 모니터링</strong></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="p">{</span> <span class="nx">MemoryInfo</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;react-native-device-info&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">memoryUsage</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">MemoryInfo</span><span class="p">.</span><span class="nx">getUsedMemory</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="sb">`Memory: </span><span class="si">${</span><span class="p">(</span><span class="nx">memoryUsage</span> <span class="o">/</span> <span class="mi">1024</span> <span class="o">/</span> <span class="mi">1024</span><span class="p">).</span><span class="nx">toFixed</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span><span class="si">}</span><span class="sb"> MB`</span><span class="p">);</span></span></span></code></pre></div></div>
<p><strong>4. 오프라인 테스트</strong></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%9e%ec%9c%bc%eb%a1%9c-%ed%95%b4%eb%b3%b4%ea%b3%a0-%ec%8b%b6%ec%9d%80-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>llama.cpp는 이미 비전 모델을 지원하는데, &ldquo;이 현장 사진을 작업에 추가해 줘&rdquo; 같은 사용법은 이 앱에 딱 맞습니다. 과거 작업 설명과 고객 대화 데이터로 LoRA 어댑터를 파인튜닝하는 것도 시도해 보고 싶고, 도메인 쿼리 기준으로 정확도가 20~30% 오르지 않을까 짐작하고 있습니다. 그 너머로는 기기에서 도는 음성 인식 모델을 붙인 완전 오프라인 음성 어시스턴트, 그리고 원본 데이터를 기기 밖으로 내보내지 않고 사용자 전체에 걸쳐 모델을 개선하는 연합 학습도 그려 보고 있습니다.</p>

<h2 class="relative group">만들 만한가?
    <div id="만들-만한가" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a7%8c%eb%93%a4-%eb%a7%8c%ed%95%9c%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>민감한 데이터를 다루는 비즈니스 앱, 오프라인 우선 워크플로우, 분류나 데이터 입력 같은 구조화된 작업이 중심인 앱이라면, 그리고 규모가 커졌을 때의 API 비용이 걱정된다면 답은 &lsquo;예&rsquo;입니다. 지금 당장 됩니다.</p>
<p>자유로운 대화가 필요하면 대형 클라우드 모델을 쓰세요. 지식으로 승부하는 작업(소형 모델은 정말 아는 게 없습니다), 저사양 기기 지원이 필수인 경우, 도메인 지식이 모델 업데이트보다 빨리 바뀌는 경우도 마찬가지입니다.</p>
<p>구현에 든 시간은 12시간 남짓. 월 운영비 $0에, 비행기 안에서도 돌아가고, 고객 데이터는 고객 손을 떠나지 않습니다. 솔직히 흠잡을 데가 없네요.</p>
<hr>
<p><em>질문이나 피드백은 <a href="https://twitter.com/jaredlynskey"  target="_blank" rel="noreferrer">@jaredlynskey</a>로 주세요. llama.rn 라이브러리는 <a href="https://github.com/mybigday/llama.rn"  target="_blank" rel="noreferrer">github.com/mybigday/llama.rn</a>에 있습니다.</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-01-30-on-device-ai/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
