<?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>Python · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/python/</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/python/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>닫힌 풀 리퀘스트, 포크, 그리고 PyPI 패키지: Python을 위한 빠른 HTML→Markdown 변환</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>오픈소스</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>성능</category>
      <description>AI 에이전트용으로 HTML을 Markdown으로 변환하면서 상대 링크를 절대 URL로 풀어야 했습니다. 원본 Rust 크레이트는 제 풀 리퀘스트가 LLM으로 작성됐다는 이유로 받아 줄 수 없었고, 그래서 포크를 만들어 pyhtml2text를 공개했습니다. base_url을 지원하는 Python 바인딩, 새로 만든 html2markdown, 프로파일 기반 최적화(PGO) 휠, 깊이 중첩된 HTML을 선형 시간에 처리하도록 패치한 파서까지. 왜 만들었는지, 무엇이 들어 있는지, 출력을 어떻게 검증했는지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>10월 15일, PyPI에 <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>를 공개했습니다. HTML을 일반 텍스트나 Markdown으로 바꿔 주는 패키지이고, Chris Emerson의 Rust 크레이트인 <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>를 기반으로 합니다. Markdown 변환은 제가 측정한 페이지에서 흔히 쓰는 Python 변환기들보다 3배에서 21배 빨랐습니다. 상대 링크를 페이지 URL 기준으로 풀어 주는 기능도 있어서, 출력물이 원래 페이지를 떠나도 링크가 그대로 작동합니다.</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">pip install pyhtml2text</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>시작은 rust-html2text에 보낸 풀 리퀘스트였습니다. 메인테이너는 LLM으로 작성된 코드라는 이유로 이를 닫았고, 그래서 pyhtml2text는 포크 위에서 돌아갑니다. 이 글은 그 과정과 포크·패키지에 들어 있는 것, 그리고 Markdown 출력이 맞는지 어떻게 확인했는지에 대한 이야기입니다.</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%99%9c-%ed%95%84%ec%9a%94%ed%96%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제가 만드는 마켓플레이스 겸 커뮤니티 앱은 <code>Accept: text/markdown</code> 요청에 HTML 대신 Markdown으로 답합니다. 내비게이션 바와 푸터를 헤치고 다니고 싶지 않은 AI 에이전트와 LLM 클라이언트를 위해서입니다. 엣지 쪽 이야기는 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >크롤러와 캐시에 관한 글</a>에서 다뤘습니다. 앱 데이터에서 나오는 페이지는 HTML이 텅 빈 JavaScript 셸이라 Markdown을 바로 만듭니다. 나머지는 정적 HTML(마케팅, 약관, 블로그 페이지)이고, 이건 변환을 거칩니다.</p>
<p>변환할 때 문제는 링크입니다. 정적 사이트의 페이지에는 <code>/en/posts/...</code>나 <code>../setup/</code> 같은 링크가 가득합니다. 브라우저에서는 문제없습니다. 어느 페이지에서 온 링크인지 브라우저가 알고 있으니까요. 하지만 Markdown을 읽는 에이전트에게는 그런 맥락이 없어서 <code>/login</code>을 따라갈 수 없습니다. 필요한 건 <code>https://example.com/login</code>입니다.</p>
<p>순수 Python으로 작성된 <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>에는 이를 위한 <code>baseurl</code> 옵션이 있어서 처음에는 그걸 썼습니다. 하지만 느리고, 아래에서 다룰 Markdown 문제도 있었습니다. rust-html2text는 빠르고 테스트도 잘 돼 있었지만, 링크를 풀어 주는 기능이 없었습니다.</p>

<h2 class="relative group">먼저 업스트림에 요청하기
    <div id="먼저-업스트림에-요청하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a8%bc%ec%a0%80-%ec%97%85%ec%8a%a4%ed%8a%b8%eb%a6%bc%ec%97%90-%ec%9a%94%ec%b2%ad%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>8월 29일, 사용 사례와 원하는 동작을 적어 <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">이슈 #287</a>을 열었습니다. RFC 3986 방식으로 풀되, 절대 URL과 <code>mailto:</code>, <code>data:</code>, <code>#fragment</code> 링크는 건드리지 않는 동작이었습니다. 몇 분 뒤 <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">풀 리퀘스트 #288</a>을 열었습니다. <code>Config::base_url(...)</code> 옵션을 추가하는 PR이었고, 이슈에는 이게 출발점일 뿐이며 메인테이너가 다른 방식을 원한다면 충분히 이해한다고 적었습니다.</p>
<p>10월 11일, 메인테이너가 PR을 닫았습니다.</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn&rsquo;t previously stated)</p>
</blockquote><p>(기여해 줘서 고맙지만 LLM으로 생성된 것으로 보여 받을 수 없다, 이전에는 명시돼 있지 않던 간단한 정책을 이제 README.md에 추가했다는 내용입니다.)</p>
<p>그리고 이슈에는 이렇게 답했습니다.</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>(합리적인 기능 요청이라 고맙다, PR은 LLM으로 작성된 것으로 보여 받을 수 없어 닫았지만 시도해 줘서 고맙다, 짧은 HTML 예시와 기대하는 출력이 있으면 기능 구현에 분명 도움이 될 거라는 내용입니다.)</p>
<p>README에는 이제 이런 정책이 있습니다.</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>(현재 이 프로젝트는 저작권 지위가 불확실하기 때문에 생성형 AI나 LLM으로 만든 코드 기여를 받을 수 없다는 내용입니다.)</p>
<p>판단은 정확했습니다. 그 코드는 제가 AI 코딩 도구로 작성했습니다. 저는 이 정책이 무리하다고 생각하지도 않습니다. LLM 출력물의 저작권 문제는 아직 정리되지 않았고, 다른 프로젝트들이 의존하는 MIT 크레이트에 그런 코드를 병합했을 때의 위험은 메인테이너가 떠안게 됩니다. 그분의 프로젝트이고, 답변도 정중했습니다. 기능 요청 이슈는 지금도 열려 있습니다.</p>
<p>그래도 저는 그 기능이 필요했고, 메인테이너와 그 프로젝트의 기여 정책을 두고 논쟁하는 건 기능을 얻는 좋은 방법이 아닙니다. 그래서 변경 사항은 포크에 두고 그 위에 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="#%ed%8f%ac%ed%81%ac%ec%97%90-%eb%93%a4%ec%96%b4-%ec%9e%88%eb%8a%94-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>포크는 제 rust-html2text 포크의 <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> 브랜치</a>입니다. 업스트림 위로 깔끔하게 리베이스할 수 있도록 작은 커밋 세 개로만 유지하고 있습니다. 핵심은 PR에 있던 <code>Config::base_url</code> 옵션입니다. 상대 링크를 <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>에 따라 풀고, base URL을 주지 않으면 출력은 그대로입니다. 또 하나는 크레이트가 호출될 때마다 스타일 규칙을 새로 만들지 않게 한 변경인데, 짧은 스니펫 기준 7.3 µs가 5.6 µs가 됐습니다. 큰 페이지에서는 아무것도 아니지만, 몇천 번 호출하면 쌓입니다. 마지막 하나는 아래의 Markdown 변환기가 크레이트에서 필요로 하는 부분을 공개할 뿐입니다.</p>

