<?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>GitHub Actions · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/github-actions/</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/github-actions/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon: 변경된 코드에 영향받는 테스트만 실행하기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmon은 변경이 닿는 테스트만, 짐작이 아니라 커버리지 기록으로 골라 돌립니다. 테스트 2,248개짜리 Django 스위트에 넣은 방법, 필요한 래퍼, Python 3.14의 함정.</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a>은 변경 사항이 영향을 줄 수 있는 테스트만 실행하는 pytest 플러그인입니다. coverage.py로 각 테스트가 어떤 코드 줄을 실행하는지 지켜보고, 테스트가 거쳐 간 코드 블록마다 체크섬을 저장해 둡니다. 다음 실행에서는 그 체크섬을 디스크의 코드와 비교해서, 블록이 바뀐 테스트와 지난번에 실패한 테스트만 돌립니다. <code>pytest --lf</code>가 하던 일까지 겸하는 셈이죠.</p>
<p>이런 기법을 통틀어 테스트 영향 분석(test impact analysis)이라고 하는데, testmon은 Python에서 이걸 무료 오픈소스로 쓰는 방법입니다. git과는 아무 상관이 없습니다. 커밋을 했든 안 했든, 지난 실행에서 본 것과 비교할 뿐입니다.</p>
<p>이번 주에 제 마켓플레이스 앱의 Python 테스트에 이걸 넣었습니다. 속도 자체가 목적은 아니었습니다.</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="이 글을 내레이션으로 소개하는 짧은 영상">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어" default>
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>이 글을 약 8분짜리 영상으로 만들었습니다. 내레이션은 영어이고 한국어 자막이 나옵니다.</figcaption>
</figure>


<h2 class="relative group">넣은 이유
    <div id="넣은-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%84%a3%ec%9d%80-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>기능 요청은 대개 작습니다. 화면 하나, 엔드포인트 하나, 다르게 동작해야 하는 규칙 하나. 그런데 테스트 스위트를 통째로 돌리다 보니 요청마다 실제보다 커졌습니다. 스위트는 Django 테스트 2,248개로, 프로세스 넷이면 3분 반쯤, 하나면 11분이 걸립니다. 그런데 수정할 때마다 이걸 전부 돌렸습니다. 그 거의 전부가 요청과는 아무 상관 없는 것을 확인하는데도요. 상관없는 테스트가 실패하거나 그냥 실행이 오래 걸리면 기능은 기다려야 했습니다. 기능 요청과 무관한 사정에 발이 묶인 건데, 발목을 잡은 게 다름 아닌 제 테스트 스위트였죠.</p>
<p>그렇다고 다른 길이 더 나은 것도 아니었습니다. 전체 실행을 건너뛰고, 변경을 다루는 테스트 파일이 어느 건지 짐작하고, 가끔은 틀리는 겁니다. 빠르긴 해도 정확하지는 않습니다. 그리고 테스트 없이 내보낸 변경은 같은 기능 요청을 두 번 처리하게 만듭니다.</p>
<p>제가 기다리는 테스트는 변경이 닿을 수 있는 테스트와 정확히 같아야 합니다. 더도 덜도 말고요. testmon은 그 집합을 짐작이 아니라 기록으로 줍니다. 전체 스위트는 여전히 CI에서 돕니다. 거기서는 제가 다음에 할 작업을 붙잡지 않습니다.</p>

<h2 class="relative group">돌릴 테스트를 고르는 방식
    <div id="돌릴-테스트를-고르는-방식" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8f%8c%eb%a6%b4-%ed%85%8c%ec%8a%a4%ed%8a%b8%eb%a5%bc-%ea%b3%a0%eb%a5%b4%eb%8a%94-%eb%b0%a9%ec%8b%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon은 Python 파일 하나를 여러 블록으로 나눕니다. 모듈 최상위, 각 클래스, 각 함수가 블록이 되고, 중첩 함수의 본문은 바깥 블록에서 잘라 냅니다. 저장하는 건 블록마다의 체크섬이고, 코드 자체는 저장하지 않습니다. 테스트가 도는 동안 coverage.py가 실행된 줄을 기록하면, testmon은 그 줄이 어느 블록에 속하는지 적어 둡니다. 정적 분석도 없고, import를 보고 짐작하지도 않습니다. 테스트는 정확히 자기가 실행한 블록에만 의존합니다.</p>
<p>이런 모듈이 있다고 해 보죠.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p><code>subtotal()</code>만 호출하는 테스트는 모듈 최상위와 <code>subtotal</code>의 본문에 의존합니다. <code>discount()</code>를 어떻게 고쳐 쓰든 testmon은 그 테스트를 다시 돌리지 않습니다. <code>discount</code>의 줄을 하나도 실행한 적이 없으니까요. 반대로 <code>subtotal</code> 안에서 글자 하나만 바꿔도, 결과가 달라질 수 없는 변경이라 해도 테스트는 다시 돕니다. testmon은 부작용을 두고 머리를 굴리지 않습니다. 실행한 블록이 바뀌었으면 다시 돌 뿐입니다.</p>
<p>코드를 둘러싼 환경도 추적합니다. 기록에는 Python 버전, 그리고 설치된 패키지와 그 버전이 들어갑니다. 환경 변수는 보지 않습니다. 같은 코드를 설정 모듈 두 개로 돌린다면 <code>--testmon-env</code>(8단계)로 이름을 붙여 주지 않으면 기록 하나를 같이 쓰게 됩니다. Django를 업그레이드하면 Django를 건드린 테스트가 전부 다시 영향을 받는데, Django 앱에서는 그게 곧 전부입니다.</p>

<h2 class="relative group">보지 못하는 것
    <div id="보지-못하는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b3%b4%ec%a7%80-%eb%aa%bb%ed%95%98%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon이 추적하는 건 테스트가 실행하는 Python입니다. 테스트가 읽는 다른 파일, 그러니까 템플릿, JSON, 텍스트, 그 밖에 디스크에 있는 무엇이든 추적하지 않습니다. 네트워크 너머의 서비스도 추적하지 않습니다. 그리고 테스트가 시작되기 전에 한 번 실행되는 것은 어느 특정 테스트에도 묶을 수 없습니다.</p>
<p>Django 프로젝트라면 우선 마이그레이션입니다. 테스트 데이터베이스를 만들 때 한 번 실행되기 때문에 어느 테스트의 기록에도 들어가지 않습니다(아래 래퍼는 새 마이그레이션이 생기면 테스트 데이터베이스를 다시 만듭니다). 다음은 설정과 시작 코드로, pytest가 시작하는 동안 어느 한 테스트의 바깥에서 실행됩니다. 그리고 템플릿과 데이터 파일입니다. 템플릿은 Python이 아니라서, 고쳐도 그걸 렌더링하는 테스트가 하나도 다시 돌지 않습니다.</p>
<p>제 스위트에는 이런 게 몇 가지 더 있습니다. 어떤 테스트는 모바일 앱의 테스트와 함께 쓰는 픽스처 JSON 파일을 읽습니다. 어떤 테스트는 빌드된 웹 번들을 확인하고, 또 어떤 테스트는 같은 저장소에서 생성하는 정적 사이트의 결과물을 확인합니다. 이 중 무엇을 바꾸든 testmon은 태연하게 돌릴 게 없다고 할 겁니다. 아래 방법에서 testmon을 스크립트로 감싸고, 이런 파일이 바뀌면 알아채서 전부 돌리게 하는 이유가 이겁니다.</p>

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

<h2 class="relative group">얼마나 아껴 주나
    <div id="얼마나-아껴-주나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%96%bc%eb%a7%88%eb%82%98-%ec%95%84%ea%bb%b4-%ec%a3%bc%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>래퍼를 넣고 다음 절의 버그까지 고친 뒤, 시작 시간을 포함해서 재 봤습니다.</p>
<table>
	<thead>
			<tr>
					<th>바뀐 것</th>
					<th style="text-align: right">실행한 테스트</th>
					<th style="text-align: right">시간</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>없음</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0초</td>
			</tr>
			<tr>
					<td>홈 화면 배너 뒤의 코드 한 군데 수정</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5초</td>
			</tr>
			<tr>
					<td>그 수정 되돌리기</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7초</td>
			</tr>
			<tr>
					<td>비교용 스위트 전체(프로세스 4개)</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">약 3½분</td>
			</tr>
	</tbody>
</table>
<p>평소의 수정이라면 병렬 스위트보다 25배쯤 빠르고, 바뀐 게 없을 때는 200배쯤 빠릅니다.</p>
<p>기록도 더 넓은 범위에서 같은 말을 합니다. 스위트가 실행하는, 테스트가 아닌 Python 파일 307개 가운데 중앙값에 해당하는 파일을 바꾸면 테스트 22개, 테스트 시간으로 8초쯤에 닿습니다. 반대쪽 끝에는 핵심 모듈 몇 개가 있습니다. 거의 모든 테스트가 이 모듈들을 실행하니, 여기를 바꾸면 1,500개 안팎의 테스트에 닿고 testmon은 스위트 대부분을 다시 돌립니다. 그게 맞는 답이지, 실패가 아닙니다. 이 수치들은 파일별 상한입니다. testmon은 블록 단위로 일하니까, 함수 하나를 바꾸면 그 함수를 실행한 테스트만 고릅니다.</p>
<p>비용은 래퍼가 대체 경로로 넘어갈 때 드러납니다. 전체 실행은 첫 실행과 똑같이 프로세스 하나로 기록하는 실행이라 12분쯤 걸리고, 그건 이 래퍼가 대신하는 병렬 스위트보다 느립니다. 프로세스 하나로 도는 건 testmon을 pytest-xdist와 함께 아직 안 써 봤기 때문입니다. xdist는 1.4부터 지원하니, 다음에 재 볼 건 그쪽이죠. 그래서 전체 실행을 촉발하는 파일 목록은 정직하게 줄일 수 있는 데까지 짧아야 합니다.</p>

<h2 class="relative group">초록불처럼 보였던 버그
    <div id="초록불처럼-보였던-버그" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b4%88%eb%a1%9d%eb%b6%88%ec%b2%98%eb%9f%bc-%eb%b3%b4%ec%98%80%eb%8d%98-%eb%b2%84%ea%b7%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>처음 잰 수치는 이것보다 좋았습니다. 수정 한 번에 테스트 6개, 3.3초. 그런데 틀린 수치였고, 화면 어디에도 그런 말은 없었습니다.</p>
