<?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>Sat, 07 Nov 2026 00:00:00 +1300</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>Cloudflare 대시보드는 제 요청의 4분의 1이 504라고 말하고 있었습니다. 그중 사용자에게 간 것은 하나도 없었습니다. 정체는 Cloudflare Early Hints 기능의 캐시 미스였고, <code>ClientRequestSource = earlyHintsCache</code>, <code>OriginResponseStatus = 0</code>인 504로 기록된 것이었죠. 이 글에서는 이것을 진짜 504와 구별하는 법, 그리고 미스를 히트로 바꿔 주는 헤더 하나를 정리합니다.</p>
<p>처음 마주친 건 9월이었습니다. <a href="https://jared.lynskey.co.nz/ko/posts/2026/2026-09-09-scale-to-zero-504s/" >헬스 체크는 전부 초록불인데 Cloudflare 대시보드에는 요청의 22%가 실패로 찍혀 있던 때</a>였고, 저는 Early Hints를 끄고 넘어갔습니다. 11월에 같은 존(zone)을 보니 Early Hints가 다시 켜져 있었고 하루 만에 504가 또 2,600건 쌓여 있었습니다. 이번에는 그 정체를 정확히 알고 싶었고, 기능을 또 꺼야 하는지 아니면 제대로 일하게 만들어야 하는지도 확실히 하고 싶었습니다. 이 존은 Fly.io 오리진 하나를 앞에서 받고 있고, 그 오리진이 Django API, Expo 웹 앱, Hugo로 만든 마케팅 사이트를 서빙합니다.</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는 방문자가 아니라 Cloudflare의 Early Hints 캐시가 기록한 것입니다. Cloudflare 로그에서는 <code>ClientRequestSource = earlyHintsCache</code>로 표시됩니다.</li>
<li>여기서 504는 캐시 미스라는 뜻입니다. 그 URL에 대해 저장된 힌트가 없었다는 것이죠. 히트는 2xx로 기록됩니다.</li>
<li>이 요청은 오리진에 절대 도달하지 않습니다. <code>OriginResponseStatus</code>가 <code>0</code>입니다.</li>
<li>Early Hints는 <code>Link</code> HTTP 응답 헤더로 동작합니다. HTML 안의 <code>&lt;link rel=&quot;preload&quot;&gt;</code> 태그로는 동작하지 않습니다.</li>
<li>헤더를 보내면, 미리 불러올 것이 있는 페이지에서는 이 미스가 히트로 바뀌고 진짜 <code>103 Early Hints</code> 응답이 나가기 시작합니다.</li>
</ul>
<p>미리 알아 두면 좋은 것이 세 가지 더 있습니다. 힌트는 렌더링에 꼭 필요한 CSS, 스크립트, 폰트만 주고, 폰트에는 <code>crossorigin</code>을 유지하세요. 확인은 <code>curl -sv --http2</code>로 하면 되고, <code>200</code> 전에 <code>103</code>이 보여야 합니다. 그리고 무엇을 결정하든 <code>earlyHintsCache</code>는 알림에서 빼 두고, 헤더를 앞으로도 보내지 않을 거라면 로그 노이즈로 비용을 치르느니 Early Hints를 끄세요.</p>