<h2 class="relative group">Python 패키지
    <div id="python-패키지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#python-%ed%8c%a8%ed%82%a4%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a>는 PyO3와 maturin으로 만들었고, 함수는 두 개입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code>는 rust-html2text의 일반 텍스트 렌더러로, 터미널과 이메일의 <code>text/plain</code> 파트를 위해 만들어진 것입니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>바인딩에서 잘 드러나지 않는 점 몇 가지:</p>
<ul>
<li><strong>두 함수 모두 <code>str</code>이나 UTF-8 <code>bytes</code>를 받습니다.</strong> 그래서 <code>response.content</code>를 디코딩하지 않고 바로 넘길 수 있습니다. 입력은 복사하지 않고 빌려 씁니다.</li>
<li><strong>변환하는 동안 GIL을 풉니다.</strong> 처음부터 끝까지 순수 Rust라서 스레드가 여러 코어로 확장됩니다. 모듈이 GIL이 필요 없다고 선언하기 때문에, free-threaded Python 3.14t에서 모듈을 임포트해도 GIL이 다시 켜지지 않습니다.</li>
<li><strong>패닉은 <code>ValueError</code>로 돌아옵니다.</strong> PyO3는 기본적으로 <code>PanicException</code>을 던지는데, 이건 <code>except Exception</code>으로 잡히지 않습니다. 변환 오류를 처리했다고 생각하는 웹 워커가 여기서 죽어 버리니, 바인딩에서 잡아서 <code>ValueError</code>로 다시 던집니다.</li>
<li><strong>휠은 30개입니다.</strong> CPython 3.10 이상용 abi3 휠과 free-threaded 3.14t 휠을, Linux(glibc와 musl), macOS, Windows에서 maturin CI가 빌드할 수 있는 모든 아키텍처에 대해 제공합니다. 그래서 설치할 때 Rust 툴체인이 필요 없습니다.</li>
</ul>

<h2 class="relative group">빠르게 만들기
    <div id="빠르게-만들기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b9%a0%eb%a5%b4%ea%b2%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>0.2.0은 실제 페이지에서 0.1.0보다 1.5~1.7배, 짧은 스니펫에서는 두 배 빨랐고, 출력은 바이트 단위까지 똑같았습니다. 대부분은 프로파일 기반 최적화(PGO) 덕분입니다. CI가 계측용 휠을 빌드해서 문서 페이지와 RFC로 이뤄진 학습 세트를 돌리고, 그 프로파일로 실제 휠을 빌드합니다. 학습 세트에 없는 페이지에서 약 25% 빨라지며, CI가 계측 빌드를 네이티브로 돌릴 수 있는 플랫폼에만 적용됩니다. 나머지는 작은 할당이 아주 많을 때 훨씬 잘 버티는 mimalloc 할당자로 바꾼 것, 그리고 포크의 스타일 규칙 변경에서 나왔습니다.</p>