<p>Python 3.14부터 coverage.py는 인터프리터의 <code>sys.monitoring</code> 위에 만든 새 코어를 기본으로 씁니다. 이 코어는 한 프로세스가 어떤 줄을 처음 실행할 때만 그 줄을 보고합니다. testmon은 어느 테스트가 어느 줄을 실행했는지를 coverage에서 받아 기록을 만드니, 각 줄은 우연히 그 줄을 처음 실행한 테스트 하나에만 돌아가고 다른 테스트에는 하나도 돌아가지 않았습니다. 테스트 여덟 개가 함께 거치는 줄을 고치면 testmon은 그중 하나만 고르는 거죠.</p>
<p>이걸 잡아낸 건 이 설정을 그대로 가져다 쓴 제 다른 작은 프로젝트였습니다. 거기에 도입하면서 일부러 한 줄을 망가뜨리고 결과를 비교해 봤는데, 숫자가 맞지 않았습니다. 이 스위트로 돌아와 같은 확인을 해 봤습니다. 홈 화면 배너 코드에서 슬라이드 제목 한 줄을 망가뜨렸더니, 그냥 pytest는 실패한 테스트 8개를 보고했고 testmon은 4개를 골랐습니다. 망가진 나머지 4개는 한 번도 돌지 않았고, 출력 어디에도 그것들이 빠졌다는 말은 없었습니다.</p>
<p>고치는 건 한 줄, <code>export COVERAGE_CORE=ctrace</code>입니다. coverage를 예전의 C 트레이서로 되돌리는 설정이죠. 트레이서가 다르면 기록도 달라지니 다시 기록했습니다. 테스트 2,248개에 12분 3초. 그 뒤로는 같은 식으로 망가뜨리는 수정을 하면, 그 수정으로 깨진 테스트 11개가 전부 돌았습니다.</p>
<p>업데이트(11월 7일): 11월 2일에 나온 testmon 2.3.0은 프로세스 안에서도, 서브프로세스에서도 C 트레이서를 스스로 설정하므로 새로 설치하면 이 export는 더 이상 필요 없습니다. 다음 문단의 검사는 여전히 필요합니다.</p>
<p>이 글에서 무엇보다 먼저 전하고 싶은 교훈이 이겁니다. 너무 적게 고르는 테스트 선택기는 성공과 똑같이 생겼습니다. 테스트는 적고, 실행은 빠르고, 전부 초록불이죠. 그러니 선택기를 설정할 때는 여러 테스트가 함께 쓰는 무언가를 일부러 망가뜨리고, 그냥 스위트와 선택기를 둘 다 돌려서 같은 테스트가 실패하는지 확인하세요. 그리고 Python이나 coverage.py의 버전이 바뀔 때마다 다시 확인하세요.</p>

<h2 class="relative group">코딩 에이전트가 들어오는 자리
    <div id="코딩-에이전트가-들어오는-자리" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%bd%94%eb%94%a9-%ec%97%90%ec%9d%b4%ec%a0%84%ed%8a%b8%ea%b0%80-%eb%93%a4%ec%96%b4%ec%98%a4%eb%8a%94-%ec%9e%90%eb%a6%ac" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이제 그 저장소의 변경을 전부 제 손으로 하지는 않습니다. 작은 변경은 코딩 에이전트가, 제 경우엔 Claude Code가 많이 맡는데, 좋은 에이전트는 수정할 때마다 테스트를 돌립니다. 11분짜리 스위트 앞에서는 에이전트도 제가 겪던 나쁜 선택지 두 개를 똑같이 마주합니다. 기다리거나, 어느 테스트 파일이 중요한지 짐작하거나.</p>
<p>그래서 저장소의 에이전트 지침에 이제 이렇게 적혀 있습니다. 작업 중에 테스트를 돌리는 기본 방법은 래퍼 스크립트이고, 전체 실행은 <code>--all</code>. 에이전트는 수정할 때마다 저와 똑같이 정확한 테스트 집합을 몇 초 만에 받습니다. 6단계에서 스크립트에 전용 데이터베이스와 캐시를 준 것도 이 때문입니다. 바쁜 날에는 같은 머신에서 테스트를 돌리는 세션이 하나가 아니고, 유지해 둔 데이터베이스가 남의 실행과 마주쳐서는 절대 안 되니까요.</p>
<p>testmon에 AI 전용 기능이 따로 필요한 건 하나도 없습니다. 같은 기록, 같은 선택입니다. 다만 변경을 만드는 쪽이 변경을 아주 많이 만들 때 더 중요해질 뿐이죠.</p>

<h2 class="relative group">CI에는 왜 더 거친 버전을 넣었나
    <div id="ci에는-왜-더-거친-버전을-넣었나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci%ec%97%90%eb%8a%94-%ec%99%9c-%eb%8d%94-%ea%b1%b0%ec%b9%9c-%eb%b2%84%ec%a0%84%ec%9d%84-%eb%84%a3%ec%97%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>testmon에는 CI 모드가 없습니다. 러너에서도 노트북에서와 똑같이 동작하고, 작성자는 CI 러너끼리 <code>.testmondata</code>를 공유하거나 캐시하는 방식이 자기네 호스팅 서비스인 testmon.net보다 훨씬 덜 효과적이라고 적어 두었습니다. 캐시된 기록은 앞에서 말한 모든 면에서 눈이 먼 채일 테고, 제 CI는 Django의 러너로 프로세스 넷에서 스위트를 돌리니, testmon은 빼 두었습니다.</p>
<p>대신 CI에는 같은 아이디어를 파일 단위로 넣었습니다. 체크 워크플로는 이제 작은 잡 하나로 시작하는데, 이 잡이 push의 diff를 보고 저장소의 어느 쪽을 건드렸는지 정합니다. Python API인지, 모바일 앱인지, 둘 다인지, 둘 다 아닌지. 전에는 문서만 바꾼 push까지 포함해 모든 push에서 Python 잡과 앱 잡을 둘 다 돌렸습니다. 이제 문서만 바꾼 push는 아무것도 돌리지 않고, 앱만 바꾼 push는 Django 스위트를 건너뜁니다. 실제 커밋 다섯 개에 대고 이 잡을 시험 삼아 돌려 봤더니, 문서만 바꾼 커밋은 아무것도 돌리지 않았고, 앱만 바꾼 커밋은 Python 잡을, 백엔드만 바꾼 커밋은 앱 잡을 건너뛰었습니다. 줄인 예제입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>실제 워크플로는 비교할 범위가 없을 때도 전부 돌립니다. 새 브랜치, 강제 push, 수동 실행이 그런 경우입니다.</p>
<p>사각지대는 모양만 다를 뿐 그대로입니다. testmon이 JSON 파일을 못 보듯, 경로 필터도 목록에 적어 주지 않으면 못 봅니다. 그래서 패턴에 공유 픽스처, 빌드된 번들, 생성된 사이트를 명시적으로 적고, 이걸 고치는 사람은 목록을 정직하게 유지해 달라는 주석을 달아 두었습니다. 파일 단위 버전은 더 투박하지만, 실패하더라도 안전한 쪽으로 실패합니다. Python 쪽에서 뭐든 바뀌었으면 스위트 전체가 돕니다.</p>