<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>ClientRequestSource</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>는 연결은 됐지만 125초(Enterprise 미만 요금제의 기본값) 안에 응답을 받지 못했다는 뜻입니다.</p>
<p>그러니 504 때문에 Cloudflare 타임아웃을 늘리는 방법을 찾다가 이 글에 오셨다면: 504 뒤에 있는 타임아웃은 거의 Cloudflare 것이 아닙니다. nginx의 <code>proxy_read_timeout</code>, 로드 밸런서의 idle timeout, 또는 플랫폼이 머신을 깨우는 데 걸리는 시간을 확인하세요. 쓸 만한 단서가 하나 있습니다. 요청이 125초보다 한참 짧은 딱 떨어지는 숫자, 예를 들어 30초나 60초에서 실패한다면 Cloudflare가 아니라 여러분 프록시의 한도입니다. (Cloudflare의 125초 한도는 Enterprise 요금제에서만 최대 6,000초까지 늘릴 수 있고, Cloudflare 스스로도 이를 임시방편으로 봅니다.)</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가 켜진 존에서는 HTML 페이지나 확장자 없는 URL로 가는 요청마다(API가 많은 존에서는 API 호출 대부분이 여기 해당합니다) 내부 조회가 하나 더 붙습니다. Cloudflare가 보낼 힌트가 있는지 자기 캐시를 확인하는 것이죠. 이 조회는 <code>ClientRequestSource = earlyHintsCache</code>로 별도 줄에 기록됩니다. 최종 사용자 요청이 아니고, 오리진으로 보내지지도 않습니다.</p>
<p>그 줄의 상태 코드는 에러가 아니라 캐시 조회 결과입니다.</p>
<ul>
<li>2xx는 히트입니다. Cloudflare에 그 URL용 <code>Link</code> 헤더가 저장돼 있었고, 이를 <code>103</code>으로 브라우저에 보냈다는 뜻입니다. 제 존에서는 히트가 <code>200</code>과 <code>204</code> 두 가지로 나타납니다.</li>
<li>504는 미스입니다. 저장된 것이 없어서 미리 보낼 것이 없었다는 뜻입니다.</li>
</ul>
<p>오리진이 Early Hints가 쓰는 헤더를 한 번도 보내지 않으면 모든 조회가 미스가 되고, 모든 요청이 504 줄을 하나씩 남깁니다. 제가 아무것도 바꾸기 전 3시간 동안 이 존은 Early Hints 조회를 173건 기록했고, 173건 모두 미스였습니다. 기능이 켜진 뒤로 힌트를 한 번도 보낸 적이 없었던 겁니다.</p>
<p>9월 글에 대한 정정도 하나 하겠습니다. 저는 그때 이것을 &ldquo;Fly 오리진에서 응답을 받지 못하는&rdquo; 프로브라고 설명했습니다. 정확한 설명이 아닙니다. 이 조회는 오리진에 아예 가지 않습니다. 504는 타임아웃이 아니라 캐시 미스를 기록한 것입니다.</p>
<p>Cloudflare도 <a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hints 문서</a>에서 이 점을 짧게 설명하고 있습니다. <code>ClientRequestSource: earlyHintsCache</code>로 기록된 Early Hints 조회는 &ldquo;expected and benign&rdquo;(예상된 것이고 무해함)이며, 200은 캐시 히트, 504는 미스입니다. Log Explorer에서는 <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code>로 걸러낼 수 있습니다. Cache Analytics는 원래부터 이걸 빼고 보여 주지만, 저를 놀라게 한 HTTP 트래픽 차트는 그렇지 않습니다.</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="#%ea%b0%80%ec%a7%9c-504-%eb%95%8c%eb%ac%b8%ec%97%90-%ec%84%9c%eb%b2%84%eb%a5%bc-%ec%97%85%ea%b7%b8%eb%a0%88%ec%9d%b4%eb%93%9c%ed%96%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 구분이 중요한 이유가 여기 있습니다. 이 글을 쓰기 시작하기 전날인 11월 3일, Cloudflare에는 방문자 요청 약 13,300건에 504가 3,292건 기록됐습니다. 겉보기 에러율이 25%입니다. API는 Fly.io의 <code>shared-cpu-1x</code> 머신에서 돌고 있는데, 이 머신은 버스트 크레딧 방식입니다. 보장되는 CPU 몫은 작고, 더 빨리 돌려면 크레딧을 씁니다. 8월 부하 테스트에서 지속 부하가 걸리면 초당 약 20~30 요청 수준으로 스로틀링된다는 것을 확인했었습니다. &ldquo;피크 시간에 요청 4분의 1이 타임아웃&quot;은 딱 그 스로틀링처럼 보였고, 그래서 API를 <code>shared-cpu-2x</code>로 옮겼습니다.</p>
<p>그런데 그 504는 전부 Early Hints 캐시 미스였습니다. <code>requestSource</code>로 나누고 오리진 자체 로그까지 다시 보니 이랬습니다.</p>
<ul>
<li>10월 28일부터 11월 4일까지 Cloudflare의 5xx는 하루 한 건 정도의 525를 빼면 모두 <code>earlyHintsCache</code> 조회였습니다. (확인한 525는 보안 스캐너에서 온 것이었습니다.)</li>
<li>10월 24일부터 11월 4일까지 오리진 액세스 로그에는 5xx가 한 건도 없었고, 10초를 넘긴 요청은 한 건뿐이었습니다.</li>
<li>Fly 메트릭으로 보면 가장 많을 때도 초당 7요청 정도였고, 머신이 쓴 CPU는 코어 하나의 5분의 1을 넘지 않았습니다. 스로틀링 상한 근처에도 가지 않았습니다.</li>
</ul>
<p>더 큰 머신은 아무것도 고치지 않았습니다. 고장 난 게 없었으니까요. API는 다시 <code>shared-cpu-1x</code>로 돌려놓았습니다.</p>
<p>request source로 나눠 보지 않은 5xx 차트를 근거로 용량을 결정하지 마세요. 오리진 자체 로그와도 맞춰 보세요. Cloudflare는 요청의 4분의 1이 실패했다는데 서버 로그에 에러가 없다면, 서버를 손대기 전에 Cloudflare가 무엇을 세고 있는지부터 확인하세요.</p>
<p><em>2026년 11월 5일 업데이트: 이 섹션의 첫 버전에는 그 주에 API가 <code>shared-cpu-1x</code>에서 실제로 무너졌다고 썼습니다. 실제로는 그렇지 않았습니다. 위 수치는 확인해서 나온 결과입니다.</em></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(17 이상)는 <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> 헤더로 옮겨 적으면 됩니다. 표준 라이브러리의 <code>html.parser</code>는 따옴표가 있는 속성도 없는 속성도(Hugo의 minifier는 따옴표를 떼어 냅니다) 처리해 주니, 정규식으로 틀릴 여지가 없습니다.</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">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</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">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</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">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</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">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</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">str</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">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</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="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</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>저를 물었던 디테일 하나는 꼭 챙길 만합니다. 폰트에는 <code>crossorigin</code>을 유지하세요. 이게 없는 폰트 preload는 다른 요청으로 취급돼서 폰트를 두 번 내려받게 되고, 페이지는 빨라지기는커녕 느려집니다.</p>
<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>nginx에서는 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>Laravel과 Vite 조합에는 이미 이를 위한 미들웨어 <code>AddLinkHeadersForPreloadedAssets</code>가 있어서, Vite의 preload를 <code>Link</code> 헤더로 바꿔 줍니다. 여기에 보고된 이슈 두 가지가 위의 함정과 똑같습니다. <code>modulepreload</code> 힌트가 <code>crossorigin</code> 없이 나가서 브라우저가 이를 버리고 스크립트를 두 번 받을 수 있었고, 큰 페이지에서는 헤더가 Apache의 약 8KB 헤더 한도를 넘기도 했습니다. 쓰는 버전에서 둘 다 확인하세요.</p>
</li>
<li>
<p>그 밖의 환경: <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 비중이 큰 존에서는 이게 여전히 대부분입니다. 합리적인 선택지는 세 가지입니다. 효과가 있는 페이지에 헤더를 보내고, API 경로가 계속 미스를 남기는 것은 받아들이는 것. 기능은 동작하고, 차트는 조용해지지만 완전히 조용하지는 않습니다. 무엇을 결정하든 <code>earlyHintsCache</code>를 5xx 알림과 대시보드에서 제외하는 것. 내부 캐시 조회 때문에 누가 호출을 받아서는 안 됩니다. 아니면 <code>Link</code> 헤더를 앞으로도 보내지 않을 거라면 Early Hints를 끄는 것(대시보드의 Speed → Settings → Content Optimization, 또는 API의 존 설정 <code>early_hints</code>). HTML이 아주 작은 싱글 페이지 앱은 얻는 게 별로 없을 수 있고, 그러면 모든 조회가 멈추면서 노이즈도 같이 사라집니다.</p>
<p>저는 켜 두기로 했습니다. 웹 페이지는 이제 출발이 빨라졌고, API의 미스는 알림이 걸리는 모든 곳에서 걸러 냈습니다. 9월에 기능을 끄는 대신 했어야 할 일이 바로 이것이었습니다.</p>
]]></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>