<p>속도를 올리면서 아무것도 바뀌지 않았는지 확인하려고, 실제 페이지 코퍼스를 모든 옵션 조합으로 변환한 출력의 해시를 기록했습니다. 빈 입력이나 아주 깊은 중첩 같은 엣지 케이스도 넣었고요. 모든 변경은 해시를 하나도 바꾸지 않아야 했습니다.</p>
<p>Apple Silicon Mac에서 Markdown으로 변환한 중앙값 시간입니다.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>백과사전 문서 (1 MB)</th>
					<th>뉴스 사이트 첫 페이지 (370 KB)</th>
					<th>Python 문서 페이지 (245 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (순수 Python)</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>일반 텍스트로 변환한 경우입니다.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>백과사전 문서 (780 KB)</th>
					<th>뉴스 사이트 첫 페이지 (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (순수 Python)</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">깊이 중첩된 HTML, 그리고 html5ever 패치
    <div id="깊이-중첩된-html-그리고-html5ever-패치" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b9%8a%ec%9d%b4-%ec%a4%91%ec%b2%a9%eb%90%9c-html-%ea%b7%b8%eb%a6%ac%ea%b3%a0-html5ever-%ed%8c%a8%ec%b9%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>40,000단계로 중첩된 목록을 손으로 쓰는 사람은 없습니다. 하지만 남이 쓴 페이지를 변환하다 보면 언젠가는 악의적이거나 망가진 무언가가 그런 걸 보내옵니다. 기본 파서로는 40,000단계 중첩된 <code>&lt;ul&gt;&lt;li&gt;</code>를 파싱하는 데 <strong>6초</strong>가 걸렸습니다. 요청 하나에 워커 하나가 그만큼 묶이는 겁니다.</p>
<p>원인은 rust-html2text가 쓰는 HTML5 파서인 <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>에 있었습니다. 대부분의 블록 레벨 시작 태그는 열린 <code>&lt;p&gt;</code> 요소가 &ldquo;button scope 안에&rdquo; 있는지 확인해야 하는데, html5ever는 이 질문에 답하려고 열린 요소 스택 전체를 훑었습니다. 중첩이 깊어질수록 태그 하나당 시간이 늘었고, 그래서 전체 시간은 깊이의 제곱에 비례해 커졌습니다.</p>
<p>패키지에는 패치 하나를 적용한 html5ever 0.39.0이 들어 있습니다. 요소가 열리고 닫힐 때마다 그 답을 갱신해 두기 때문에, 확인하는 데 드는 시간이 더 이상 깊이에 좌우되지 않습니다. 같은 문서가 이제 <strong>0.14초</strong>에 파싱됩니다. 패치가 다른 것은 아무것도 바꾸지 않았는지 확인하려고, html5lib의 트리 구성 테스트 전체, 실제 페이지 약 20개, 무작위 태그 수프 문서 300,000개에서 기본 파서와 패치한 파서의 DOM을 비교했고, 전부 동일했습니다.</p>
<p>Markdown 변환기에도 같은 이유로 한계를 두었습니다. 어떤 입력도 스레드 스택을 넘치게 할 수 없고, 인용문과 목록은 32단계를 넘으면 더 들여쓰지 않아서 출력이 제곱으로 불어나지 않습니다. 그리고 행·열 병합을 펼친 격자가 100,000칸을 넘는 표는 셀 내용만 씁니다.</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>0.3.0에서 <code>html2markdown</code>이 추가됐고, 패키지 작업의 대부분이 여기에 들어갔습니다. rust-html2text의 렌더러는 Markdown처럼 보이는 텍스트를 만들지만, 터미널 폭에 맞춰 줄바꿈되고 파싱할 용도로 만든 게 아닙니다. 그래서 <code>html2markdown</code>은 파서만 빌려 쓰고, 출력은 직접 <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>에 GitHub 스타일 파이프 표와 <code>~~취소선~~</code>을 더한 형식으로 씁니다.</p>
<ul>
<li><code>#</code> 제목, <code>-</code> 글머리표, <code>_기울임_</code>과 <code>**굵게**</code>.</li>
<li>인라인 링크와 이미지. <code>base_url</code>을 주면 절대 URL로 바꿉니다. 아이콘만 들어 있는 링크는 <code>aria-label</code>이나 <code>title</code>을 링크 텍스트로 씁니다.</li>
<li>펜스 코드 블록. 페이지가 언어를 지정했으면(<code>class=&quot;language-python&quot;</code>) 언어 태그를 붙입니다.</li>
<li>파이프 표. 여러 행이나 열에 걸친 셀은 빈 셀이 되고, 헤더 행이 없는 표에는 빈 헤더를 붙이며, 레이아웃용 표는 문단으로 씁니다.</li>
<li>줄바꿈은 하지 않습니다. 숨겨진 콘텐츠(<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), 스크립트, 스타일, 폼 컨트롤은 뺍니다.</li>
</ul>

<h3 class="relative group">필요한 것만 이스케이프하기
    <div id="필요한-것만-이스케이프하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%84%ec%9a%94%ed%95%9c-%ea%b2%83%eb%a7%8c-%ec%9d%b4%ec%8a%a4%ec%bc%80%ec%9d%b4%ed%94%84%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>어떤 HTML→Markdown 변환기는 Markdown 문법이 될 수 있는 문자를 전부 이스케이프합니다. 예를 들어 markdownify는 기본적으로 모든 <code>_</code>와 <code>*</code>를 이스케이프합니다. 결과는 정확하지만 <code>snake\_case</code>, <code>5 \* 3</code> 같은 표기로 가득해서, 사람이 읽기 어렵고 모델에게는 토큰 낭비입니다. pyhtml2text는 CommonMark 파서가 문법으로 읽을 자리에서만 문자를 이스케이프합니다.</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">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">한국어 조사 앞의 굵은 글씨
    <div id="한국어-조사-앞의-굵은-글씨" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%a1%b0%ec%82%ac-%ec%95%9e%ec%9d%98-%ea%b5%b5%ec%9d%80-%ea%b8%80%ec%94%a8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>이건 제 앱의 페이지에서 직접 겪은 문제입니다. 한국어에서는 조사가 띄어쓰기 없이 명사에 바로 붙으니, 괄호가 들어간 단어를 굵게 하면 이런 HTML이 나옵니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>가장 자연스러운 Markdown은 <code>**타이타닉(Titanic)**의</code>이고, markdownify도 이렇게 씁니다. 그런데 이건 작동하지 않습니다. CommonMark의 flanking 규칙에 따르면 <code>**</code>가 문장 부호 뒤에서 닫히려면 그 뒤에 공백이나 또 다른 문장 부호가 와야 합니다. <code>의</code>는 글자이므로 별표가 그대로 글자로 남습니다. 순수 Python html2text는 공백을 넣어서 이 문제를 피합니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>굵게는 표시되지만 한국어가 달라집니다. 조사를 띄어 쓴 셈이니 맞춤법이 틀린 문장이 됩니다. pyhtml2text는 대신 문장 부호를 마커 바깥으로 옮깁니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>닫는 괄호는 더 이상 굵지 않지만, 강조는 제대로 파싱되고 원문의 글자는 하나도 빠지지 않습니다. 일본어와 중국어, 그리고 뒤에 오는 말 앞에 공백을 두지 않는 모든 언어에 똑같이 적용됩니다.</p>

<h3 class="relative group">다시 읽어서 출력 검증하기
    <div id="다시-읽어서-출력-검증하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8b%a4%ec%8b%9c-%ec%9d%bd%ec%96%b4%ec%84%9c-%ec%b6%9c%eb%a0%a5-%ea%b2%80%ec%a6%9d%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>단위 테스트를 통과했다고 변환기를 믿지는 않습니다. Markdown 출력의 버그는 아무도 테스트를 써 볼 생각을 못 한 조합에서 나오니까요. 그래서 출력을 다시 읽어 보는 방식으로 확인합니다.</p>
<p>변환하는 동안 문단마다 CommonMark로 다시 파싱해서 강조가 의도한 대로 읽히는지 확인합니다. 그렇지 않으면 다른 강조 스타일(<code>_</code> 대신 <code>*</code>, 또는 그 반대)로 다시 쓰고, 둘 다 안 되면 텍스트, 링크, 코드는 남기고 서식만 버려서 단어가 하나도 사라지지 않게 합니다. 테스트 스위트는 다른 Markdown 파서로 왕복 검사를 해서, 보이는 모든 단어, 링크 대상, 이미지 소스를 원본 HTML과 비교합니다. 이스케이프되지 않은 문법에 먹힌 텍스트, 파싱되지 않은 링크, 엉뚱한 <code>*</code>가 모두 차이로 드러납니다. 실제 페이지와 수만 개의 무작위 문서에 대해 돌리고, 실패하면 최소 사례로 줄여 줍니다.</p>

<h2 class="relative group">프로덕션 적용
    <div id="프로덕션-적용" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%94%84%eb%a1%9c%eb%8d%95%ec%85%98-%ec%a0%81%ec%9a%a9" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제 앱의 Markdown 협상(negotiation)은 0.3.0이 나온 날 순수 Python html2text에서 pyhtml2text로 바뀌었습니다. 가이드 페이지 하나 기준으로 변환 시간이 4.2 ms에서 0.36 ms로, 약 12배 빨라졌습니다. 링크는 절대 URL이 됐고, 표는 진짜 파이프 표가 됐으며, 한국어 굵은 글씨 뒤의 조사도 붙은 그대로입니다. abi3 휠이 이미 x86_64와 arm64의 Python 3.14를 지원하기 때문에, 컨테이너 이미지에는 설치를 위해 새로 추가할 게 없었습니다.</p>

<h2 class="relative group">크레딧, 그리고 버그 제보처
    <div id="크레딧-그리고-버그-제보처" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%81%ac%eb%a0%88%eb%94%a7-%ea%b7%b8%eb%a6%ac%ea%b3%a0-%eb%b2%84%ea%b7%b8-%ec%a0%9c%eb%b3%b4%ec%b2%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>일반 텍스트 렌더링은 Chris Emerson의 rust-html2text이고, MIT 라이선스입니다. pyhtml2text는 여기에 그 포크와 패치한 html5ever 사본(MIT/Apache-2.0), 그리고 Markdown 변환기를 더한 것입니다. 이 패키지는 두 프로젝트 어디와도 제휴하거나 보증받은 관계가 아니니, <strong>문제는 업스트림이 아니라 <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text 이슈 트래커</a>에 제보해 주세요.</strong> 이 코드의 버그는 제가 고칠 몫이고, 업스트림 메인테이너가 받지 않기로 한 코드에 대한 제보를 받게 해서는 안 되니까요.</p>
<p>Python에서 HTML을 변환하고 있다면, 특히 모델이 읽을 용도라면 한번 써 보세요.</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">pip install pyhtml2text</span></span></code></pre></div></div>
<p>소스는 <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>에 있고, 0.1.0부터 0.3.0까지의 변경 사항은 changelog에 정리돼 있습니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io에서 Django 오토스케일링: 부하 테스트로 작은 머신 한 대의 한계 찾기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>Django 앱 두 개를 Fly.io의 shared-cpu-1x 머신에서 돌립니다. 머신 한 대를 빠르게 만들고, 부하 테스트로 무너지는 지점을 찾고, 그 앞에 오토스케일 임계값을 두는 방법입니다.</description>
      <content:encoded><![CDATA[<p>Fly.io에서 Django를 싸게 돌리려고, 저는 shared-cpu-1x 작은 머신 한 대를 최대한 빠르게 만들고, 지연 시간이 무너질 때까지 부하 테스트를 한 뒤, 그 직전에 Fly 오토스케일러가 두 번째 머신을 띄우도록 설정합니다. 부하 테스트는 단계적으로 부하를 올리는 Locust(다른 앱에서는 동시성을 바꿔 가며 돌리는 작은 Python 스크립트)로 하고, p95 지연 시간과 에러율을 봅니다. 트래픽이 줄면 Fly가 유휴 머신을 다시 멈추기 때문에, 평소 비용은 앱당 작은 VM 한 대입니다.</p>
<p>저는 현재 1인 개발자로 두 개의 프로덕트를 운영하고 있습니다. 하나는 현장에서 견적을 내는 기술자들을 위한 <strong>현장 견적 앱</strong>이고, 다른 하나는 게시글 등록과 실시간 메시징을 갖춘 <strong>마켓플레이스 겸 커뮤니티 앱</strong>입니다.</p>
<p>프로덕트도 다르고 사용자도 다르지만, 기술 스택은 거의 동일합니다. 그리고 둘 다 Fly.io에서 가장 저렴한 티어인 <strong>shared-cpu-1x 머신</strong>에서만 돌아갑니다. 이 제약은 의도적인 것입니다. 피크 부하에 맞춰 서버를 크게 잡는 대신, 작은 머신 한 대에서 뽑아낼 수 있는 성능을 최대한 끌어올리고, 정확히 어디서 무너지는지 측정한 다음, 그 측정된 한계를 기준으로 Fly의 오토스케일러가 머신을 추가하고 제거하도록 맡깁니다. 이 글에서는 이를 가능하게 하는 최적화들, 임계값의 근거가 된 부하 테스트 수치, 그리고 Fly.io에서 스케일 업/다운이 실제로 어떻게 동작하는지 다룹니다.</p>

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

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

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

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

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

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

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

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

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

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

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

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

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

<h2 class="relative group">가져가서 쓸 만한 것
    <div id="가져가서-쓸-만한-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%80%ec%a0%b8%ea%b0%80%ec%84%9c-%ec%93%b8-%eb%a7%8c%ed%95%9c-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이식 가능한 아이디어는 특정 라이브러리가 아니라 루프 자체입니다. 저렴한 머신 한 대를 진짜로 빠르게 만들고(Rust 핫 패스, 커넥션 재사용, 진짜 병목 앞의 캐시, CDN과 클라이언트로 오프로드), 정확히 어느 동시성에서 무너지는지 찾을 때까지 부하 테스트를 하고, 오토스케일러의 soft limit을 그 변곡점에 놓고, 유휴 머신은 정지시키고, 최소 플릿은 1로 유지합니다. 프로덕션 앱 두 개, 실시간 기능, AI 어시스턴트 — 그런데 평상시 청구서는 플랫폼에서 가장 저렴한 VM 두어 대 값입니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>그림 가격은 무엇이 결정할까? 미술품 경매 가격을 예측하는 알고리즘 만들기</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-04-01-art-evaluation-algorithm/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-04-01-art-evaluation-algorithm/</guid>
      <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Machine Learning</category>
      <category>XGBoost</category>
      <category>Art Market</category>
      <category>Python</category>
      <category>Data Science</category>
      <description>미술품 경매 가격은 무엇이 결정할까요? 경매 34,200건을 다룬 2024년 XGBoost 연구를 재현해 보니, 시장이 값을 매기는 건 작품이 아니라 작가였습니다. 어떤 특징을 썼고 결과가 어땠는지, 그리고 그 위에 만든 미술품 가치 평가 플랫폼까지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>경매에서 그림 가격은 무엇이 결정할까요? 대부분은 그림이 아니라 작가입니다. 현대 미술 경매 34,200건을 다룬 2024년 연구에서, 작품만 본 모델은 가격 분산의 5% 정도밖에 설명하지 못했습니다. 작가의 경력, 전시 이력, 과거 판매가로 만든 모델은 73% 정도를 설명했고, 경매소의 사전 추정가를 더하자 92%까지 올라갔습니다. 이 결과를 제가 어떻게 재현했는지, 그리고 알고리즘으로 미술품 가치를 평가한다는 게 그래서 무슨 뜻인지 정리했습니다.</p>
<p>지난 몇 주 동안 서로 얽힌 프로젝트 두 개에 매달렸습니다. 첫 번째는 2024년 <em>Scientific Reports</em>에 실린 Lee et al.의 논문 &ldquo;Social signals predict contemporary art prices better than visual features, particularly in emerging markets&quot;의 재현입니다. 생존 작가 590명의 경매 기록 34,200건으로 XGBoost 모델을 훈련하는 내용이죠. 두 번째는 미술 플랫폼을 만드는 일이었는데, 작가·소장자·경매 기록을 잇는 지식 그래프가 핵심입니다. 한마디로, 저런 모델로 뭔가 쓸모 있는 일을 하려면 필요해지는 데이터 배관입니다.</p>
<p>두 프로젝트 모두 결국 같은 질문으로 돌아왔습니다. 미술품이 얼마에 팔릴지 정말 예측할 수 있는가? 답은 &lsquo;할 수 있다&rsquo;입니다. 다만 작품을 들여다봐서는 안 됩니다. 효과가 있는 특징은 거의 전부 사회적인 것이었습니다.</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%af%b8%ec%88%a0%ed%92%88-%ea%b0%92-%eb%a7%a4%ea%b8%b0%ea%b8%b0%ea%b0%80-%ec%96%b4%eb%a0%a4%ec%9a%b4-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>코드를 짜기 전에 감정인과 경매소가 실제로 어떻게 가격을 매기는지 며칠 동안 읽었습니다. 고려하는 요소는 많고, 대부분 깔끔하게 정량화되지 않습니다. 프로비넌스, 즉 누가 소유해 왔고 어디에 전시됐고 어떤 카탈로그에 실렸는지. 귀속과 진위 문제도 큽니다. 작가 재단이 진품으로 확인한 작품은 그저 &ldquo;~의 작품으로 전해지는&rdquo; 작품보다 자릿수가 다르게 비쌉니다. 개인전, 비엔날레, 미술관 소장, 비평으로 쌓이는 작가의 명성. 여기에 보존 상태와 복원 이력, 작가 작품 세계 안에서의 희소성, 매체까지. 캔버스 유화는 종이 작업보다, 종이 작업은 판화보다 비싸게 팔리는 경향이 있습니다. 대형 미술관이 소장하고 있다면 2차 시장이 신뢰하는 도장이 찍힌 셈이고요. 타이밍도 중요합니다. 5년 전 비교 사례는 이미 낡았습니다.</p>
<p>경매소 전문가들은 &ldquo;감정적 가치&rdquo; 이야기도 합니다. 컬렉터의 욕망, 호가 경쟁의 역학, 개인적인 울림 같은 것들이죠. 이런 요소는 합리적인 추정을 한참 넘겨 낙찰가를 밀어 올리기도 합니다. 직접 모델링할 수는 없지만, 경매소 추정가를 통해 간접적으로 그 윤곽을 잡을 수는 있습니다.</p>
<p>실무에서 지배적인 평가 방법은 비교 매매 분석입니다. 같은 작가의 비슷한 작품이 최근 경매에서 얼마에 팔렸는지 찾아서 차이를 보정하는 것이죠. XGBoost 모델이 하는 일도 본질적으로 같습니다. 다만 인간 감정인이 손으로 따라갈 수 있는 규모와 특징 수를 훨씬 넘어설 뿐입니다.</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%85%bc%eb%ac%b8%ec%9d%98-%ed%95%b5%ec%8b%ac-%ec%a3%bc%ec%9e%a5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Lee et al.의 주장은 직관에 반합니다. 작품의 시각적 내용은 가격을 거의 설명하지 못한다는 것입니다. 시각적 특징만(색상, 구성, 엣지 밀도, ResNet18 임베딩) 쓴 모델은 R² 약 0.055. 평균값을 찍는 것보다 조금 나은 수준입니다.</p>
<p>반면 작가 수준의 &ldquo;사회적&rdquo; 특징만(경력 단계, 전시 이력, 과거 경매가, ArtFacts 순위) 쓴 모델은 R² ≈ 0.73에 도달합니다. 경매소의 사전 추정가를 특징으로 추가하면 0.92까지 올라가고요.</p>
<p>다시 말해 작품 자체는 거의 상관이 없습니다. 누가 만들었는지, 그리고 시장이 그 사람에 대해 이미 뭐라고 말해 왔는지가 중요합니다.</p>
<p>논문의 두 번째 주장은 이 격차가 신흥 시장에서 더 벌어진다는 것입니다. 여기서 신흥 시장이란 미국·영국·프랑스·독일이라는 기존 핵심 바깥의 모든 곳을 뜻합니다. 그런 시장에서는 사회적 신호가 더 크게 작동하고 전문가 추정가는 덜 정확해서, 알고리즘 예측이 가치를 더할 여지가 커집니다.</p>

<h2 class="relative group">논문 재현하기
    <div id="논문-재현하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%85%bc%eb%ac%b8-%ec%9e%ac%ed%98%84%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>받은 과제는 단순했습니다. 1인 개발 MVP로 논문을 재현할 것. 프로덕션 배포 없음, 실시간 데이터 파이프라인 없음. 충실한 재구현, 프로토타입 수준의 코드, 그리고 결과를 정리한 보고서입니다.</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%8d%b0%ec%9d%b4%ed%84%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>논문은 정제된 CSV를 보충 자료로 제공합니다.</p>
<table>
	<thead>
			<tr>
					<th>파일</th>
					<th>행 수</th>
					<th>내용</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Df_mloutfull.csv</code></td>
					<td>86,221</td>
					<td>500개 이상의 컬럼을 가진 원본 데이터셋</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_up_to_2012.csv</code></td>
					<td>34,200</td>
					<td>메인 정제 데이터셋, 1996–2012</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_old_market.csv</code></td>
					<td>29,853</td>
					<td>기존 시장만</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_new_market.csv</code></td>
					<td>4,346</td>
					<td>신흥 시장만</td>
			</tr>
			<tr>
					<td><code>transactions.csv</code></td>
					<td>114,283</td>
					<td>이미지 링크가 포함된 원본 경매 기록</td>
			</tr>
	</tbody>
</table>
<p>전체 약 5GB. RAM에 넉넉히 들어가고, 이 정도 데이터셋의 XGBoost 훈련은 노트북 CPU로 5분이 안 걸립니다. 핵심 모델에 GPU는 필요 없습니다. 이 규모의 표 형식 데이터에서 그래디언트 부스티드 트리는 사실상 공짜로 학습되죠. 최근 몇 년을 신경망 동네에서 보냈다면 잊기 쉬운 사실입니다.</p>

<h3 class="relative group">특징
    <div id="특징" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%8a%b9%ec%a7%95" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>모델은 38개의 특징을 세 범주로 나눠 씁니다.</p>
<p><strong>작가에 대한 정보 (30개):</strong></p>
<ul>
<li>인구통계: 나이, 성별, 학력, 명문대 출신 여부 플래그.</li>
<li>경력: ArtFacts 순위, 개인전, 그룹전, 비엔날레 참가, 수상 이력.</li>
<li>소장: 개인 및 공공 컬렉션 수.</li>
<li>가격 이력: 작가의 최근 5회/10회 판매에서의 평균, 중간값, 최고가, 최저가.</li>
<li>면적 보정 가격 이력 (제곱인치당 가격).</li>
<li>지리: 작가의 활동·거주 지역, 국가 플래그로 인코딩.</li>
<li>매칭 플래그: 작품 장르가 작가의 평소 장르와 일치하는가? 판매 국가가 일치하는가?</li>
</ul>
<p><strong>시장에 대한 정보 (8개):</strong></p>
<ul>
<li>경매소 등급 (1–4).</li>
<li>해당 연도, 해당 국가/지역의 가격 수준 (최소, 평균, 중간값, 최대).</li>
</ul>
<p><strong>작품 자체에 대한 정보 (시각 외):</strong></p>
<ul>
<li>가로, 세로, 제곱인치 면적.</li>
<li>매체 분류 (회화, 판화, 사진, 조각, 기타).</li>
</ul>
<p>이 목록에 빠진 것이 눈에 띄죠. 작품의 실제 시각적 내용에 관한 항목이 하나도 없습니다. 그건 별도의 어블레이션 실험으로만 등장합니다.</p>

<h3 class="relative group">학습/테스트 분할
    <div id="학습테스트-분할" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%99%ec%8a%b5%ed%85%8c%ec%8a%a4%ed%8a%b8-%eb%b6%84%ed%95%a0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>랜덤이 아니라 시간 기반입니다. 2011년 5월 이전은 학습용(~80%, 약 27,360건), 이후는 테스트용(~20%, 약 6,840건)입니다. 랜덤으로 나누면 작가의 가격 이력 특징을 타고 미래 정보가 샙니다. 같은 작가의 기록을 학습과 테스트에 무작위로 흩어 놓으면, 테스트 행들은 롤링 윈도 특징을 통해 서로의 가격을 이미 &ldquo;본&rdquo; 상태가 되고, R²는 인위적으로 부풀려집니다.</p>
<p>한번 알고 나면 당연한데, 처음 만들 때는 놓치기 쉬운 종류의 문제입니다.</p>

<h3 class="relative group">모델
    <div id="모델" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%aa%a8%eb%8d%b8" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>XGBoost 회귀로 <code>log10(price_usd)</code>를 예측합니다. 검증용 슬라이스를 떼어 놓고 <code>max_depth</code>와 <code>learning_rate</code>를 탐색합니다. 모델 변형은 두 가지입니다.</p>
<ol>
<li><strong>전문가 추정가 없이.</strong> 목표 R² ≈ 0.73.</li>
<li><strong>전문가 추정가 포함.</strong> 목표 R² ≈ 0.92.</li>
</ol>
<p>&ldquo;추정가 포함&rdquo; 버전은 경매소의 사전 추정가 하한과 상한을 추가 특징으로 씁니다. 사실 이 두 추정가만 단독으로 써도 R² ≈ 0.90이 나옵니다. 그 위에 사회적 특징을 얹으면 0.02가 더해지죠. 절대값으로는 작지만, 전문가가 이미 아는 것 너머에서 실제 예측 일을 하는 부분이 바로 여기입니다.</p>

<h3 class="relative group">어디까지 맞추려 했나
    <div id="어디까지-맞추려-했나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%96%b4%eb%94%94%ea%b9%8c%ec%a7%80-%eb%a7%9e%ec%b6%94%eb%a0%a4-%ed%96%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>논문을 이기려던 게 아닙니다. 모든 조건에서 그들의 R² 값에서 ±0.03 이내에 들어가는 것이 목표였습니다. 헤드라인 수치(메타데이터만 ~0.73, 추정가 포함 ~0.92)는 재현성이 좋을 겁니다. XGBoost는 시드를 고정하면 결정론적이고 특징 정의도 명확하니까요. 어긋날 만한 곳은 두 군데로 봤습니다. PCA 차원이나 이미지 전처리의 작은 차이에 결과가 흔들리는 시각 특징 어블레이션, 그리고 4,346건밖에 없어 잡음 바닥이 높은 신흥 시장 분할입니다.</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%8b%9c%ea%b0%81%ec%a0%81-%ed%8a%b9%ec%a7%95%ec%9d%b4-%ea%b1%b0%ec%9d%98-%ec%95%88-%ed%86%b5%ed%95%98%eb%8a%94-%ec%9d%b4%ec%9c%a0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>저한테는 이게 제일 흥미로운 결과였습니다. 논문의 시각 파이프라인은 이미지 한 장에서 8,971개의 숫자를 뽑아냅니다.</p>
<ul>
<li><strong>GIST 디스크립터</strong> (960차원): 전반적인 공간 배치.</li>
<li><strong>방향성 그래디언트 히스토그램(HOG)</strong> (2,915차원): 엣지 구조.</li>
<li><strong>컬러 히스토그램</strong> (4,096차원): 팔레트 분해.</li>
<li><strong>ResNet18 특징</strong> (1,000차원): 사전 학습된 고수준 임베딩.</li>
<li><strong>컬러풀니스</strong> (1차원): 색의 선명도 스칼라.</li>
<li><strong>복잡도</strong> (1차원): 엣지 밀도 스칼라.</li>
</ul>
<p>이걸 PCA로 압축해 XGBoost에 넣으면 R² ≈ 0.055가 나옵니다. 색상만으로 0.056, 엣지 구조는 0.029, 신경망 임베딩은 0.009로 사실상 0입니다.</p>
<p>깔끔한 설명은 이렇습니다. 경매가는 개별 작품이 아니라 작가 수준에서 정해진다. 같은 작가의 그림 두 점은 캔버스에 뭐가 그려져 있든 비슷한 값에 팔립니다. 값이 매겨지는 건 서명이니까요.</p>
<p>경매 카탈로그 해설을 읽어 본 적이 있다면 수긍이 갈 겁니다. 그 글들은 거의 모든 지면을 프로비넌스와 전시 이력, 비교 매매에 씁니다. 작품 자체에 대한 묘사는 거의 없습니다.</p>
<p>이 5%라는 숫자를 진지하게 받아들이면, 작품의 미적 내용은 시장 가치와 거의 분리되어 있다는 뜻이 됩니다. 시장이 보상하는 것이 무엇이든 그건 캔버스 위에 있지 않습니다. 픽셀 수준에서 미술을 이해하는 모델이, 작가 이력서만 아는 모델보다 가격 예측을 못하게 되는 것이죠.</p>

<h3 class="relative group">3단계는 조건부였다
    <div id="3단계는-조건부였다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3%eb%8b%a8%ea%b3%84%eb%8a%94-%ec%a1%b0%ea%b1%b4%eb%b6%80%ec%98%80%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>논문의 이미지 링크 컬럼이 가리키는 URL은 2012년에 유효했던 것들이고, 2026년에는 대부분 죽어 있습니다. 제가 짠 계획은 8일 차에 이 리스크를 확인하도록 되어 있었습니다. URL 100개를 무작위로 뽑아 몇 개가 살아 있는지 본다. 절반 이상이 사라졌으면 시각 단계 전체를 건너뛰고 이유를 문서화한다.</p>
<p>어차피 시각적 특징이 설명하는 가격 분산은 5% 정도입니다. 그 정도 숫자를 확인하려고 링크 부패와 2주씩 싸우는 건 남는 장사가 아닙니다. 죽은 링크 조사 결과를 보고서에 정리하고 넘어가는 편이 낫죠.</p>

<h2 class="relative group">기존 시장 vs 신흥 시장
    <div id="기존-시장-vs-신흥-시장" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b8%b0%ec%a1%b4-%ec%8b%9c%ec%9e%a5-vs-%ec%8b%a0%ed%9d%a5-%ec%8b%9c%ec%9e%a5" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>논문은 기존 시장(미국, 영국, 프랑스, 독일)과 신흥 시장(나머지 19개국)에 대해 별도의 모델을 학습합니다. 핵심 결과는 이렇습니다.</p>
<table>
	<thead>
			<tr>
					<th>특징 세트</th>
					<th>기존 시장 R²</th>
					<th>신흥 시장 R²</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>시각만</td>
					<td>0.053</td>
					<td>0.056</td>
			</tr>
			<tr>
					<td>메타데이터 (사회적 신호)</td>
					<td>0.667</td>
					<td>0.750</td>
			</tr>
			<tr>
					<td>메타데이터 + 추정가</td>
					<td>0.916</td>
					<td>0.859</td>
			</tr>
	</tbody>
</table>
<p>눈에 띄는 점이 둘 있습니다. 첫째, 사회적 신호는 신흥 시장에서 오히려 더 잘 통합니다. 데이터가 풍부한 기존 시장 쪽이 사회 특징의 성능도 좋을 거라 짐작했는데, 정반대였습니다.</p>
<p>둘째, 신흥 시장에서는 전문가 추정가가 덜 정확합니다. 추정가를 넣었을 때의 0.916 대 0.859 격차가 그것입니다. 기존 시장에서 알고리즘 예측은 수십 년치 비교 매매 데이터를 등에 업은 대형 경매소 전문가와 경쟁해야 합니다. 신흥 시장에서는 그 전문가의 데이터가 얇고, 알고리즘이 가치를 더할 공간이 넓어집니다.</p>
<p>이 모델로 사업을 한다면, 밥값을 하는 곳은 신흥 시장일 겁니다.</p>

<h2 class="relative group">짚고 넘어갈 한계
    <div id="짚고-넘어갈-한계" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a7%9a%ea%b3%a0-%eb%84%98%ec%96%b4%ea%b0%88-%ed%95%9c%ea%b3%84" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이건 논문 재현이지 프로덕션 시스템이 아닙니다. 실시간 데이터 파이프라인이 없어서 모델은 1996–2012 경매 데이터로 학습된 채 갱신되지 않습니다. 배포도 없습니다. 결과물은 노트북과 보고서이지 예측 API가 아니에요. 드리프트 감지도 없으니, 한번 학습된 모델은 시장이 언제 변했는지 알 길이 없습니다. 데이터셋은 동결되어 있어서 크립토 아트, NFT, 코로나 이후의 시장 변화는 학습 데이터에 아예 존재하지 않습니다. 한국 경매소의 가격을 예측하고 싶다면 경매소별 특징과 어쩌면 한국 시장 전용 모델이 필요할 텐데, 거기까지 특화하지도 않았습니다.</p>
<p>모델은 과거의 패턴을 비추는 거울이기도 합니다. 현대 미술 시장이 2015년 무렵 구조적으로 바뀌었다면(아트 페어가 1차 시장의 주요 무대로 떠오르고 새로운 세대의 컬렉터가 진입했다는 주장이 있습니다), 1996–2012의 패턴은 일반화되지 않을 수 있습니다.</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%85%bc%eb%ac%b8%ec%97%90%ec%84%9c-%ed%94%8c%eb%9e%ab%ed%8f%bc%ec%9c%bc%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>논문을 읽고 나니 두 번째 프로젝트를 보는 눈이 달라졌습니다. 이 플랫폼은 원래 미술 전시 크라우드펀딩 마켓플레이스로 구상한 것이었습니다. 작가는 다가오는 전시 자금을 미리 확보하고, 투자자는 경매 시장이 허락하는 것보다 이른 시점에 신진 작가에 대한 노출을 얻는 구조죠.</p>
<p>제품의 표면은 일부러 단순하게 잡았습니다. 작가가 자금이 필요한 전시를 올립니다(장소, 일정, 출품 예정작). 투자자는 특정 전시를 단계별 금액($100, $500, $1k, $5k)으로 후원하고, 작품이 팔리면 상승분을 나눠 갖습니다. 작가-투자자 채팅의 대부분은 AI 에이전트가 맡아서, 양쪽 다 접속해 있지 않아도 대화가 굴러갑니다. 한국 갤러리가 14시간 시차를 두고 미국 컬렉터와 이야기할 때 특히 유용하죠.</p>
<p>자산은 작품이 아니라 작가라는 사실이 와닿고 나면, 데이터 모델도 거기에 맞춰 바뀌어야 합니다. 잡아야 하는 것은 각 작품을 누가 소유해 왔는지, 어디에 전시됐는지, 비교 작품들이 얼마에 팔렸는지, 그리고 같은 작가의 작품을 또 누가 갖고 있는지입니다. 소유의 사슬, 기관의 인정, 경매 비교 사례, 컬렉터 네트워크. 지식 그래프가 필요한 이유가 바로 이겁니다.</p>
<p>데이터 모델은 일부러 작게 잡았습니다. 작가, 작품, 경매 기록, 소유 기간, 그리고 데이터 자체의 출처 로그. 덕분에 모든 기록을 어디서 스크랩했는지까지 거슬러 올라가 확인할 수 있습니다. 마지막 항목은 장부 정리처럼 들리지만, 스크랩한 데이터를 다룰 때는 이게 있어야 그래프를 믿을 수 있습니다.</p>
<p>모바일 앱은 이 전부를 force-directed 그래프로 렌더링합니다. 시간 범위 슬라이더로 연도를 넘겨 가면 네트워크가 자라나는 과정이 보입니다. 어떤 컬렉터가 언제 들어왔는지, 어떤 작가가 어느 미술관의 눈에 들었는지, 2008년 침체기에 어떤 작품이 주인을 바꿨는지.</p>
<p>엔티티 종류마다 모양과 색을 다르게 줬습니다. 작가는 다이아몬드, 작품은 둥근 사각형, 경매소는 육각형이고, 컬렉터·딜러·미술관·유산 같은 소유자들은 색이 다른 원입니다.</p>
<p>모양은 장식이 아닙니다. 노드 수백 개 규모로 줌아웃하면, 라벨이 렌더링되기를 기다리지 않아도 실루엣만으로 어떤 엔티티인지 알 수 있습니다. 원들에 둘러싸인 다이아몬드는 컬렉터 네트워크를 가진 작가. 여러 다이아몬드와 연결된 육각형은 작가를 여럿 다루는 경매소. 시장의 구조가 한눈에 읽힙니다.</p>
<p>두 번째 탭은 같은 데이터를 타임라인으로 보여줍니다. 소유 기간은 가로 막대, 판매는 점 이벤트. 같은 그래프를 다른 렌즈로 보는 셈이죠. 특정 시점의 네트워크 모양이 아니라 누가 무엇을 어떤 순서로 가졌는지 보고 싶을 때 유용합니다.</p>
<p>이 플랫폼 자체는 가격 예측 도구가 아닙니다. 그 밑에 까는 데이터 레이어입니다. 동결된 2012년 CSV 대신 살아 있는 최신 데이터를 Lee et al. 같은 모델에 공급하고 싶다면, 필요한 구조가 바로 이것입니다.</p>

<h2 class="relative group">가치 평가를 게임으로
    <div id="가치-평가를-게임으로" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%80%ec%b9%98-%ed%8f%89%ea%b0%80%eb%a5%bc-%ea%b2%8c%ec%9e%84%ec%9c%bc%eb%a1%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>아직 만들지 않았지만 제일 관심이 가는 부분은, 미술품 가치 평가를 게임으로 바꾸는 것입니다.</p>
<p>Lee et al. 논문은 경매소의 사전 추정가를 특징으로 써서 R²를 크게 끌어올립니다. 그 추정가가 값진 이유는, 수천 건의 비교 작품을 봐 왔고 결과에 책임을 지는 훈련된 전문가들의 종합된 판단이기 때문입니다. 하지만 경매소 전문가는 좁고 비싼 자원입니다. 전 세계에 몇백 명 수준이고, 특정 위탁 건에만 붙으며, 아직 아무도 위탁하지 않는 작가들의 롱테일에는 손이 닿지 않습니다.</p>
<p>제가 계속 되돌아오는 질문은 이겁니다. 훨씬 넓고 저렴한 판단의 풀에서 비슷한 신호를 합성해 낼 수는 없을까?</p>
<p>대략적인 설계는 이렇습니다.</p>
<ul>
<li>사용자에게 작품을 보여준다. 이미지, 크기, 연도, 작가 이름, 짧은 이력.</li>
<li>경매에서 얼마에 팔릴지 묻는다.</li>
<li>가격 구간을 고르거나 낙찰가를 직접 찍게 한다.</li>
<li>답을 확정한 뒤 실제 판매가를 공개한다.</li>
<li>시간이 쌓이면 Brier 점수 방식의 정확도 등급을 매긴다.</li>
<li>리더보드, 일일 스트릭, 연속 적중 배지를 보여준다.</li>
<li>각 사용자의 향후 예측을 과거 정확도로 가중한다.</li>
</ul>
<p>미술품 가격 예측 시장을 맞히기 게임의 형태로 만든 것입니다. 루프는 지리 맞히기 게임이나 온라인 체스의 퍼즐 레이팅과 같습니다. 반복할 수 있고, 점수가 매겨지고, 피드백을 딛고 실력을 쌓을 수 있죠. 잘 맞히는 사람은 리더보드를 올라가고, 그 사람의 향후 추측은 종합 신호에서 더 무겁게 반영됩니다.</p>
<p>진짜 쓸모는 모델 쪽에서 생깁니다. 가중 집계된 추측들이 사회적 신호와 나란히 XGBoost에 넣을 수 있는 특징이 됩니다. 군중의 정확도 가중 중간값이 낙찰가와 상관관계가 높다면, 진짜 추정가가 없는 작품에 대해서도 합성 경매소 추정가에 가까운 것을 손에 쥐게 됩니다. Lee et al. 모델이 가장 도움을 필요로 하는 작가 롱테일의 빈틈이 정확히 거기입니다.</p>
<p>콜드 스타트 관점도 있습니다. 모델은 가격 이력이 풍부한 작가에게는 잘 통하고, 판매가 세 건뿐인 작가에게는 형편없습니다. 판매 세 건에 군중의 추측 천 개가 더해지면 최소한 다뤄 볼 재료는 되죠. 크라우드소싱 가치 평가는 기저 데이터가 커버하지 못하는 사례를 위한 학습 신호를 만들어 내는 방법입니다.</p>
<p>여기서 게임화가 중요한 건, 군중을 믿을 만하게 만드는 유일한 방법이 참여를 충분히 재미있게 만들어 자주 하게 하는 것이기 때문입니다. 단발성 설문으로는 한 번 클릭하고 지나간 사람들의 잡음 데이터만 얻습니다. 일일 스트릭에 리더보드, 보정 배지가 있으면 같은 사람이 1년에 걸쳐 공들여 고민한 예측 500개를 만들어 줍니다. 물량에 자기 선별(잘 맞히는 사람은 남고, 못 맞히는 사람은 지루해서 떠나는)이 겹쳐야 잡음이 아닌 신호가 나옵니다.</p>
<p>만들기 전에 풀어야 할 설계 문제도 몇 가지 있습니다. 사용자에게 무엇을 얼마나 보여줄 것인가. 이미지와 이력만인지, 맥락으로 비교 매매까지인지. 맥락이 많을수록 추측의 질은 올라가지만, 지나치면 사용자는 이미 보여준 것을 되뇌는 것에 불과해집니다. 가격 피드백의 굵기는 어떻게 할까요. 낙찰가 그대로, 범위, 아니면 ±20% 안에 들었는지 여부만? 너무 세밀하면 특정 숫자에 앵커링하는 버릇을 들이게 되고, 너무 뭉뚱그리면 보정이 안 됩니다. 리더보드 공략 문제도 있습니다. 플레이어는 아는 작가 쪽으로 몰릴 테고, Banksy 맞히기는 실력이라기보다 상식 퀴즈에 가깝죠. 언어 학습 앱이 레슨을 고르듯 작품을 강제로 무작위로 뽑고, 작가 이름을 가린 &ldquo;블라인드&rdquo; 라운드는 랭킹을 따로 두는 게 현실적인 답일 겁니다. 그리고 사후 확신 편향은 피할 수 없습니다. Basquiat이 1억 1천만 달러에 팔렸다는 걸 알아 버린 사용자는 모르던 상태로 돌아갈 수 없어요. 루프를 정직하게 유지하는 건 아직 아무도 못 본 작품의 공급인데, 그 공급은 주 단위로 유한합니다.</p>
<p>게임화라는 틀은 플랫폼의 두 반쪽을 묶어 주기도 합니다. 크라우드펀딩 쪽은 전시가 재무적으로 잘됐을 때 투자자에게 보상하고, 맞히기 게임 쪽은 돈을 낸 투자자든 아니든 가격을 잘 부르는 사람에게 보상합니다. 둘 다 같은 아이디어의 변주입니다. 군중의 집단적 판단을 거래 가능한 신호로 바꾸자는 것. 한쪽은 자본을 거래하고 다른 쪽은 주의를 거래합니다. 진짜 우위를 키운 플레이어는 검증 가능한 가격 예측 실적을 들고 언젠가 투자자 쪽으로 &lsquo;졸업&rsquo;할 수도 있겠죠. &ldquo;괜찮은 갤러리스트를 안다&quot;보다는 훨씬 나은 필터입니다.</p>

<h2 class="relative group">얻어 간 것들
    <div id="얻어-간-것들" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%96%bb%ec%96%b4-%ea%b0%84-%ea%b2%83%eb%93%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>사전 추정가는 대단히 훌륭합니다. 경매소는 불투명한 가격 책정으로 욕을 많이 먹지만, 그들의 추정가만으로 가격 분산의 90% 가까이가 잡힙니다. 전문가들이 뭘 하고 있는지는 몰라도, 잘하고 있는 건 분명합니다. 그런데 정작 작품 자체는 통계적으로 거의 의미가 없습니다. 미적인 이유로 미술을 아끼는 사람에게는 불편한 결론이죠. 시장은 당신이 보는 것에 값을 매기지 않습니다. 다른 사람들이 옆에 있는 작품에 이미 치른 값에 값을 매깁니다.</p>
<p>모델링 쪽에서는 두 가지 습관이 제값을 했습니다. 시간 기반 분할은 가격 모델을 평가하는 유일하게 정직한 방법이고, 랜덤 분할이 주는 건 실제 프로덕션에서 살아남지 못하는 보기 좋은 R²입니다. 그리고 그래디언트 부스티드 트리는 여기서 여전히 옳은 도구입니다. 논문에서 XGBoost는 모든 신경망 베이스라인을 이기고, 이 규모의 표 형식 데이터에 이만큼 공들인 특징 구조라면 트랜스포머에 손을 뻗을 이유가 없습니다.</p>
<p>사업적인 관찰도 두 가지. 흥미로운 기회는 신흥 시장에 있습니다. 전문가의 값 매기기가 가장 얇고, 알고리즘 예측이 메울 격차가 가장 큰 곳이니까요. 그리고 군중은 덜 쓰이고 있는 데이터 소스입니다. Lee et al. 모델의 최대 단일 특징은 경매소의 사전 추정가였습니다. 보정된 군중이 작가 롱테일에 대해 그에 준하는 신호를 만들어 낼 수 있다면, 그건 모델 입력으로서도, 그 자체 제품으로서도 진짜 기회입니다.</p>
<p>두 프로젝트는 계획했던 것 이상으로 서로를 보완하게 됐습니다. 논문 재현은 무엇이 가격을 예측하는지 가르쳐 줬고, 플랫폼 작업은 그 지식을 써먹으려면 어떤 데이터 구조가 필요한지 가르쳐 줬습니다. 게임화 아이디어가 둘을 잇습니다. 지식 그래프가 작품과 과거 판매를 공급하고, 맞히기 게임이 군중 기반 추정가 특징을 만들어 내고, 모델이 그 특징을 사회적 신호와 함께 소비해서, 그렇지 않았으면 보이지 않았을 작가들에 대한 예측을 내놓는 구조입니다.</p>
<p>어느 것도 완성되지 않았습니다. 논문 재현은 보고서가 딸린 동작하는 프로토타입이고, 플랫폼은 시드 데이터를 넣은 데모이고, 크라우드소싱 게임은 방금 읽으신 설계 메모로만 존재합니다. 그래도 관통하는 아이디어가 충분히 일관돼서, 가야 할 모양은 꽤 또렷해 보입니다. 작품이 아니라 작가에 값을 매길 것. 전문가에게만 기대지 말고 군중을 집계할 것. 가치 평가를 전문가가 위에서 내려 주는 것이 아니라 시장이 함께 만들어 내는 것으로 다룰 것.</p>
<p>시간만 나면, 다음에 만들 버전은 이겁니다.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