<h2 class="relative group">써 볼 만한가요?
    <div id="써-볼-만한가요" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8d%a8-%eb%b3%bc-%eb%a7%8c%ed%95%9c%ea%b0%80%ec%9a%94" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>몇 분씩 걸리는 스위트라면, 네. 진짜 비용은 testmon이 보지 못하는 것들이고, 래퍼 스크립트가 그걸 위해 있습니다. 그렇다 해도 testmon이 있어야 할 자리는 노트북에서 도는 작업 루프입니다. CI는 Python 쪽이 바뀔 때마다 여전히 스위트 전체를 돌리고, 배포 전에 제가 믿는 건 그 실행입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub Actions CI에서 Docker Compose 대신 쓰는 것, 그리고 지금 돌리는 파이프라인</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>2026년에 제가 돌리는 GitHub Actions CI/CD: CI에서는 Docker Compose 대신 서비스 컨테이너, 캐시는 단계별 소요 시간을 보고 고르고, 배포는 스크립트로.</description>
      <content:encoded><![CDATA[<p>CI/CD 파이프라인은 변경 사항이 <code>git push</code>에서 프로덕션까지 가는 자동화된 경로입니다. CI는 모든 push를 깨끗한 머신에서 빌드하고 테스트하고, CD는 통과한 것을 반복 가능한 단계 하나로 릴리스합니다. GitHub Actions에서는 <code>.github/workflows/</code> 아래의 YAML 파일이 그것이고, 제 것은 일부러 얇게 만들었습니다. 체크는 제 노트북에서 돌리는 명령 그대로이고, 배포는 전부 GitHub 없이도 똑같이 동작할 스크립트나 CLI입니다.</p>
<p>GitHub Actions에서 Docker Compose를 돌리는 법을 찾아 오셨다면, 결론부터. 저는 그걸 2년 하다가 그만뒀습니다. 지금 CI는 같은 Postgres와 Valkey 이미지를 <code>services:</code>로 선언하고, 같은 <code>uv run …</code>과 <code>npm run …</code> 명령을 러너에서 바로 돌립니다. Compose는 개발자가 로컬에서 돌리는 것으로 남았고요. 나머지는 그 이유와 실제 파일들입니다.</p>
<p>2024년에 <a href="https://jared.lynskey.co.nz/ko/posts/2024/2024-06-02-simplifyingcicd/" >플랫폼 종속 YAML 대신 Docker Compose로 CI/CD 하기</a>라는 글을 썼습니다. 빌드 로직은 Compose에 두고 CI 도구는 그걸 호출하는 일만 하게 하라는 내용이었죠. 2년이 지나 제가 실제로 배포하는 저장소를 전부 다시 훑어봤습니다. 워크플로가 있는 저장소는 넷이고, 커미터는 모두 저 한 명입니다. 그리고 그 워크플로 중 <code>docker compose</code>를 호출하는 건 이제 하나도 없습니다. 원칙은 살아남았고, 방법은 살아남지 못했습니다. 아래는 그 자리를 대신한 것들입니다. 줄여서 예제로 만든 파일, GitHub가 기록한 단계별 소요 시간, 그리고 CI/CD 다이어그램이 바라는 것보다 덜 자동화된 부분까지 적었습니다.</p>

<h2 class="relative group">제가 돌리는 파이프라인 전부, 표 하나로
    <div id="제가-돌리는-파이프라인-전부-표-하나로" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a0%9c%ea%b0%80-%eb%8f%8c%eb%a6%ac%eb%8a%94-%ed%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8-%ec%a0%84%eb%b6%80-%ed%91%9c-%ed%95%98%eb%82%98%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>파이프라인</th>
					<th>시작하는 쪽</th>
					<th>CI가 확인하는 것</th>
					<th>가는 곳</th>
					<th>릴리스하는 사람</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>이 블로그와 클라이언트의 Hugo 사이트 하나</td>
					<td>모든 push, 브랜치 무관</td>
					<td><code>hugo --gc</code> 빌드가 되는지</td>
					<td>Cloudflare Pages: <code>main</code>은 프로덕션, 다른 브랜치는 미리보기 URL</td>
					<td>없음. push가 곧 릴리스</td>
			</tr>
			<tr>
					<td>제 마켓플레이스 앱의 API</td>
					<td>배포 스크립트, 수동</td>
					<td><code>main</code>에 push할 때마다 린트, 타입 체크, Django 테스트</td>
					<td>Fly.io, 롤링 배포</td>
					<td>저</td>
			</tr>
			<tr>
					<td>같은 앱의 프런트엔드: 웹, OTA 업데이트, 스토어 빌드</td>
					<td>릴리스 스크립트와 로컬 스토어 빌드, 수동</td>
					<td><code>main</code>에 push할 때마다 TypeScript와 Jest</td>
					<td>오브젝트 스토리지, OTA 업데이트 서비스, 두 앱 스토어</td>
					<td>저</td>
			</tr>
			<tr>
					<td>제 오픈소스 Python 패키지 pyhtml2text</td>
					<td>모든 push, 풀 리퀘스트, 태그</td>
					<td>빌드 타깃 15개용 wheel, 대부분에서 테스트</td>
					<td>PyPI, 태그를 달 때</td>
					<td>태그</td>
			</tr>
	</tbody>
</table>
<p>교과서 용어로 말하면 두 Hugo 사이트는 지속적 배포이고, 앱(제가 만드는 마켓플레이스 겸 커뮤니티 앱)은 지속적 통합에 명령 한 줄짜리 릴리스를 더한 지속적 전달입니다. 체크는 뭔가 잘못됐다고 알려 줄 뿐, 배포를 막지는 않습니다. 일부러 이렇게 나눴습니다. OTA 업데이트는 대상 바이너리 버전을 쓰는 모든 휴대폰에 닿으니, 머지의 부작용이 아니라 결정이어야 합니다. 그리고 API는 데이터베이스 마이그레이션이 실행되는 곳입니다. 제 나머지 Django 앱 두 개에는 워크플로가 아예 없습니다.</p>

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

<h2 class="relative group">Docker Compose 파일은 어디로 갔나
    <div id="docker-compose-파일은-어디로-갔나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#docker-compose-%ed%8c%8c%ec%9d%bc%ec%9d%80-%ec%96%b4%eb%94%94%eb%a1%9c-%ea%b0%94%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱의 <code>docker-compose.yml</code>에서 CI와 관련된 부분을 예제로 옮긴 것입니다. 실제 파일에는 서비스도 더 있고 환경 변수도 훨씬 많습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p><code>checks.yml</code>의 <code>services:</code> 블록 옆에 놓고 보면 같은 이미지 두 개입니다. Postgres 17(여기서는 Alpine 빌드, CI에서는 Debian 빌드)과 <code>valkey/valkey:8-alpine</code>. 지금의 관계는 이렇습니다. Compose 파일은 개발자가 돌리는 것이고, CI는 같은 이미지를 서비스 컨테이너로 선언한 다음 개발자가 돌리는 것과 같은 명령, <code>uv run …</code>과 <code>npm run …</code>을 러너에서 바로 돌립니다.</p>
<p>2024년 글과 비교하면, 제가 가장 신경 쓰던 부분은 살아남았습니다. CI 도구는 얇고, 체크와 배포는 제가 직접 돌릴 수 있는 명령입니다. 살아남지 못한 건 CI가 Compose를 호출하는 방식입니다.</p>
<p>앱의 CI는 Docker 이미지를 아예 빌드하지 않습니다. 이미지는 배포할 때 Fly의 빌더가 한 번 빌드하고, CI는 코드가 괜찮은지만 말해 주면 됩니다. 툴체인이 캐시돼 있으면 그건 6초짜리 설치로 끝납니다. 게다가 Compose 파일은 작업 사본을 바인드 마운트하고 데이터를 시드하는데, 테스트 실행이 할 일은 아닙니다. 그러니 CI용 Compose 파일이 따로 필요했을 테고, 그건 같은 서비스를 두 번째로 정의해 손으로 맞춰 가야 한다는 뜻입니다.</p>
<p>그리고 2024년 예제에는 당시 눈치채지 못한 버그가 있었습니다. <code>docker compose build</code>는 이미지를 빌드할 뿐 서비스의 <code>command</code>를 실행하지 않습니다. 그러니 그 파일의 <code>npm test</code>는 CI에서 한 번도 돌지 않았고, 워크플로는 테스트가 통과하든 말든 초록불이었습니다.</p>
<p>그 글이 가장 걱정했던 위험, 즉 CI 업체를 바꾸는 일은 결국 일어나지 않았습니다. 실제로 일어난 이동은 반대쪽 끝에서였습니다. 이 블로그와 그 클라이언트 사이트가 호스팅을 Cloudflare Pages로 옮겼는데, 블로그 쪽은 45줄짜리 워크플로 하나를 새로 쓰는 걸로 끝났습니다. 빌드가 원래 명령 한 줄이었기 때문이죠. 그리고 제가 경고했던 300줄짜리 워크플로요? 하나 있습니다. pyhtml2text의 워크플로입니다. wheel을 Linux, macOS, Windows 러너에서 각각 여러 아키텍처로 빌드해야 해서 긴 건데, 그래서 플랫폼 전용 YAML이 맞는 도구인 유일한 작업이기도 합니다.</p>

<h2 class="relative group">캐싱: 단계별 소요 시간부터 읽기
    <div id="캐싱-단계별-소요-시간부터-읽기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%ba%90%ec%8b%b1-%eb%8b%a8%ea%b3%84%eb%b3%84-%ec%86%8c%ec%9a%94-%ec%8b%9c%ea%b0%84%eb%b6%80%ed%84%b0-%ec%9d%bd%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>GitHub는 모든 단계의 소요 시간을 기록해 두고, <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code>로 꺼내 볼 수 있습니다. 아래는 10월 20일, 앱의 가장 최근 성공 실행입니다.</p>
<table>
	<thead>
			<tr>
					<th>잡</th>
					<th>단계</th>
					<th>초</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Postgres와 Valkey 시작</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, 캐시 있음</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django 테스트</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, 캐시 있음</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>의존성 캐시 두 개는 모두 켜져 있고, 둘 다 저렴한 부분입니다. GitHub가 이 저장소용으로 보관하는 uv 캐시는 200KB 정도인데, setup-uv가 다시 내려받으면 되는 것들은 정리해 버리기 때문입니다. npm 캐시는 400MB에 가깝습니다. 시간이 드는 곳은 테스트이고, 테스트는 이미 4갈래로 병렬 실행됩니다. 실행을 더 빠르게 하고 싶다면 캐시가 아니라 테스트 스위트를 손보겠습니다.</p>
<p>블로그는 정반대입니다. Hugo가 처리한 이미지는 gitignore된 <code>resources/_gen</code>에 있어서, 새 러너마다 커버, 본문 이미지, 플레이스홀더를 전부 다시 인코딩했습니다. 아래 워크플로는 <code>actions/cache</code>로 이것을 복원합니다. 키는 사진 자체의 해시이고, 키가 맞지 않으면 가장 최근의 이전 캐시로 대신합니다. Hugo는 빠진 것만 처리하니까요. 캐시를 넣기 전 마지막 8번의 실행에서 빌드 단계는 33<del>76초 걸렸고, 넣은 뒤 26번은 6</del>20초였습니다. 94초가 한 번 있었을 뿐이고, 같은 커밋에서 플레이스홀더와 두 번째 카드 크기까지 추가했는데도 그렇습니다.</p>
<p>앱의 이미지는 Fly의 빌더에서 빌드되고, 의존성 파일을 코드보다 먼저 복사하며 uv의 다운로드 캐시는 BuildKit 캐시 마운트(<code>RUN --mount=type=cache,target=/root/.cache/uv</code>)에 둡니다. 하지만 더 큰 개선은 캐시가 아니었습니다. 예전 <code>.dockerignore</code>는 블록리스트였는데, 추가 git worktree들을 모아 둔 폴더를 빠뜨렸습니다. worktree마다 자기 의존성과 빌드 산출물이 들어 있었죠. 그래서 파일 27만 7천 개, 7.7GB가 배포할 때마다 빌더로 올라갔습니다. 허용 목록으로 다시 쓴 지금은 빌드 컨텍스트가 40MB 정도입니다. 그 패턴을 예제로 보면 이렇습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

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

<h2 class="relative group">배포: push마다, 스크립트로, 태그로
    <div id="배포-push마다-스크립트로-태그로" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b0%b0%ed%8f%ac-push%eb%a7%88%eb%8b%a4-%ec%8a%a4%ed%81%ac%eb%a6%bd%ed%8a%b8%eb%a1%9c-%ed%83%9c%ea%b7%b8%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>

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

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

<h3 class="relative group">pyhtml2text는 태그로 릴리스합니다
    <div id="pyhtml2text는-태그로-릴리스합니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text%eb%8a%94-%ed%83%9c%ea%b7%b8%eb%a1%9c-%eb%a6%b4%eb%a6%ac%ec%8a%a4%ed%95%a9%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>릴리스 잡입니다. 패키지 이름은 일반적인 이름으로 바꿨습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>릴리스 잡은 모든 빌드 잡을 기다리고, 실제 게시는 태그일 때만 합니다. 수동 실행은 빌드하고 증명(attestation)까지 만들지만 아무것도 올리지 않습니다. <code>actions/attest</code>는 어느 워크플로가 각 wheel을 빌드했는지 기록하고, <code>uv publish --trusted-publishing always</code>는 비밀번호 대신 OIDC 토큰으로 업로드합니다. 패키지 자체에 대해서는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/" >닫힌 풀 리퀘스트, 포크, 그리고 PyPI 패키지</a>에 썼습니다.</p>

<h2 class="relative group">예약 워크플로는 최선을 다할 뿐입니다
    <div id="예약-워크플로는-최선을-다할-뿐입니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%98%88%ec%95%bd-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c%eb%8a%94-%ec%b5%9c%ec%84%a0%ec%9d%84-%eb%8b%a4%ed%95%a0-%eb%bf%90%ec%9e%85%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>앱에는 예약 워크플로가 하나 있습니다. Fly가 기준치보다 머신을 더 띄우지 않았는지 보는 체크로, 그냥 두면 청구서에서 처음 알게 될 일입니다. 알림은 일부러 실패하는 방식으로 보냅니다. 예약 실행이 실패하면 GitHub이 소유자에게 메일을 보내니까요.</p>
<p>설정은 <code>cron: '*/15 * * * *'</code>, 하루 96번입니다. 그런데 지난 2주 동안은 하루 3~8번만 돌았고, 실행 간격의 중앙값은 3시간 40분쯤이었습니다. GitHub 문서가 내놓는 설명은 부하뿐입니다. Actions가 바쁘면 예약 실행이 늦어질 수 있고, 아주 바쁘면 빠질 수도 있다는 거죠. 계속되는 스케일 아웃이라면 몇 시간이면 충분합니다. 몇 분 안에 울려야 하는 것이라면 GitHub의 cron은 모니터링이 아닙니다.</p>

<h2 class="relative group">파이프라인에 던져 볼 질문
    <div id="파이프라인에-던져-볼-질문" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8c%8c%ec%9d%b4%ed%94%84%eb%9d%bc%ec%9d%b8%ec%97%90-%eb%8d%98%ec%a0%b8-%eb%b3%bc-%ec%a7%88%eb%ac%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>파이프라인을 만들고 있거나 이미 있는 걸 들여다보고 있다면:</p>
<ol>
<li>GitHub Actions가 하루 동안 멈추면 그래도 릴리스할 수 있나요? 못 한다면 릴리스 로직이 YAML 안에 있는 것이고, 2024년 글이 여전히 유효합니다.</li>
<li>CI가 프로덕션과 같은 인터프리터, 같은 CPU 아키텍처, 같은 데이터베이스 메이저 버전으로 도나요? 제 CI가 컴파일러 버그를 잡은 건 러너와 프로덕션은 둘 다 x86_64이고 제 노트북은 아니었기 때문입니다.</li>
<li>새로 받은 체크아웃이 통과하나요? 안 된다면 대개 생성되고 gitignore된 파일 때문입니다.</li>
<li>실행 시간은 실제로 어디에 쓰이나요? 캐시를 넣기 전에 단계별 소요 시간부터 읽어 보세요.</li>
<li>시크릿마다 몇 군데에 있나요? 그리고 정말 GitHub에 있어야 하나요?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>브랜치별 프리뷰 환경: Fly.io와 Cloudflare로 스테이징 쟁탈전 끝내기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>PR마다 프리뷰 환경을 띄우는 방법입니다. 라벨 하나로 브랜치 이름의 해시를 Fly 앱, Postgres, Redis, 버킷, 호스트명에 찍어 공유 인프라를 나눠 쓰고, PR을 닫으면 걷어냅니다.</description>
      <content:encoded><![CDATA[<p>브랜치마다 프리뷰 환경을 주려고 저는 Fly.io의 인프라를 통째로 복제하지 않고, 공유한 채로 잘라 씁니다. PR에 <code>env</code> 라벨을 붙이면 GitHub Actions 워크플로가 스크립트 하나를 돌리고, 그 스크립트가 브랜치 이름에서 뽑은 이름(슬러그와 짧은 해시)을 Fly 앱, 템플릿에서 복제한 Postgres 데이터베이스, Redis 키 접두사, 버킷 접두사, 새로 생성한 시크릿, Cloudflare 와일드카드 아래의 호스트명에 찍습니다. push할 때마다 갱신되고, PR을 닫거나 머지하면 철거되며, 새어 나간 건 매일 밤 도는 스위퍼가 치웁니다. 공유 인프라 위의 브랜치별 환경이라, 이제 &ldquo;스테이징 쓰는 사람 있어요?&ldquo;라고 물을 일이 없습니다.</p>
<p>앱 하나를 만드는 작은 팀을 떠올려 보세요. 개발자 둘일 수도, 다섯일 수도 있습니다. 전문 분야가 나뉜 사람들이 아니라, 각자 자기 브랜치에서 데이터베이스 마이그레이션부터 API를 거쳐 모바일 앱 화면까지 기능 하나를 처음부터 끝까지 맡는 사람들입니다. 팀을 굴리기에 좋은 방식이고, 두 번째 개발자가 합류하는 순간부터 반복되는 실패가 하나 있습니다. &ldquo;지금 스테이징 쓰는 사람 있어요?&ldquo;라는 메시지입니다.</p>
<p>그 메시지 뒤에는 기능을 다 만들고 테스터나 디자이너에게 보여주고 싶은데 그럴 수 없는 개발자가 있습니다. 스테이징에는 두 번째 개발자의 브랜치가 올라가 있고, dev에는 세 번째 개발자의 브랜치가 올라가 있는데 그건 이틀 전에 로그인을 망가뜨린 뒤로 다시 배포되지 않았습니다. 몇 사람, 공유 환경 둘, 그리고 일정 조율을 떠맡은 채팅 스레드.</p>
<p>이건 사람 문제가 아닙니다. 토폴로지 문제입니다. 모든 개발자가 환경을 &ldquo;내 브랜치가 실제가 되는 곳&quot;으로 다루니, 환경이 머지가 해야 할 일을 브랜치 하나씩 차례로 대신하고 있고, 나머지 전부는 줄을 서 있는 겁니다.</p>
<p>제 스택의 프로덕션 형태는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a>에 적었습니다. Fly.io의 가장 싼 머신 위의 Django, 커넥션 풀러 뒤의 Postgres, 캐시와 Channels 레이어용 Redis, 별도의 WebSocket 앱, 백그라운드 워커, 파일용 S3 호환 버킷, 호스팅형 인증 서비스, 앞단의 Cloudflare, 그리고 이 모든 것과 통신하는 모바일 앱입니다. 이 글은 그 스택을, 어떤 브랜치든 개발자가 PR에 라벨 하나를 붙이면 몇 분 안에 HTTPS가 붙은 공개 복사본 전체를 얻는 것으로 바꾼 이야기입니다. 다른 누구에게 부탁하지도, 누가 프로비저닝해 주지도 않고요. Postgres 클러스터를 하나 더 두지도, Redis를 하나 더 두지도, 버킷을 하나 더 두지도 않고요. 인프라는 공유된 채로 남습니다. 달라지는 건 모든 공유 자원이 이름으로 잘리는 법을 배운다는 것이고, 그 이름은 브랜치에서 나옵니다. 여기에 팀 규모를 전제하는 건 없습니다. 개발자 둘이든 스물이든 설계는 같고, 늘어나는 건 앱 목록뿐이며 그건 스위퍼가 처리합니다. 오후 한나절에 만들고, 브랜치 셋을 동시에 띄우고, 예상 못 한 두 군데에서 부러뜨리고, 다시 걷어냈습니다. 아래 출력은 그 실행에서 나온 것입니다.</p>

<h2 class="relative group">공유 환경 하나가 개발자 한 명 이상으로 늘어나지 못하는 이유
    <div id="공유-환경-하나가-개발자-한-명-이상으로-늘어나지-못하는-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b3%b5%ec%9c%a0-%ed%99%98%ea%b2%bd-%ed%95%98%eb%82%98%ea%b0%80-%ea%b0%9c%eb%b0%9c%ec%9e%90-%ed%95%9c-%eb%aa%85-%ec%9d%b4%ec%83%81%ec%9c%bc%eb%a1%9c-%eb%8a%98%ec%96%b4%eb%82%98%ec%a7%80-%eb%aa%bb%ed%95%98%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 dev 환경에는 전제가 하나 깔려 있습니다. 모두가 보고 싶어 하는 &ldquo;현재 코드 버전&quot;이 하나라는 전제입니다. 개발자가 한 명이거나, 팀이 변경 흐름 하나를 발맞춰 작업할 때는 맞는 말입니다. 두 번째 개발자가 다른 일정으로 기능을 시작하는 순간 틀린 말이 되고, 처음부터 끝까지 맡는 방식에서는 모든 기능이 그렇습니다. 그때부터 환경이 쥐고 있는 모든 것이 쟁탈 대상이 됩니다.</p>
<ul>
<li><strong>배포된 코드.</strong> 마지막에 배포한 사람이 이깁니다. 나머지 사람들의 작업은 다시 배포하기 전까지 보이지 않고, 다시 배포하면 앞사람 것이 지워집니다.</li>
<li><strong>데이터베이스 스키마.</strong> 개발자마다 자기 기능의 데이터 모델을 소유할 때 가장 아프게 무는 부분입니다. 첫 브랜치가 컬럼을 추가합니다. 한 시간 뒤 배포된 두 번째 브랜치는 그 컬럼을 모르고, ORM이 insert에서 실패하기 시작합니다. 아니면 두 브랜치 모두 <code>0042_*</code> 마이그레이션을 갖고 있어서, 공유 데이터베이스가 누구의 마이그레이션 이력과도 맞지 않는 상태가 되고, 누군가 초기화하면서 세 번째 개발자가 오후 내내 세팅한 테스트 데이터를 지웁니다.</li>
<li><strong>캐시.</strong> 한 브랜치의 serializer 변경이 다른 브랜치 코드가 읽는 것과 같은 Redis 키 아래 캐시됩니다. 그래서 나오는 버그 리포트는 어리둥절하고, 하나에 오전 한나절씩 잡아먹습니다.</li>
<li><strong>서드파티 연동.</strong> OAuth 콜백 하나, 웹훅 URL 하나, 푸시 인증서 하나. 누구 브랜치가 배포돼 있든 그 브랜치가 이벤트를 받습니다.</li>
<li><strong>테스터.</strong> 배포된 것만 테스트할 수 있으니, 기능이 준비된 순서가 아니라 환경이 우연히 점유된 순서대로 한 번에 하나씩 테스트합니다.</li>
</ul>
<p>팀은 예약 시스템으로 대응합니다. 채팅 스레드, 고정 메시지, <code>/claim staging</code> 봇. 그건 락입니다. 작업을 보여줄 수 있는 유일한 장소에 걸린 락은 전달 속도에 걸린 락입니다. 개발자 둘이면 성가신 정도입니다. 셋이면 좋은 주에는 견딜 만합니다. 네 번째가 합류하거나, 기능 하나가 디자이너와 사흘간 주고받느라 스테이징을 붙들고 있으면 견딜 수 없게 됩니다.</p>
<p>사람들이 먼저 손대는 해결책은 세 번째 공유 환경, &ldquo;dev2&quot;나 &ldquo;uat&quot;입니다. 몇 주는 벌어 줍니다. 줄이 두 갈래가 됐을 뿐입니다.</p>
<p>진짜 해결책은 dev와 스테이징이 애초에 환경이 아니었다는 걸 알아차리는 것입니다. 그것들은 <em>누군가의 브랜치의 현재 상태에 URL이 붙은 것</em>이었습니다. 그렇다면 알맞은 개수는 지금 팀이 신경 쓰는 브랜치의 개수이고, 알맞은 수명은 브랜치의 수명입니다.</p>

<h2 class="relative group">모델: 브랜치가 곧 환경
    <div id="모델-브랜치가-곧-환경" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%aa%a8%eb%8d%b8-%eb%b8%8c%eb%9e%9c%ec%b9%98%ea%b0%80-%ea%b3%a7-%ed%99%98%ea%b2%bd" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>목표 상태는 이렇습니다.</p>
<table>
	<thead>
			<tr>
					<th>환경</th>
					<th>개수</th>
					<th>만드는 주체</th>
					<th>없애는 주체</th>
					<th>수명</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>브랜치 환경 (예전의 &ldquo;dev&rdquo;)</td>
					<td>활성 브랜치당 하나</td>
					<td>브랜치의 PR에 붙는 <code>env</code> 라벨, 그다음은 push마다. PR 없는 스파이크라면 같은 <code>./scripts/env up</code>을 손으로</td>
					<td>브랜치 삭제, 그리고 매일 밤 도는 스위퍼</td>
					<td>며칠</td>
			</tr>
			<tr>
					<td>스테이징</td>
					<td>릴리스 후보당 하나</td>
					<td><code>main</code>에서 태그가 잘릴 때 CI</td>
					<td>같은 이미지가 프로덕션으로 승격되면 CI</td>
					<td>몇 시간</td>
			</tr>
			<tr>
					<td>프로덕션</td>
					<td>하나</td>
					<td>당신이, 한 번</td>
					<td>아무도</td>
					<td>영원히</td>
			</tr>
	</tbody>
</table>
<p>표에서 누가 만들고 누가 없애는지를 다시 보세요. 전부 git 이벤트 아니면 시계입니다. git이 컨트롤 플레인입니다. 존재해야 할 환경의 집합은 존재하는 브랜치의 집합에서 그대로 따라 나오고, 자동화가 하는 일은 현실을 거기에 맞춰 두는 것뿐입니다. PR의 라벨이 만들고, 그 뒤의 push마다 갱신하고, PR을 닫거나 머지하면 없애고, 매일 밤 도는 잡이 앞의 셋이 놓친 것을 정리합니다. 모든 브랜치가 환경을 얻는 것도 아닙니다. 라벨이 게이트이고 개발자의 클릭 한 번이니, 백업 push나 봇의 의존성 업데이트는 비용이 없습니다. 아무도 환경을 프로비저닝하지 않습니다. 티켓을 받은 DevOps 엔지니어도, 채팅에서 부르는 봇도, 매번 프롬프트를 넣어야 하는 AI 에이전트도 아닙니다. 열 번째 개발자의 첫 브랜치도 첫 번째 개발자의 브랜치와 똑같은 방법으로, PR에 라벨을 붙였다는 이유로 환경을 얻습니다. 사람에게 부탁해야 한다면 확장된 게 아니라 줄이 자리를 옮긴 것뿐입니다.</p>
<p>그리고 이걸 굴러가게 하는 규칙은 하나입니다. <strong>환경이 건드리는 모든 자원은 브랜치 이름을 따서, 결정론적으로 이름 붙인다.</strong> 개발자 이름도 아니고, 티켓 번호도 아니고, 누군가 열기 전까지는 존재하지도 않는 PR 번호도 아닙니다. 브랜치입니다. 개발자가 실제로 다루는 것이 브랜치이고, 이름이 결정론적이면 같은 브랜치에서 <code>./scripts/env up</code>을 두 번째 실행했을 때 새 환경을 또 만드는 대신 이미 만든 환경을 찾아내기 때문입니다.</p>
<p>이름은 사람을 위한 슬러그와 유일성을 위한 짧은 해시입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code>가 환경입니다. 이 문자열이 Fly 앱 이름, Postgres 데이터베이스 이름(하이픈은 밑줄로), Redis 키 접두사, 버킷의 객체 접두사, 에러 트래커의 environment, 그리고 호스트명의 맨 왼쪽 라벨이 됩니다. 브랜치 이름을 아는 사람은 누구나 계산할 수 있습니다. 어디서도 찾아볼 필요가 없습니다.</p>
<p>마지막 문장이 곧 설계입니다. 환경 목록도 없고 조정자도 없습니다. <code>env up</code>, <code>env down</code>, 호스트명을 라우팅하는 Cloudflare Worker, 밤마다 도는 스위퍼가 각자 브랜치에서 이름을 독립적으로 다시 계산하고, 구조적으로 서로 일치합니다. 특히 Cloudflare는 아무것도 조정하지 않습니다. Worker는 호스트명에서 Fly 앱으로 가는 순수 함수이고, 환경이 만들어졌는지 없어졌는지 영영 알지 못합니다. 앱이 있으면 요청이 닿고, 없으면 Fly가 자기 오류로 답합니다.</p>
<p>여기서 세 가지가 따라 나옵니다.</p>
<p>첫째, &ldquo;스테이징 누가 쓰고 있어요&quot;가 더는 질문이 아닙니다. <code>feature-payments-retr-3f9a1c-dev.example.com</code>이 있고 <code>fix-login-redirect-8b21e0-dev.example.com</code>이 있으며, 각각 그 브랜치를 맡은 사람의 것입니다.</p>
<p>둘째, 스테이징이 쓰레기장이 아니라 프로덕션의 정직한 리허설이 됩니다. 프로덕션에 갈 바로 그 이미지로, 몇 분 전 프로덕션 모양의 스냅샷에서 복제한 데이터베이스를 상대로 만들어집니다. 거기서 되면 프로덕션에 남은 변수는 프로덕션 데이터뿐입니다.</p>
<p>셋째, 개발자마다 공유 자원의 root 권한 없이도 힘을 얻습니다. 브랜치 환경의 폭발 반경은 그 브랜치 환경입니다. 네 번째로 합류한 사람이 첫날에 자기 브랜치에 뭘 배포하든, 최악의 결과는 자기 브랜치가 망가지는 것입니다.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="조직의 Fly.io 대시보드: 기능 브랜치 앱 셋(둘은 이미 suspended)이 프로덕션 앱들과 공유 프리뷰 Postgres, Redis 옆에 나란히"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>브랜치 셋이 올라간 조직의 앱 목록. 셋 중 둘은 배포 1분 만에 이미 suspended 상태이고, 공유 프리뷰 Postgres와 Redis가 곁에 있습니다. 아래쪽 행들이 프로덕션 앱입니다.</figcaption>
</figure>


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

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

<p><strong><code>DEBUG = False</code>, 프로덕션과 같은 settings 모듈.</strong> 브랜치 환경은 공개 URL입니다. 테스트에 중요한 모든 면(진짜 HTTPS, 진짜 쿠키, 같은 헬스 체크)에서 프로덕션처럼 굴고, 누군가를 다치게 할 수 있는 모든 면에서는 프로덕션과 달라야 합니다. 별도의 프리뷰용 settings 파일 대신, 차이는 <code>env up</code>이 설정하는 시크릿이 실어 나릅니다. 샌드박스 결제 키, 인증 서비스 관리자 자격 증명 없음, 그리고 아래의 모든 접두사를 켜는 <code>ENV_NAME</code>.</p>
<p><strong>설정 하나가 모든 것을 자릅니다.</strong> <code>ENV_NAME</code>은 settings에 새로 들어간 유일한 손잡이이고, 네 곳에 적용됩니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>channels 쪽을 빠뜨리면 두 브랜치의 WebSocket 서버가 그룹 이름을 공유해서, 한 환경에서 보낸 메시지가 다른 환경에 나타납니다. 하루를 잡아먹는 종류의 버그입니다.</p>
<p>다섯 번째 자리는 나중에 공유 Redis를 읽어 보고서야 찾았습니다. 504 글의 시작 락, 즉 이미지당 머신 하나가 마이그레이션을 돌리는 동안 나머지가 기다리게 하는 장치는 이미지 참조를 키로 삼고 있었습니다. 같은 커밋에서 빌드된 브랜치 환경 둘은 같은 이미지를 갖습니다. 두 번째 환경은 첫 번째의 &ldquo;done&rdquo; 마커를 발견하고, 마이그레이션되지 않은 자기 데이터베이스에 대한 자기 마이그레이션을 건너뛰었을 겁니다. 지금은 환경 이름이 키에 들어가고 키는 접두사 아래에 삽니다. 공유 인프라 설계가 계속 만들어 내는 종류의 문제입니다. 암묵적으로 &ldquo;배포당&quot;이던 모든 것을 명시적으로 환경당으로 만들어야 하고, 뭐가 암묵적이었는지는 들여다보기 전까지 모릅니다.</p>
<p><strong>데이터베이스는 빈 상태에서 마이그레이션하는 게 아니라 복제합니다.</strong> 다음 절입니다.</p>

<h2 class="relative group">데이터베이스: 몇 초 만에 복제되는 템플릿
    <div id="데이터베이스-몇-초-만에-복제되는-템플릿" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4-%eb%aa%87-%ec%b4%88-%eb%a7%8c%ec%97%90-%eb%b3%b5%ec%a0%9c%eb%90%98%eb%8a%94-%ed%85%9c%ed%94%8c%eb%a6%bf" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>브랜치 환경의 데이터베이스는 셋 중 하나일 수 있습니다. 모두와 공유. 우리를 여기까지 데려온 방식입니다. 빈 상태에서 마이그레이션하고 픽스처로 시드. 결정론적이지만, 픽스처는 실제 모양의 데이터만큼 좋았던 적이 없고 언제나 열여덟 달은 묵어 있습니다. 아니면 프로덕션 모양 스냅샷의 복제. 현실적인 행 수, 픽스처는 늘 채워져 있다고 가정했던 컬럼의 현실적인 null, 빈 테이블에서는 즉시 끝나지만 실제 테이블에서는 40분 걸리는 마이그레이션이 제 모습을 드러냅니다.</p>
<p>세 번째로 가되, 두 번째는 CI 전용 검사로 남깁니다. 그리고 세 번째를 브랜치마다 할 만큼 빠르게 만드는 건 대부분이 있는지도 잊은 Postgres 기능입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code>은 클러스터 안에서의 파일 수준 복사입니다. 몇 기가바이트면 <code>pg_restore</code>가 걸리는 몇 분이 아니라 몇 초입니다. 복제되는 동안 템플릿에는 열린 연결이 있으면 안 되고, <code>datallowconn = false</code>가 그걸 강제합니다. 밤 작업이 살아 있는 템플릿 위에 복원하는 대신 <code>seed_raw</code>에 빌드하고 마지막에 이름을 바꾸는 이유이기도 합니다.</p>
<p>데모에서는 프로덕션 덤프 대신 손으로 템플릿을 만들었습니다. <code>fly proxy</code>로 프리뷰 클러스터에 터널을 뚫고, <code>manage.py migrate</code>, 그다음 저장소에 있는 시드 명령으로 작지만 현실적인 데이터 세트를 넣었습니다. 19메가바이트. 브랜치 셋이 올라오면서 클러스터에 복제본 셋이 나타났고, 각각 19 MB, 각각 1초 미만이었습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>프리뷰 클러스터는 관리형이 아닌 Fly Postgres 노드 하나, 1 GB 볼륨을 단 <code>shared-cpu-1x</code>로, 한 달에 2달러쯤입니다. 브랜치 환경은 사설 네트워크의 <code>.flycast</code>로 붙습니다. 공개된 것은 아무것도 없습니다.</p>
<p>익명화기는 선택이 아닙니다. 프로덕션 데이터가 <code>-dev</code> 호스트명에서 닿는 순간, 그건 프로덕션 데이터가 아니어야 합니다. 이름, 이메일, 전화번호, 주소, 자유 텍스트, 결제 참조, 토큰을 이름을 바꾸기 전에 전부 덮어씁니다. 익명화기가 실패하면 전날 밤 템플릿이 남고 누군가 메시지를 받습니다. 작은 일이고, 이 접근 전체를 개인정보 설문에 답하는 사람이 받아들일 수 있게 만드는 바로 그 일입니다.</p>
<p>entrypoint가 모든 이미지의 첫 부팅에서 <code>migrate</code>를 돌리므로, 브랜치 자신의 마이그레이션이 템플릿 위에 적용됩니다. &ldquo;내 마이그레이션이 프로덕션 모양의 데이터에서 되나&quot;의 첫 정직한 테스트이고, 누가 URL을 열어 보기 전에 일어납니다.</p>

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

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

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

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

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

<h3 class="relative group">라벨, push, delete 워크플로
    <div id="라벨-push-delete-워크플로" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%9d%bc%eb%b2%a8-push-delete-%ec%9b%8c%ed%81%ac%ed%94%8c%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>게이트는 <code>env</code> 라벨이고, 제가 가장 강하게 주장하고 싶은 부분입니다. 게이트가 없으면 모든 브랜치가 앱 하나와 데이터베이스 복제본 하나를 비용으로 치릅니다. 노트북 백업용으로 push한 브랜치, 내일 squash될 스파이크, 봇이 새벽 3시에 연 의존성 업데이트까지요. 그래서 push만으로는 아무 일도 일어나지 않고, PR을 여는 것만으로도 아무 일도 일어나지 않습니다. PR에 <code>env</code>를 붙이면 환경이 올라오고, 라벨이 붙어 있는 동안의 push마다 갱신되고, 라벨을 떼거나 PR을 닫거나 머지하면 내려갑니다. 옵트인은 여전히 GitHub 이벤트이지 사람에게 하는 부탁이 아니고, 누가 볼 건지 아는 유일한 사람이 하는 클릭 한 번입니다. 고정 댓글이 사용자 인터페이스입니다. PR에 라벨을 붙이면 누구에게든 보낼 수 있는 URL이 담긴 댓글이 나타나고, push마다 쌓이는 대신 제자리에서 갱신됩니다. PR이 생기기 전에 올리고 싶은 브랜치는 <code>workflow_dispatch</code>가, 나머지는 노트북에서 돌리는 <code>./scripts/env up</code>이 맡습니다.</p>
<p>알아 둘 만한 게이트가 둘 더 있습니다. 브랜치 이름 필터(<code>branches: ['feature/**', 'fix/**']</code>)는 PR이 필요 없지만 모든 기능 브랜치가 비용을 치릅니다. &ldquo;드래프트가 아닌 모든 PR&quot;은 가장 자동적이고, 팀이 PR을 늦게, 준비됐을 때만 연다면 맞는 선택입니다. 저는 라벨을 골랐습니다. 비용 결정을 맥락을 아는 사람에게 두고, 아무것도 닫지 않고 되돌릴 수 있기 때문입니다. 마음에 드는 부작용 하나. 포크에서 온 <code>pull_request</code> 실행은 시크릿을 받지 못하니, 포크는 환경을 띄울 수 없습니다.</p>
<p><code>concurrency</code>는 보기보다 중요합니다. 한 브랜치에 push가 연달아 오면 앞선 배포를 경주시키는 대신 취소합니다. 같은 앱에 <code>fly deploy</code> 둘이 동시에 돌면 하나가 지는 걸로 끝납니다.</p>
<p>PR이 있던 것은 머지됐든 아니든 <code>closed</code>가 철거를 맡습니다. <code>delete</code> 이벤트는 <code>workflow_dispatch</code>로 띄웠는데 끝내 PR이 생기지 않은 브랜치를 위한 뒷받침이고, 브랜치가 실제로 삭제될 때만 발생합니다. 그건 저장소 설정 <em>Automatically delete head branches</em>이고, 기본은 꺼져 있습니다. 켜 두면 Merge를 누르는 것이 PR을 닫고 브랜치를 삭제하니, 두 이벤트 중 어느 하나만으로도 <code>env down</code>이 돌았을 겁니다. 머지 버튼이 철거입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code>이 파일에서 위험한 줄입니다. 앱을 만들고 없앨 수 있으니, <code>$APP</code>이 브랜치 환경이 아닌 무언가가 되는 순간 프로덕션을 없앨 수도 있습니다. 가드는 둘이고 둘 다 두겠습니다. 토큰을 별도의 Fly 조직에 한정해 프로덕션 조직을 말 그대로 볼 수 없게 하는 것(데모에서는 새 조직에 결제 정보를 붙이는 걸 건너뛰려고 프로덕션 조직을 그대로 썼고, 두 번째 가드 하나에 기댔습니다), 그리고 <code>down()</code>이 <code>slug-hash</code> 패턴에 맞지 않는 앱 이름을 전부 거부하는 것.</p>

<h3 class="relative group">브랜치 환경이 스스로 할 수 없는 검사
    <div id="브랜치-환경이-스스로-할-수-없는-검사" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b8%8c%eb%9e%9c%ec%b9%98-%ed%99%98%ea%b2%bd%ec%9d%b4-%ec%8a%a4%ec%8a%a4%eb%a1%9c-%ed%95%a0-%ec%88%98-%ec%97%86%eb%8a%94-%ea%b2%80%ec%82%ac" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치는 자기 마이그레이션만 봅니다. 그래서 중요한 검사는 pull request에서 <code>main</code>을 머지한 상태를 상대로 돌고, <code>main</code>이 요구하는 유일한 상태 검사입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>30초. <code>0042</code> / <code>0042</code> 충돌을 릴리스 당일의 놀람에서 두 번째 개발자 PR의 빨간 체크로 바꿔 주는 것이 이겁니다. 아래 마이그레이션 절에서 실제로 걸리는 모습을 보여줍니다.</p>

<h3 class="relative group"><code>main</code>의 규칙
    <div id="main의-규칙" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#main%ec%9d%98-%ea%b7%9c%ec%b9%99" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치가 한 달을 살거나 누군가 <code>main</code>에 직접 push하면 이 중 무엇도 버티지 못합니다. 규칙은 보호라기보다 환경을 작게, 마이그레이션을 최신으로 유지하기 위한 것입니다.</p>
<ul>
<li><strong><code>main</code>은 보호되고 언제나 배포 가능합니다.</strong> <em>현재 <code>main</code>을 기준으로</em> <code>migrations</code> 검사가 초록인 PR로만 머지합니다(strict 상태 검사이니, 지난주에 초록이던 PR은 <code>main</code>이 움직이면 다시 돕니다). 선형 이력, force-push 금지, 삭제 금지.</li>
<li><strong>기능 브랜치는 짧습니다.</strong> 2주 열려 있는 브랜치는 <code>main</code>보다 2주 뒤처진 환경과, 남의 것과 충돌할 확률이 2주치 더 높은 마이그레이션을 갖습니다. 한 달짜리 기능은 플래그 뒤에서 조각조각 <code>main</code>에 머지합니다. 오래 사는 건 플래그이지 브랜치가 아닙니다.</li>
<li><strong>브랜치 하나, 환경 하나, 데이터베이스 하나.</strong> 이미 이름이 강제합니다.</li>
<li><strong>프로덕션에 가는 건 릴리스 태그뿐입니다.</strong> 이미지는 태그에서 한 번 빌드되고, 스테이징과 프로덕션은 그 이미지를 돌립니다. 다시 빌드하지 않습니다.</li>
<li><strong><code>migrations/</code>를 건드리는 PR은 나머지 둘 중 하나가 리뷰합니다.</strong> 아래에.</li>
</ul>
<p>룰셋으로는, <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code>으로 한 번 적용합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>솔직한 주름 하나. 이 저장소는 GitHub Free 플랜의 비공개 저장소이고, Free에서 브랜치 보호와 룰셋은 공개 저장소에서만 쓸 수 있습니다. API는 <code>403 Upgrade to GitHub Pro or make this repository public</code>이라고 답합니다. <code>delete_branch_on_merge</code> 설정, environment, 워크플로는 Free에서도 전부 됩니다. 그러니 비공개 Free 저장소의 작은 팀에게 위 룰셋은 관례입니다. <code>migrations</code> 워크플로는 여전히 돌고 여전히 빨개지지만, 머지 버튼을 회색으로 만드는 것은 없습니다. 월 4달러짜리 결정이고, 저라면 두 번째 사람이 머지할 수 있게 되는 순간 하겠습니다.</p>
<p><code>develop</code> 브랜치도 <code>release/*</code> 브랜치도 없습니다. Git-flow는 통합이 비싸서 묶어서 해야 한다는 전제로 설계됐습니다. 브랜치 환경이 통합을 싸게 만드니, 묶음이 사라집니다.</p>

<h3 class="relative group">스테이징은 태그의 환경이고, 프로덕션은 승격이다
    <div id="스테이징은-태그의-환경이고-프로덕션은-승격이다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%8a%a4%ed%85%8c%ec%9d%b4%ec%a7%95%ec%9d%80-%ed%83%9c%ea%b7%b8%ec%9d%98-%ed%99%98%ea%b2%bd%ec%9d%b4%ea%b3%a0-%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98%ec%9d%80-%ec%8a%b9%ea%b2%a9%ec%9d%b4%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>스테이징은 장치 전부를 재사용합니다. <code>main</code>의 <code>v*</code> 태그가 브랜치 이름 <code>staging/v1.42.0</code>으로 <code>scripts/env up</code>을 돌리고, 이름 규칙이 그걸 <code>staging-v1-42-0-&lt;hash&gt;</code>로 바꿉니다. 같은 템플릿 복제 데이터베이스, 같은 놀면 suspend되는 머신, 같은 호스트명 체계. 차이는 그 뒤에 오는 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p><code>production</code> 잡은 필수 리뷰어가 있는 GitHub environment를 기다립니다. 누군가 스테이징을 보고 Approve를 누르면, 프로덕션은 스테이징이 돌린 <em>같은 이미지</em>를 참조로 받습니다. 다시 빌드하지 않습니다. 마지막 단계가 스테이징을 없애는 건 제 할 일을 다 했기 때문입니다. 아무도 승인하지 않으면 릴리스 후보는 거절된 것이고, 스위퍼가 그 &ldquo;브랜치&rdquo;(<code>staging/v1.42.0</code>은 origin에 존재한 적이 없습니다)가 생존 검사에 실패할 때 스테이징을 치웁니다. 스위퍼가 확인할 수 있는 이유 없이는 아무것도 존재할 수 없습니다.</p>

<h3 class="relative group">밤마다 도는 스위퍼
    <div id="밤마다-도는-스위퍼" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b0%a4%eb%a7%88%eb%8b%a4-%eb%8f%84%eb%8a%94-%ec%8a%a4%ec%9c%84%ed%8d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code>은 스위퍼가 Actions가 기본으로 체크아웃하는 커밋 하나가 아니라 모든 원격 브랜치와 마지막 커밋 날짜를 필요로 하기 때문입니다. <code>workflow_dispatch</code>는 사고 뒤에 손으로 돌릴 수 있게 하려는 것입니다.</p>

<h3 class="relative group">개발자가 실제로 하는 일
    <div id="개발자가-실제로-하는-일" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%9c%eb%b0%9c%ec%9e%90%ea%b0%80-%ec%8b%a4%ec%a0%9c%eb%a1%9c-%ed%95%98%eb%8a%94-%ec%9d%bc" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>브랜치를 push하고, PR을 열고, 누군가 봐 줬으면 할 때 <code>env</code> 라벨을 붙입니다. 목록은 그게 전부입니다. 3분 안에 PR에 URL이 나타나고, push마다 갱신되고, PR이 머지되거나 닫히면 사라집니다. 팀원과 충돌했다면 마이그레이션 검사가 누구보다 먼저 알려줍니다. 차지할 스테이징이 없으니 아무도 스테이징을 차지하지 않고, 철거가 곧 머지이니 아무도 뭘 철거하지 않습니다.</p>
<p>그리고 이걸 세팅한 사람이 그 뒤로 브랜치마다 하는 일은, 없습니다. 공유 Postgres, Redis, 버킷은 한 번 프로비저닝하고, 시크릿은 Actions에 한 번 넣고, 그 뒤로 반복되는 일이라곤 월요일에 스위퍼 로그를 읽는 것뿐입니다. 티켓 큐도, 불러야 할 봇도, 프롬프트를 넣을 대상도 없습니다.</p>

<h2 class="relative group">철거를 믿지 않는 철거
    <div id="철거를-믿지-않는-철거" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%b2%a0%ea%b1%b0%eb%a5%bc-%eb%af%bf%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%b2%a0%ea%b1%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><code>env down</code>이 보통의 경우이고, 흥미로운 부분은 <em>누가</em> 치우느냐입니다. Redis 키와 버킷 객체는 공유 서비스의 것이고, <code>down</code>을 돌리는 노트북은 어느 쪽 자격 증명도 필요 없습니다. 환경 자신의 머신이 이미 그걸 갖고 있고 자기 접두사를 이미 알기 때문입니다. 그래서 <code>down</code>은 머신이 suspend돼 있으면 깨우고, 거기서 작은 정리 명령을 돌린 다음에야 앱을 없애고 데이터베이스를 드롭합니다. 환경이 스스로 뒷정리를 합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>530은 조정에 관한 요점을 다시 보여줍니다. Worker는 여전히 호스트명을 라우팅하고, Fly에는 아무것도 없으며, 아무도 Cloudflare에 알릴 필요가 없었습니다.</p>
<p>정리 명령은 <code>ENV_NAME</code> 없이는 실행을 거부합니다. 접두사가 비어 있으면 공유 버킷을 통째로 지우는 일이 되니까요. 그 가드가 이게 셸 스크립트 세 줄이 아니라 앱 안의 명령으로 존재하는 이유 전부입니다.</p>
<p>그래도 환경은 샙니다. GitHub Actions가 장애인 사이에 브랜치가 삭제됩니다. 개발자가 실험용으로 노트북에서 <code>env up</code>을 돌리고 push는 영영 안 합니다. <code>fly apps destroy</code>가 일시적으로 실패하고 <code>|| true</code>가 삼킵니다. 석 달 뒤 앱이 마흔 개이고, 누군가 무슨 일이냐고 묻게 만드는 청구서가 옵니다.</p>
<p>그래서 첫 번째를 믿지 않는 두 번째 장치가 있습니다. 위 표의 밤마다 도는 스위퍼입니다. 모든 <code>myapp-*</code> 앱을 나열하고, 각각이 어느 브랜치 것인지 알아내고, 브랜치가 사라졌거나 7일간 push가 없는 것을 전부 없앱니다. 일주일 논 브랜치에 살아 있는 URL은 필요 없고, 다음 push가 2분 만에 다시 만듭니다. 해시는 되돌릴 수 없으니, 스위퍼는 살아 있는 모든 브랜치의 환경 이름을 계산해 그 집합에 없는 앱을 전부 없앤 뒤, 뒤에 앱이 없는 <code>env_*</code> 데이터베이스를 드롭합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>데모 브랜치는 어느 것도 push된 적이 없으니(위의 노트북 실행), 스위퍼 입장에서는 전부 사라진 것이었습니다. 누군가 노트북에서 띄우고 끝내 push하지 않은 스파이크가 일주일 뒤에 놓일 상태가 정확히 이것입니다. 남은 둘을, tagline 환경이 써 둔 Redis 키까지 포함해 해체했습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>(작게 문 것 하나. macOS에는 연관 배열이 없는 bash 3이 딸려 옵니다. 스위퍼 첫 버전은 그걸 썼다가 제 노트북에서 <code>declare -A</code>에서 죽었습니다. 위 버전은 줄바꿈으로 구분한 목록을 써서 어디서든 돕니다.)</p>
<p>비용은 즐거운 부분입니다. suspend된 머신은 rootfs 값만 내고, 한 달에 몇 센트입니다. 데이터베이스는 이미 값을 치른 볼륨 위의 19 MB입니다. Redis는 종량제 플랜으로, 명령 10만 개에 20센트인데 브랜치 테스트 규모에서는 0으로 반올림됩니다. 버킷 접두사는 저장한 만큼 냅니다. 공유 Postgres 노드가 유일한 고정 항목으로, 한 달에 2달러쯤입니다. 활성 브랜치 스무 개는 항상 켜진 <code>shared-cpu-1x</code> 하나 값쯤이고, 스위퍼가 그걸 이백이 아니라 스물로 유지합니다. 데모 전체, 즉 한 시간 동안의 환경 셋과 공유 부품은 그동안 마신 커피보다 쌌습니다.</p>

<h2 class="relative group">브랜치를 가로지르는 마이그레이션, 경고해 줄 공유 데이터베이스 없이
    <div id="브랜치를-가로지르는-마이그레이션-경고해-줄-공유-데이터베이스-없이" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b8%8c%eb%9e%9c%ec%b9%98%eb%a5%bc-%ea%b0%80%eb%a1%9c%ec%a7%80%eb%a5%b4%eb%8a%94-%eb%a7%88%ec%9d%b4%ea%b7%b8%eb%a0%88%ec%9d%b4%ec%85%98-%ea%b2%bd%ea%b3%a0%ed%95%b4-%ec%a4%84-%ea%b3%b5%ec%9c%a0-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%b2%a0%ec%9d%b4%ec%8a%a4-%ec%97%86%ec%9d%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>개발자마다 기능을 처음부터 끝까지 맡으면 모든 브랜치에 마이그레이션이 들어 있습니다. 그래서 이 절이 중요한 절입니다.</p>
<p>공유 환경 세계를 떠날 때 사람들이 놓치는 것이 있습니다. 모두가 dev 데이터베이스 하나에 배포할 때 마이그레이션 충돌은 즉시, 아프게 드러났지만, 어쨌든 <em>드러났습니다</em>. 마이그레이션 하나가 돌고, 다음 배포가 실패하고, 누군가 그날 오후에 고쳤습니다. 브랜치마다 데이터베이스가 있으면 각 마이그레이션은 자기 환경에서 완벽하게 돌고, 둘은 두 번째가 <code>main</code>에 머지될 때 처음 만납니다. 부주의하게 하면 충돌이 싸던 dev에서 싸지 않은 릴리스로 옮겨 갑니다.</p>
<p>규칙 넷, 전부 CI로 강제할 수 있어서 아무도 기억할 필요가 없습니다.</p>
<p><strong>1. <code>main</code>을 머지한 상태에서 마이그레이션 그래프에 리프가 둘이면 CI가 실패합니다.</strong> <code>makemigrations --check</code>는 모델과 마이그레이션이 어긋난 걸 잡지만, 중요한 충돌은 두 브랜치가 둘 다 <code>0042_*</code>를 추가한 것입니다. 브랜치 단독이 아니라 <code>main</code>에 머지된 브랜치를 상대로 확인합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>이건 제가 일부러 일으킨 것입니다. 데모 브랜치 셋 중 둘, <code>feature/profile-tagline</code>과 <code>feature/profile-website</code>가 각각 마이그레이션 하나로 사용자 프로필 모델에 필드 하나를 추가했고, 각 환경은 올라오면서 자기 것을 적용했습니다. 새 컬럼은 있어야 할 곳에만 있었습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>둘 다 <code>0042_*</code>입니다. 어느 브랜치도 상대 것을 볼 수 없으니, 어느 환경도 문제가 있다고 말해 주지 못합니다. 하나를 다른 하나에 머지하면 검사가 말해 줍니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>두 번째 작성자가 자기 브랜치에서, 자기 환경에서, 아무도 귀찮게 하지 않고 그 <code>--merge</code>를 돌리면, 다음 <code>env up</code>이 자기 복제본 위에 <code>0043</code>을 적용합니다. (이 경우 git이 먼저 텍스트 충돌도 표시했습니다. 두 필드가 모델 파일의 같은 줄에 추가됐기 때문입니다. 현실적이고, 텍스트 충돌이 해소되기 전까지 마이그레이션 충돌을 가린다는 걸 알아 둘 가치가 있습니다.)</p>
<p><strong>2. 마이그레이션은 이전 릴리스의 코드와 하위 호환됩니다.</strong> 배포 중에는 새 스키마와 옛 코드가 공존하는 창이 있고, 머신이 롤백되면 그 반대도 있습니다. 컬럼은 nullable이거나 기본값을 갖게 추가하고, 한 번에 이름을 바꾸지 않으며, 현재 릴리스가 여전히 읽는 것은 절대 드롭하지 않습니다. 추가하고, 배포하고, 백필하고, 코드를 바꾸고, 나중 릴리스에서 드롭합니다. expand and contract입니다. 여기서 <em>더</em> 중요한 이유는 일회용 환경이 배포를 잦게 만들어서 그 창 안에 더 자주 들어가기 때문입니다. 태그별 스테이징이 그걸 리허설하는 곳입니다.</p>
<p><strong>3. CI는 빈 데이터베이스에 0부터 마이그레이션도 합니다.</strong> 템플릿 복제는 &ldquo;내 마이그레이션이 현실적인 데이터에 적용되나&quot;를 테스트합니다. 빈 실행은 &ldquo;전체 체인이 여전히 무에서 되나&quot;를 테스트하고, 이전 데이터 마이그레이션이 뭔가 채워 놨다고 가정한 마이그레이션을 잡습니다. 30초이고, 그러지 않으면 다음 새 노트북을 기다리는 부류의 버그를 잡습니다.</p>
<p><strong>4. 스키마 변경에는 두 번째 눈이 붙습니다.</strong> 작은 팀에는 <code>CODEOWNERS</code> 파일이 필요 없지만, 규칙은 필요합니다. <code>migrations/</code>를 건드리는 PR은 머지 전에 팀원 하나가 리뷰하고, 리뷰어의 일은 SQL을 확인하는 게 아닙니다. 자기나 팀의 다른 누군가가 이번 2주 안에 같은 테이블을 건드리고 있는지 알고, 그렇다고 말하는 것입니다. 개발자가 열댓 명을 넘어가면 그 지식이 한 사람 머리에 다 들어가지 않으니, 그때는 <code>migrations/</code>에 <code>CODEOWNERS</code> 항목을 두는 게 제값을 합니다. 어떤 툴링도 이걸 대신하지 못합니다. 기계적인 건 툴링이 전부 잡아 주니 2분짜리 일이고, 옛 공유 환경 세계에서 간직할 가치가 있는 유일한 부분입니다. 두 사람이 곧 부딪힐 걸 알아차리는 순간.</p>

<h2 class="relative group">버전 관리, URL이 무엇을 돌리는지 말해 주도록
    <div id="버전-관리-url이-무엇을-돌리는지-말해-주도록" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b2%84%ec%a0%84-%ea%b4%80%eb%a6%ac-url%ec%9d%b4-%eb%ac%b4%ec%97%87%ec%9d%84-%eb%8f%8c%eb%a6%ac%eb%8a%94%ec%a7%80-%eb%a7%90%ed%95%b4-%ec%a3%bc%eb%8f%84%eb%a1%9d" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 환경 하나일 때 &ldquo;스테이징에 뭐 올라가 있어요&quot;는 채팅으로 묻는 질문이었습니다. 여럿이면 환경의 속성이어야 하고, 아니면 모든 버그 리포트가 고고학으로 시작합니다.</p>
<ul>
<li><strong>모든 이미지는 커밋 SHA를 지니고 앱이 그걸 노출합니다.</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code>가 이미지에 환경 변수로 찍고, <code>/api/version/</code>이 환경 이름, 리전과 함께 돌려줍니다(위 출력 참고). &ldquo;feature-profile-tagl-fa8832에서 깨져요&quot;에 누구나 제일 먼저 하는 일은 생각한 커밋을 돌리고 있는지 확인하는 것입니다. 첫 데모 환경은 툴링이 커밋되기 한 커밋 전의 작업 트리에서 배포됐고, 엔드포인트가 그렇게 말했습니다. 제가 예상한 SHA가 아니라 이전 커밋을 보고했죠.</li>
<li><strong><code>ENVIRONMENT=$ENV</code>가 모든 로그 줄과 에러 트래커의 environment 태그에 있습니다.</strong> 브랜치 오류가 프로덕션 오류 추적을 더럽히지 않고, 클릭 한 번으로 걸러집니다.</li>
<li><strong>브랜치는 SHA를, 릴리스는 태그를 갖습니다.</strong> <code>feature-payments-retr-3f9a1c</code>에는 버전 번호가 없습니다. 커밋이 있습니다. 스테이징과 프로덕션에는 <code>v1.42.0</code>이 있습니다.</li>
<li><strong>모바일 앱은 URL이 아니라 최소 API 버전을 지닙니다.</strong> dev 메뉴의 URL 필드가 테스터가 실제 폰을 브랜치에 향하게 하는 방법이고, API 스키마 버전을 실은 버전 엔드포인트가 앱이 이해할 수 있는 상대와 통신하는지 아는 방법입니다.</li>
</ul>

<h2 class="relative group">이걸로 해결되지 않는 것
    <div id="이걸로-해결되지-않는-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%b4%ea%b1%b8%eb%a1%9c-%ed%95%b4%ea%b2%b0%eb%90%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>같이 있어야만 말이 되는 기능 둘은 해결하지 못합니다. 브랜치 환경은 브랜치 하나를 보여줍니다. 답은 둘 다 플래그 뒤에 머지하고 <code>main</code>을 보는 것이고, <code>main</code>을 따라가는 상시 환경 하나(<code>main-dev.example.com</code>, 머지마다 재배포)로 할 수 있습니다. 제가 남겨 둘 유일한 장수 비프로덕션 환경이고, 아무도 거기<em>에</em> 배포하지 않습니다. 다음 릴리스가 무엇이 될지 보는 용도입니다.</p>
<p>등록된 콜백을 요구하면서 와일드카드를 받지 않는 제공자는 해결하지 못합니다. 제가 쓰는 인증 서비스는 상위 도메인을 받으니 <code>example.com</code>이 모든 <code>-dev</code> 브랜치 호스트명을 덮습니다. 일부 OAuth와 결제 제공자는 안 되고, 그런 경우 Worker가 마지막으로 차지한 환경으로 라우팅하는 공유 호스트명 하나를 둡니다. 작은 것에 걸린 작은 락이고, 모든 것에 걸린 락보다 훨씬 낫습니다.</p>
<p>마이그레이션의 사람 쪽은 해결하지 못합니다. 툴링은 기계적 충돌을 잡습니다. 같은 2주 안에 둘이 같은 개념을 양립 불가능하게 모델링하는 건 잡지 못하고, 그걸 고치는 유일한 방법은 한 명이 알아차리는 마이그레이션 리뷰입니다.</p>

<h2 class="relative group">당신의 환경에서 확인해 볼 것
    <div id="당신의-환경에서-확인해-볼-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8b%b9%ec%8b%a0%ec%9d%98-%ed%99%98%ea%b2%bd%ec%97%90%ec%84%9c-%ed%99%95%ec%9d%b8%ed%95%b4-%eb%b3%bc-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>공유 dev나 스테이징이 있고 그걸 예약하는 채팅 스레드가 있다면:</p>
<ol>
<li>완성된 브랜치가 작성자 아닌 누군가가 도는 걸 볼 수 있기까지 얼마나 기다립니까? 한 시간 넘으면 그 락 값을 전달 시간으로 치르고 있는 것이고, 작은 팀이면 팀의 상당 부분이 기다리는 겁니다.</li>
<li>공유 데이터베이스는 얼마나 자주 초기화되고, 그때 누가 작업을 잃습니까?</li>
<li>새 개발자가 첫날에 누구에게 아무것도 묻지 않고 자기 브랜치가 도는 공개 URL을 얻을 수 있습니까? 물어야 하는 것이 무엇이든, 그게 제일 먼저 자동화할 것입니다.</li>
<li>새 환경은 현실적인 데이터에서 시작합니까? 빈 상태에서 시작하면, 처음으로 못 잡을 버그는 빈 테이블에서는 멀쩡하고 실제 테이블에서는 40분 걸리는 마이그레이션입니다.</li>
<li>비프로덕션 호스팅에 모두가 잊고 한 달 뒤에도 여전히 존재할 것이 있습니까? 있다면 다른 무엇보다 먼저 스위퍼를 쓰세요. 누수는 첫날부터 시작됩니다.</li>
</ol>
<p>채팅 스레드의 그 질문이 사라지는 건 사람들이 묻기를 그만둬서가 아니라, 답이 언제나 &ldquo;네, 당신 것에서요&quot;이기 때문입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>플랫폼 종속 YAML 대신 Docker Compose로 CI/CD 하기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>빌드와 테스트 로직을 GitHub Actions YAML이 아닌 Docker Compose 파일에 두면 이식성도 좋고, 로컬 테스트도 되고, 유지보수도 훨씬 간단해집니다.</description>
      <content:encoded><![CDATA[<p><em>2026년 10월 업데이트: 2년이 지나 제가 실제로 배포하는 저장소를 하나하나 다시 살펴보고, 이 조언 중 무엇이 살아남았는지 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >GitHub Actions로 CI/CD 파이프라인 만들기</a>에 정리했습니다. 원칙은 버텼고, CI 안에서 Compose를 돌리는 방식은 대부분 그러지 못했습니다.</em></p>
<p>빌드와 테스트 단계를 GitHub Actions YAML에 직접 쓰는 팀이 많습니다. 그 자체로는 잘 돌아가죠. 문제는 같은 빌드를 내 노트북에서 돌려보고 싶어질 때, 혹은 누군가 &ldquo;GitLab으로 옮기면 어때요?&ldquo;라고 말하는 순간입니다. 파이프라인 전체가 특정 벤더의 설정 포맷에 용접돼 있다는 걸 그제야 깨닫게 되니까요. 요즘 제 방식은 꽤 심심합니다. 진짜 로직은 Docker Compose에 두고, CI 도구는 그걸 호출하는 역할만 시키는 겁니다.</p>

<h4 class="relative group">빌드 로직을 Actions YAML에 넣지 않는 이유
    <div id="빌드-로직을-actions-yaml에-넣지-않는-이유" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%8c%eb%93%9c-%eb%a1%9c%ec%a7%81%ec%9d%84-actions-yaml%ec%97%90-%eb%84%a3%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h4>
<p>가장 뻔한 문제는 종속성입니다. Actions 워크플로우는 GitLab, Bitbucket, Jenkins 어디에서도 통하지 않으니, 빌드 단계가 워크플로우 파일 안에 있으면 플랫폼을 바꿀 때 전부 다시 써야 합니다. 그런 작업에 시간을 배정해 주는 조직은 본 적이 없네요.</p>
<p>더 은근하게 괴로운 문제는 파이프라인을 로컬에서 돌릴 수 없다는 점입니다. 빌드 수정을 확인할 방법이 &ldquo;커밋 푸시하고 Actions 탭 쳐다보기&quot;뿐이면, git 히스토리는 금세 &ldquo;fix ci&rdquo;, &ldquo;fix ci again&rdquo; 같은 커밋으로 가득 찹니다. Compose 파일은 제 노트북에서나 러너에서나 똑같이 돌아가니, &ldquo;제 컴퓨터에서는 되는데요&rdquo; 같은 논쟁도 대부분 시작 전에 끝나고요.</p>
<p>그리고 YAML은 자랍니다. 처음엔 깔끔한 20줄이던 것이 캐싱 트릭과 조건문이 붙으면서 300줄이 되고, 개발자들은 Git에 더해 CI 방언까지 배워야 하죠. 스크립트 하나는 한 가지 일만 해야 하는데, 거대한 워크플로우 파일은 모든 걸 한꺼번에 떠안기 마련입니다.</p>
<p>Compose를 쓰면 이 분담이 깔끔해집니다. 빌드와 테스트는 Compose 파일의 책임, CI 플랫폼은 푸시가 있을 때마다 그걸 실행해 주는 존재일 뿐입니다.</p>

<h4 class="relative group">설정 방법
    <div id="설정-방법" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a4%ec%a0%95-%eb%b0%a9%eb%b2%95" aria-label="앵커">#</a>
    </span>
    
</h4>
<ol>
<li>먼저 <code>Dockerfile</code>을 만들어 이미지 빌드 방법을 한곳에 모읍니다:</li>
</ol>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># 공식 Node.js 런타임을 베이스 이미지로 사용</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">FROM node:14</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># 컨테이너 내 작업 디렉토리 설정</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">WORKDIR /app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># package.json 및 package-lock.json 파일을 컨테이너로 복사</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY package*.json ./</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># 종속성 설치</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">RUN npm install</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># 나머지 애플리케이션 코드를 컨테이너로 복사</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY . .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># 앱이 실행되는 포트 노출</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">EXPOSE 3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># 애플리케이션을 실행하는 명령 정의</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">CMD [&#34;npm&#34;, &#34;start&#34;]</span></span></span></code></pre></div></div>
<ol start="2">
<li>
<p>다음으로 빌드와 테스트에 필요한 서비스를 정의하는 <code>docker-compose.build.yaml</code>을 만듭니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">myprivaterepo.com/image:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">sh -c &#34;npm install &amp;&amp; npm test&#34;</span></span></span></code></pre></div></div>
</li>
<li>
<p>로컬에서 먼저 돌려봅니다. 사실 이게 이 방식의 핵심이죠.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">docker compose -f docker-compose.build.yaml build</span></span></code></pre></div></div>
</li>
<li>
<p>여기까지 동작하면 GitHub Actions 워크플로우(다른 CI 플랫폼이라도 마찬가지입니다)는 단순한 오케스트레이터로 줄어듭니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">CI/CD Pipeline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Set up Docker Buildx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/setup-buildx-action@v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and Test with Docker Compose</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Push the image referenced in the docker compose file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml push</span></span></span></code></pre></div></div>
</li>
</ol>
<p>이제 CI 도구가 하는 일은 빌드와 푸시, 딱 두 가지입니다. 이해할 가치가 있는 부분은 전부, 어떤 플랫폼에서든 어떤 새 팀원이든 바로 실행해 볼 수 있는 파일 안에 있습니다.</p>

<h4 class="relative group">이미지 푸시
    <div id="이미지-푸시" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%b4%eb%af%b8%ec%a7%80-%ed%91%b8%ec%8b%9c" aria-label="앵커">#</a>
    </span>
    
</h4>
<p>Compose 파일의 반가운 부수 효과 하나는 푸시가 아주 쉬워진다는 겁니다. Docker는 이미지 이름을 보고 푸시할 곳을 판단합니다. <code>image: myprivaterepo.com/image:latest</code>라면 <code>myprivaterepo.com</code>으로 푸시됩니다. 프라이빗 레지스트리라면 액세스 토큰이 필요하니, 그 부분은 사용하는 레지스트리 서비스의 문서를 참고하세요.</p>
<p>지금 GitHub Actions에 만족하고 있다면 서두를 이유는 없습니다. 다만 언젠가 공급자를 바꾸는 날이 오면, 마이그레이션이 &ldquo;YAML 300줄 이식&quot;이 아니라 &ldquo;명령어 두 개 복사&quot;로 끝납니다. 그리고 그날이 오기 전까지는, 빌드 디버깅을 커밋 스팸 없이 로컬에서 차분히 할 수 있고요.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
