<?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>HTTP 103 · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/http-103/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Wed, 04 Nov 2026 13:36:11 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/http-103/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>웹 성능</category>
      <category>Preload</category>
      <category>관측성</category>
      <category>디버깅</category>
      <description>Cloudflare에 504 에러가 수천 건 찍혔는데, 실제 사용자에게 간 건 하나도 없었습니다. 전부 Early Hints 캐시 미스였죠. HTTP 103의 동작 방식, 가짜 504와 진짜 504를 구별하는 법, 그리고 문제를 해결한 헤더 하나를 정리했습니다.</description>
      <content:encoded><![CDATA[<p>9월에 <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >헬스 체크는 전부 초록불인데 Cloudflare 대시보드에는 요청의 22%가 실패로 찍혀 있던 일</a>에 대해 쓴 적이 있습니다. 알고 보니 그 504 대부분은 사용자가 아니라 Cloudflare의 Early Hints 기능에서 나온 것이었고, 저는 Early Hints를 끄고 넘어갔습니다.</p>
<p>11월에 같은 존(zone)을 다시 열어 보니 하루 만에 504가 또 2,600건 쌓여 있었습니다. Early Hints가 다시 켜져 있었습니다. 이번에는 그 504가 정확히 무엇인지, 그리고 기능을 또 꺼야 하는지 아니면 제대로 일하게 만들어야 하는지 확실히 알고 싶었습니다.</p>
<p>이 존은 Fly.io 오리진 하나를 앞에서 받고 있고, 그 오리진이 세 가지를 서빙합니다. Django API, Expo 웹 앱, 그리고 Hugo로 만든 마케팅 사이트입니다. API를 기억해 두세요. API에는 따로 진짜 용량 문제가 있었고, 바로 그것 때문에 이 구분이 중요해집니다.</p>

<h2 class="relative group">짧은 답
    <div id="짧은-답" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a7%a7%ec%9d%80-%eb%8b%b5" aria-label="앵커">#</a>
    </span>
    
</h2>
<ul>
<li>이 504는 방문자가 아니라 <strong>Cloudflare의 Early Hints 캐시</strong>가 기록한 것입니다. Cloudflare 로그에서는 <code>RequestSource = earlyHintsCache</code>로 표시됩니다.</li>
<li>여기서 <strong>504는 캐시 미스</strong>라는 뜻입니다. 그 URL에 대해 저장된 힌트가 없었다는 것이죠. 히트는 2xx로 기록됩니다.</li>
<li>이 요청은 <strong>오리진에 절대 도달하지 않습니다</strong>. <code>OriginResponseStatus</code>가 <code>0</code>입니다.</li>
<li>Early Hints는 <strong><code>Link</code> HTTP 응답 헤더</strong>로 동작합니다. HTML 안의 <code>&lt;link rel=&quot;preload&quot;&gt;</code> 태그로는 동작하지 않습니다.</li>
<li>헤더를 보내면, 미리 불러올 것이 있는 페이지에서는 이 미스가 히트로 바뀌고 진짜 <code>103 Early Hints</code> 응답이 나가기 시작합니다.</li>
</ul>

<h2 class="relative group">가짜 Cloudflare 504와 진짜 504 구별하기
    <div id="가짜-cloudflare-504와-진짜-504-구별하기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a7%9c-cloudflare-504%ec%99%80-%ec%a7%84%ec%a7%9c-504-%ea%b5%ac%eb%b3%84%ed%95%98%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Logpush나 Instant Logs를 쓴다면 무엇보다 먼저 두 필드를 보세요. <code>RequestSource</code>는 실제 방문자 요청이면 <code>eyeball</code>, Early Hints 조회면 <code>earlyHintsCache</code>입니다. <code>OriginResponseStatus</code>가 <code>0</code>이면 오리진에 연결조차 하지 않은 것입니다.</p>
<p>GraphQL Analytics API에서는 같은 구분이 <code>requestSource</code> 디멘션으로 나옵니다. 저는 이제 5xx가 튀면 믿기 전에 이 쿼리부터 돌립니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>User-Agent로 묶어 봐도 같은 결과가 나옵니다. 9월에는 Early Hints 조회가 <code>bastion early hints</code>와 <code>nginx-ssl early hints</code>로 찍혔습니다. 11월에는 <code>fl2 early hints</code>와 <code>nginx-ssl early hints</code>였습니다(FL2는 Cloudflare 프록시의 새 세대입니다). 24시간 동안 이 존의 504 2,631건이 전부 이 User-Agent에서 나왔습니다. 브라우저, 앱, curl, 검색 크롤러에서 나온 것은 하나도 없었습니다.</p>
<p>방문자가 실제로 본 진짜 504는 Cloudflare 바깥, 즉 여러분 쪽에서 나온 것입니다. 애플리케이션 앞에 있는 프록시나 로드 밸런서가 기다리다 포기한 것이죠. nginx일 수도 있고, 클라우드 로드 밸런서일 수도 있고, 잠든 머신이 깨어나길 기다리는 플랫폼 프록시일 수도 있습니다. Cloudflare 자체의 타임아웃은 다른 코드를 씁니다. <strong>522</strong>는 오리진에 연결하지 못했다는 뜻이고, <strong>524</strong>는 연결은 됐지만 100초(Enterprise 미만 요금제의 기본값) 안에 응답을 받지 못했다는 뜻입니다.</p>
<p>그러니 504 때문에 Cloudflare 타임아웃을 늘리는 방법을 찾다가 이 글에 오셨다면: 504 뒤에 있는 타임아웃은 거의 Cloudflare 것이 아닙니다. nginx의 <code>proxy_read_timeout</code>, 로드 밸런서의 idle timeout, 또는 플랫폼이 머신을 깨우는 데 걸리는 시간을 확인하세요. 쓸 만한 단서가 하나 있습니다. 요청이 100초보다 한참 짧은 딱 떨어지는 숫자, 예를 들어 30초나 60초에서 실패한다면 Cloudflare가 아니라 여러분 프록시의 한도입니다. (Cloudflare의 100초 한도는 Enterprise 요금제에서만 최대 6,000초까지 늘릴 수 있고, Cloudflare 스스로도 이를 임시방편으로 봅니다.)</p>

<h2 class="relative group">같은 주에 API에서는 진짜 504가 났다
    <div id="같은-주에-api에서는-진짜-504가-났다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ea%b0%99%ec%9d%80-%ec%a3%bc%ec%97%90-api%ec%97%90%ec%84%9c%eb%8a%94-%ec%a7%84%ec%a7%9c-504%ea%b0%80-%eb%82%ac%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 구분이 중요한 이유가 여기 있습니다. 며칠 전, 바로 그 API가 실제로 무너졌습니다. API는 Fly.io의 <code>shared-cpu-1x</code> 머신에서 돌고 있었는데, 이 머신은 버스트 크레딧 방식입니다. 뉴질랜드 피크 트래픽에서 버스트를 다 써 버리자 초당 약 20~30 요청 수준의 지속 상한으로 스로틀링됐습니다. 응답 시간이 무너졌고 방문자들은 엣지에서 진짜 504를 받았습니다. <code>shared-cpu-2x</code>로 옮기자 해결됐습니다.</p>
<p>그런데 504 차트가 다시 가득 찼습니다. 이번에는 Early Hints 미스였죠. <code>requestSource</code>로 나누지 않고 보면, 그 차트는 더 큰 머신이 효과가 없었다고 말합니다. 실제로는 같은 주, 같은 존에 서로 다른 두 종류의 504가 있었고, 그중 사람에게 닿은 것은 하나뿐이었습니다. request source로 나눠 보지 않은 5xx 차트를 근거로 용량을 결정하지 마세요.</p>

<h2 class="relative group">이 504의 정체
    <div id="이-504의-정체" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%b4-504%ec%9d%98-%ec%a0%95%ec%b2%b4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Early Hints가 켜진 존에서는 모든 요청마다 내부 조회가 하나 더 붙습니다. Cloudflare가 보낼 힌트가 있는지 자기 캐시를 확인하는 것이죠. 이 조회는 <code>RequestSource = earlyHintsCache</code>로 별도 줄에 기록됩니다. 최종 사용자 요청이 아니고, 오리진으로 보내지지도 않습니다.</p>
<p>그 줄의 상태 코드는 에러가 아니라 캐시 조회 결과입니다.</p>
<ul>
<li><strong>2xx</strong>는 히트입니다. Cloudflare에 그 URL용 <code>Link</code> 헤더가 저장돼 있었고, 이를 <code>103</code>으로 브라우저에 보냈다는 뜻입니다. 제 존에서는 히트가 <code>200</code>과 <code>204</code> 두 가지로 나타납니다.</li>
<li><strong>504</strong>는 미스입니다. 저장된 것이 없어서 미리 보낼 것이 없었다는 뜻입니다.</li>
</ul>
<p>오리진이 Early Hints가 쓰는 헤더를 한 번도 보내지 않으면 모든 조회가 미스가 되고, 모든 요청이 504 줄을 하나씩 남깁니다. 제가 아무것도 바꾸기 전 3시간 동안 이 존은 Early Hints 조회를 173건 기록했고, 173건 모두 미스였습니다. 기능이 켜진 뒤로 힌트를 한 번도 보낸 적이 없었던 겁니다.</p>
<p>9월 글에 대한 정정도 하나 하겠습니다. 저는 그때 이것을 &ldquo;Fly 오리진에서 응답을 받지 못하는&rdquo; 프로브라고 설명했습니다. 정확한 설명이 아닙니다. 이 조회는 오리진에 아예 가지 않습니다. 504는 타임아웃이 아니라 캐시 미스를 기록한 것입니다.</p>

<h2 class="relative group">HTTP 103은 에러인가?
    <div id="http-103은-에러인가" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#http-103%ec%9d%80-%ec%97%90%eb%9f%ac%ec%9d%b8%ea%b0%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>아닙니다. 1xx 대의 상태 코드는 정보성 응답, 즉 서버가 진짜 응답 전에 보낼 수 있는 중간 응답입니다. RFC 8297에 정의된 <code>103 Early Hints</code>는 <code>Link</code> 헤더를 담고 있어서, 서버가 아직 페이지를 만드는 동안 브라우저가 핵심 CSS, JavaScript, 폰트를 미리 받거나 다른 오리진에 연결을 열어 둘 수 있게 해 줍니다. 최종 <code>200</code>은 같은 요청에서 뒤이어 옵니다.</p>
<p>지원 범위는 고르지 않지만 안전합니다. Chromium 계열 브라우저(Chrome 103 이상, Edge)와 최신 Firefox는 <code>preload</code>와 <code>preconnect</code> 힌트를 모두 처리합니다. Safari는 <code>preconnect</code>만 씁니다. 브라우저는 <code>103</code>을 HTTP/2와 HTTP/3에서만 처리합니다. 이해하지 못하는 브라우저는 그냥 평소처럼 <code>200</code>을 기다릴 뿐이니, <code>103</code>은 대부분의 방문자에게는 출발 신호를 앞당겨 주고 나머지에게는 아무 영향이 없습니다.</p>

<h2 class="relative group">제 설정이 비어 있던 이유: Link 헤더 vs <code>&lt;link rel=preload&gt;</code>
    <div id="제-설정이-비어-있던-이유-link-헤더-vs-link-relpreload" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-%ec%84%a4%ec%a0%95%ec%9d%b4-%eb%b9%84%ec%96%b4-%ec%9e%88%eb%8d%98-%ec%9d%b4%ec%9c%a0-link-%ed%97%a4%eb%8d%94-vs-link-relpreload" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>여기서 시간을 가장 많이 썼습니다. 이미 동작해야 할 것처럼 보였기 때문입니다.</p>
<p>Early Hints가 캐시하는 것은 딱 하나, <code>rel=preload</code>나 <code>rel=preconnect</code> 관계를 가진 <strong><code>Link</code> HTTP 응답 헤더</strong>입니다. Cloudflare는 평소 응답(HTML 또는 확장자 없는 URL의 <code>200</code>, <code>301</code>, <code>302</code>)에서 이 헤더를 읽어 저장해 두고, 다음 방문자에게 전체 응답이 준비되기 전에 <code>103</code>으로 다시 보내 줍니다.</p>
<p>제 페이지에는 이미 preload가 있었습니다. 다만 HTML <code>&lt;head&gt;</code> 안의 태그로요.</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">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>이 태그도 쓸모가 있습니다. 브라우저가 이걸 보고 움직이니까요. 하지만 브라우저는 HTML을 받아서 파싱을 시작한 뒤에야 이 태그를 봅니다. Early Hints가 앞지르려는 바로 그 순간이죠. Expo 익스포트와 Hugo 둘 다 이 태그는 알아서 써 주지만, 헤더는 둘 다 설정하지 않습니다. 결국 기능은 켜져 있고 preload도 선언돼 있었지만, 둘은 한 번도 만나지 못했습니다.</p>

<h2 class="relative group">해결책: 이미 가진 데이터로 헤더를 보내기
    <div id="해결책-이미-가진-데이터로-헤더를-보내기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ed%95%b4%ea%b2%b0%ec%b1%85-%ec%9d%b4%eb%af%b8-%ea%b0%80%ec%a7%84-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a1%9c-%ed%97%a4%eb%8d%94%eb%a5%bc-%eb%b3%b4%eb%82%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>가장 깔끔한 해결책은 번들 해시가 바뀔 때마다 낡아 버리는 핵심 에셋 목록을 손으로 관리하는 것이 아닙니다. 각 페이지는 무엇을 미리 불러올지 이미 그 태그로 선언하고 있습니다. 그러니 페이지를 서빙할 때 그 태그를 <code>Link</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="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PRELOAD</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">rb</span><span class="s2">&#34;&#34;&#34;&lt;link\b[^&gt;]*?\brel=[&#34;&#39;]?preload[&#34;&#39;]?[^&gt;]*?&gt;&#34;&#34;&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">name</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A real attribute: preceded by whitespace, followed by =, space, / or &gt;.</span>
</span></span><span class="line"><span class="cl">    <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="sa">rb</span><span class="s2">&#34;(?&lt;=\s)&#34;</span> <span class="o">+</span> <span class="n">re</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">+</span> <span class="sa">rb</span><span class="s2">&#34;&#34;&#34;(?:\s*=\s*(?:&#34;([^&#34;]*)&#34;|&#39;([^&#39;]*)&#39;|([^\s&gt;]+)))?(?=[\s/&gt;])&#34;&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">tag</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">next</span><span class="p">((</span><span class="n">g</span> <span class="k">for</span> <span class="n">g</span> <span class="ow">in</span> <span class="n">m</span><span class="o">.</span><span class="n">groups</span><span class="p">()</span> <span class="k">if</span> <span class="n">g</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">),</span> <span class="sa">b</span><span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parts</span><span class="p">,</span> <span class="n">seen</span> <span class="o">=</span> <span class="p">[],</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">tag</span> <span class="ow">in</span> <span class="n">PRELOAD</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">html</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">href</span> <span class="o">=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;href&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">href</span> <span class="ow">or</span> <span class="n">href</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">seg</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">href</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">:=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">as_</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">as_</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;crossorigin&#34;</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">seg</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">==</span> <span class="n">limit</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>그리고 Django에서 HTML 응답에 붙입니다.</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">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>저를 물었던 디테일 두 가지는 꼭 챙길 만합니다.</p>
<ul>
<li><strong>속성 파서의 경계를 고정하세요.</strong> <code>as</code>를 단순하게 찾으면 <code>href=&quot;/assets/...&quot;</code> 안의 &ldquo;as&quot;가 먼저 걸리고, 폰트 preload에서 <code>as=font</code>가 소리 없이 빠집니다. 속성 이름이 공백 뒤에서 시작해 <code>=</code>, 공백, <code>/</code>, <code>&gt;</code>에서 끝나도록 강제하세요. Hugo 같은 정적 사이트 생성기는 <code>href=https://jared.lynskey.co.nz/fonts/x.woff2 as=font crossorigin</code>처럼 따옴표 없이 쓰기 때문에, 따옴표 있는 속성과 없는 속성을 모두 처리해야 합니다. 이건 단위 테스트가 잡아 줬습니다.</li>
<li><strong>폰트에는 <code>crossorigin</code>을 유지하세요.</strong> 이게 없는 폰트 preload는 다른 요청으로 취급돼서 폰트를 두 번 내려받게 되고, 페이지는 빨라지기는커녕 느려집니다.</li>
</ul>
<p><code>103</code>을 직접 쓸 필요는 없습니다. ASGI, Granian, uvicorn에 중간 응답 코드를 추가할 일도 없습니다. Cloudflare가 여러분의 헤더로 <code>103</code>을 만들고, 여러분이 할 일은 헤더를 보내는 것뿐입니다.</p>

<h2 class="relative group">다른 스택에서는
    <div id="다른-스택에서는" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%8b%a4%eb%a5%b8-%ec%8a%a4%ed%83%9d%ec%97%90%ec%84%9c%eb%8a%94" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>규칙은 어디서나 같습니다. HTML 응답에 <code>Link</code> 헤더를 달고 나머지는 Cloudflare에 맡기세요.</p>
<ul>
<li>
<p><strong>nginx</strong>에서는 HTML을 서빙하는 location에:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p><strong>Laravel</strong>과 Vite 조합에는 이미 이를 위한 미들웨어 <code>AddLinkHeadersForPreloadedAssets</code>가 있어서, Vite의 preload를 <code>Link</code> 헤더로 바꿔 줍니다. 여기에 보고된 이슈 두 가지가 위의 함정과 똑같습니다. <code>modulepreload</code> 힌트가 <code>crossorigin</code> 없이 나가서 브라우저가 이를 버리고 스크립트를 두 번 받을 수 있었고, 큰 페이지에서는 헤더가 Apache의 약 8KB 헤더 한도를 넘기도 했습니다. 쓰는 버전에서 둘 다 확인하세요.</p>
</li>
<li>
<p><strong>그 밖의 환경</strong>: <code>Link</code> 헤더 하나에 쉼표로 이어 쓰거나, <code>Link</code> 헤더를 여러 개 보내면 됩니다. 둘 다 됩니다.</p>
</li>
</ul>
<p>사람들이 가장 많이 과하게 하는 부분은 무엇을 힌트로 줄지입니다. 렌더링에 꼭 필요한 CSS, 진입 스크립트, 첫 화면에 쓰이는 폰트 한두 개만 힌트로 주고, 모든 청크를 다 넣지는 마세요. 목록이 길면 브라우저가 지금 필요한 것보다 아직 필요 없는 것을 먼저 받게 됩니다. 저는 12개로 제한했는데, 그것도 Expo 익스포트가 preload 목록을 이미 추려 주기 때문입니다.</p>
<p>Cloudflare 뒤에 있지 않거나 CDN이 Early Hints를 지원하지 않는다면, <code>Link</code> 헤더의 효과는 조금뿐입니다. 브라우저가 본문을 파싱하기 전, 응답 헤더가 도착하자마자 그 리소스를 받기 시작할 수 있다는 정도죠. 서버나 CDN이 보내 주지 않는 한 <code>103</code>은 나가지 않습니다.</p>

<h2 class="relative group">제대로 동작하는지 확인하는 법
    <div id="제대로-동작하는지-확인하는-법" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a0%9c%eb%8c%80%eb%a1%9c-%eb%8f%99%ec%9e%91%ed%95%98%eb%8a%94%ec%a7%80-%ed%99%95%ec%9d%b8%ed%95%98%eb%8a%94-%eb%b2%95" aria-label="앵커">#</a>
    </span>
    
</h2>
<p><code>curl</code>로 <code>103</code>을 바로 볼 수 있습니다. HTTP/2로 요청하고 상태 줄과 <code>Link</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">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p><code>103</code>이 먼저 오고, 그다음에 <code>200</code>이 옵니다. Cloudflare가 힌트 안의 속성 순서를 바꾸는데, 문제없습니다. 모든 요청이 <code>103</code>을 받을 거라고 기대하지는 마세요. 제 경우 처음 세 번 중 두 번만 받았습니다. 변경 후 첫 방문은 헤더를 저장만 하고, 최종 응답 헤더가 이미 준비돼 있으면 Cloudflare가 <code>103</code>을 건너뜁니다.</p>
<p>그다음 위의 분석 쿼리로 돌아가 <code>requestSource: &quot;earlyHintsCache&quot;</code>로 필터링하고 상태 코드와 경로로 묶어 보세요. 고친 페이지에서는 미스 대신 히트가 보여야 합니다.</p>

<h2 class="relative group">무엇이 바뀌었고, 무엇이 남았나
    <div id="무엇이-바뀌었고-무엇이-남았나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%ac%b4%ec%97%87%ec%9d%b4-%eb%b0%94%eb%80%8c%ec%97%88%ea%b3%a0-%eb%ac%b4%ec%97%87%ec%9d%b4-%eb%82%a8%ec%95%98%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>헤더를 배포하고 몇 분 만에 이 존은 처음으로 Early Hints 히트를 기록했고, <code>curl</code>에서는 웹 페이지에 진짜 <code>103</code> 응답이 보였습니다. 헤더 하나로 두 문제가 같이 풀렸습니다. 기능이 동작하기 시작했고, &ldquo;504&quot;의 일부가 사라졌습니다.</p>
<p>남은 문제는 JSON API에는 미리 불러올 것이 없다는 점입니다. API 엔드포인트는 계속 미스가 나고 <code>504</code> 줄을 계속 남기는데, API 비중이 큰 존에서는 이게 여전히 대부분입니다. 합리적인 선택지는 세 가지입니다.</p>
<ol>
<li><strong>효과가 있는 페이지에 헤더를 보내고</strong>, API 경로가 계속 미스를 남기는 것은 받아들입니다. 기능은 동작하고, 차트는 조용해지지만 완전히 조용하지는 않습니다.</li>
<li><strong><code>earlyHintsCache</code>를 5xx 알림과 대시보드에서 제외합니다.</strong> 무엇을 결정하든 이건 하세요. 내부 캐시 조회 때문에 누가 호출을 받아서는 안 됩니다.</li>
<li><strong>Early Hints를 끕니다</strong>(대시보드의 Speed → Optimization, 또는 API의 존 설정 <code>early_hints</code>). <code>Link</code> 헤더를 앞으로도 보내지 않을 거라면요. HTML이 아주 작은 싱글 페이지 앱은 얻는 게 별로 없을 수 있고, 그러면 모든 조회가 멈추면서 노이즈도 같이 사라집니다.</li>
</ol>
<p>저는 켜 두기로 했습니다. 웹 페이지는 이제 출발이 빨라졌고, API의 미스는 알림이 걸리는 모든 곳에서 걸러 냈습니다. 9월에 기능을 끄는 대신 했어야 할 일이 바로 이것이었습니다.</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%b4%ed%81%ac%eb%a6%ac%ec%8a%a4%ed%8a%b8" aria-label="앵커">#</a>
    </span>
    
</h2>
<ol>
<li>Cloudflare 5xx 급증을 장애라고 부르기 전에, <code>requestSource</code>(또는 User-Agent)로 나눠 보세요.</li>
<li><code>earlyHintsCache</code> 504는 <code>OriginResponseStatus = 0</code>인 캐시 미스입니다. 무해하고, 오리진에 도달하지 않습니다.</li>
<li>진짜 504는 여러분의 프록시나 로드 밸런서에서 나옵니다. Cloudflare 자체의 타임아웃은 522(연결 불가)와 524(100초 동안 응답 없음)입니다.</li>
<li>Early Hints에는 <code>Link</code> <strong>응답 헤더</strong>가 필요합니다. HTML 안의 preload 태그는 해당되지 않습니다.</li>
<li>렌더링에 꼭 필요한 CSS, 스크립트, 폰트만 힌트로 주고, 폰트에는 <code>crossorigin</code>을 유지하세요.</li>
<li><code>curl -sv --http2</code>로 확인하세요. <code>200</code> 전에 <code>103</code>이 보여야 합니다.</li>
<li><code>earlyHintsCache</code>는 알림에서 빼 두세요.</li>
<li>헤더를 앞으로도 보내지 않을 거라면, 로그 노이즈로 비용을 치르느니 Early Hints를 끄세요.</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
