↓ 본문으로 건너뛰기

Cloudflare Early Hints HTTP 103

Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103

Cloudflare에 504 에러가 수천 건 찍혔는데, 실제 사용자에게 간 건 하나도 없었습니다. 전부 Early Hints 캐시 미스였죠. HTTP 103의 동작 방식, 가짜 504와 진짜 504를 구별하는 법, 그리고 문제를 해결한 헤더 하나를 정리했습니다.

Cloudflare 504 에러, 사실은 장애가 아니었다: Early Hints와 HTTP 103

Cloudflare 대시보드는 제 요청의 4분의 1이 504라고 말하고 있었습니다. 그중 사용자에게 간 것은 하나도 없었습니다. 정체는 Cloudflare Early Hints 기능의 캐시 미스였고, ClientRequestSource = earlyHintsCache, OriginResponseStatus = 0인 504로 기록된 것이었죠. 이 글에서는 이것을 진짜 504와 구별하는 법, 그리고 미스를 히트로 바꿔 주는 헤더 하나를 정리합니다.

처음 마주친 건 9월이었습니다. 헬스 체크는 전부 초록불인데 Cloudflare 대시보드에는 요청의 22%가 실패로 찍혀 있던 때였고, 저는 Early Hints를 끄고 넘어갔습니다. 11월에 같은 존(zone)을 보니 Early Hints가 다시 켜져 있었고 하루 만에 504가 또 2,600건 쌓여 있었습니다. 이번에는 그 정체를 정확히 알고 싶었고, 기능을 또 꺼야 하는지 아니면 제대로 일하게 만들어야 하는지도 확실히 하고 싶었습니다. 이 존은 Fly.io 오리진 하나를 앞에서 받고 있고, 그 오리진이 Django API, Expo 웹 앱, Hugo로 만든 마케팅 사이트를 서빙합니다.

짧은 답
#

  • 이 504는 방문자가 아니라 Cloudflare의 Early Hints 캐시가 기록한 것입니다. Cloudflare 로그에서는 ClientRequestSource = earlyHintsCache로 표시됩니다.
  • 여기서 504는 캐시 미스라는 뜻입니다. 그 URL에 대해 저장된 힌트가 없었다는 것이죠. 히트는 2xx로 기록됩니다.
  • 이 요청은 오리진에 절대 도달하지 않습니다. OriginResponseStatus가 0입니다.
  • Early Hints는 Link HTTP 응답 헤더로 동작합니다. HTML 안의 <link rel="preload"> 태그로는 동작하지 않습니다.
  • 헤더를 보내면, 미리 불러올 것이 있는 페이지에서는 이 미스가 히트로 바뀌고 진짜 103 Early Hints 응답이 나가기 시작합니다.

미리 알아 두면 좋은 것이 세 가지 더 있습니다. 힌트는 렌더링에 꼭 필요한 CSS, 스크립트, 폰트만 주고, 폰트에는 crossorigin을 유지하세요. 확인은 curl -sv --http2로 하면 되고, 200 전에 103이 보여야 합니다. 그리고 무엇을 결정하든 earlyHintsCache는 알림에서 빼 두고, 헤더를 앞으로도 보내지 않을 거라면 로그 노이즈로 비용을 치르느니 Early Hints를 끄세요.

가짜 Cloudflare 504와 진짜 504 구별하기
#

Logpush나 Instant Logs를 쓴다면 무엇보다 먼저 두 필드를 보세요. ClientRequestSource는 실제 방문자 요청이면 eyeball, Early Hints 조회면 earlyHintsCache입니다. OriginResponseStatus가 0이면 오리진에 연결조차 하지 않은 것입니다.

GraphQL Analytics API에서는 같은 구분이 requestSource 디멘션으로 나옵니다. 저는 이제 5xx가 튀면 믿기 전에 이 쿼리부터 돌립니다.

query ($zone: String!, $since: Time!) {
  viewer {
    zones(filter: { zoneTag: $zone }) {
      httpRequestsAdaptiveGroups(
        limit: 20
        filter: { datetime_geq: $since, edgeResponseStatus: 504 }
        orderBy: [count_DESC]
      ) {
        count
        dimensions { requestSource userAgent clientRequestPath }
      }
    }
  }
}

User-Agent로 묶어 봐도 같은 결과가 나옵니다. 9월에는 Early Hints 조회가 bastion early hints와 nginx-ssl early hints로 찍혔습니다. 11월에는 fl2 early hints와 nginx-ssl early hints였습니다(FL2는 Cloudflare 프록시의 새 세대입니다). 24시간 동안 이 존의 504 2,631건이 전부 이 User-Agent에서 나왔습니다. 브라우저, 앱, curl, 검색 크롤러에서 나온 것은 하나도 없었습니다.

방문자가 실제로 본 진짜 504는 거의 언제나 Cloudflare 바깥, 즉 여러분 쪽에서 나온 것입니다. 애플리케이션 앞에 있는 프록시나 로드 밸런서가 기다리다 포기한 것이죠. nginx일 수도 있고, 클라우드 로드 밸런서일 수도 있고, 잠든 머신이 깨어나길 기다리는 플랫폼 프록시일 수도 있습니다. Cloudflare 자체의 타임아웃은 다른 코드를 씁니다. 522는 오리진에 연결하지 못했다는 뜻이고, 524는 연결은 됐지만 125초(Enterprise 미만 요금제의 기본값) 안에 응답을 받지 못했다는 뜻입니다.

그러니 504 때문에 Cloudflare 타임아웃을 늘리는 방법을 찾다가 이 글에 오셨다면: 504 뒤에 있는 타임아웃은 거의 Cloudflare 것이 아닙니다. nginx의 proxy_read_timeout, 로드 밸런서의 idle timeout, 또는 플랫폼이 머신을 깨우는 데 걸리는 시간을 확인하세요. 쓸 만한 단서가 하나 있습니다. 요청이 125초보다 한참 짧은 딱 떨어지는 숫자, 예를 들어 30초나 60초에서 실패한다면 Cloudflare가 아니라 여러분 프록시의 한도입니다. (Cloudflare의 125초 한도는 Enterprise 요금제에서만 최대 6,000초까지 늘릴 수 있고, Cloudflare 스스로도 이를 임시방편으로 봅니다.)

이 504의 정체
#

Early Hints가 켜진 존에서는 HTML 페이지나 확장자 없는 URL로 가는 요청마다(API가 많은 존에서는 API 호출 대부분이 여기 해당합니다) 내부 조회가 하나 더 붙습니다. Cloudflare가 보낼 힌트가 있는지 자기 캐시를 확인하는 것이죠. 이 조회는 ClientRequestSource = earlyHintsCache로 별도 줄에 기록됩니다. 최종 사용자 요청이 아니고, 오리진으로 보내지지도 않습니다.

그 줄의 상태 코드는 에러가 아니라 캐시 조회 결과입니다.

  • 2xx는 히트입니다. Cloudflare에 그 URL용 Link 헤더가 저장돼 있었고, 이를 103으로 브라우저에 보냈다는 뜻입니다. 제 존에서는 히트가 200과 204 두 가지로 나타납니다.
  • 504는 미스입니다. 저장된 것이 없어서 미리 보낼 것이 없었다는 뜻입니다.

오리진이 Early Hints가 쓰는 헤더를 한 번도 보내지 않으면 모든 조회가 미스가 되고, 모든 요청이 504 줄을 하나씩 남깁니다. 제가 아무것도 바꾸기 전 3시간 동안 이 존은 Early Hints 조회를 173건 기록했고, 173건 모두 미스였습니다. 기능이 켜진 뒤로 힌트를 한 번도 보낸 적이 없었던 겁니다.

9월 글에 대한 정정도 하나 하겠습니다. 저는 그때 이것을 “Fly 오리진에서 응답을 받지 못하는” 프로브라고 설명했습니다. 정확한 설명이 아닙니다. 이 조회는 오리진에 아예 가지 않습니다. 504는 타임아웃이 아니라 캐시 미스를 기록한 것입니다.

Cloudflare도 Early Hints 문서에서 이 점을 짧게 설명하고 있습니다. ClientRequestSource: earlyHintsCache로 기록된 Early Hints 조회는 “expected and benign”(예상된 것이고 무해함)이며, 200은 캐시 히트, 504는 미스입니다. Log Explorer에서는 ClientRequestSource != "earlyHintsCache"로 걸러낼 수 있습니다. Cache Analytics는 원래부터 이걸 빼고 보여 주지만, 저를 놀라게 한 HTTP 트래픽 차트는 그렇지 않습니다.

가짜 504 때문에 서버를 업그레이드했다
#

이 구분이 중요한 이유가 여기 있습니다. 이 글을 쓰기 시작하기 전날인 11월 3일, Cloudflare에는 방문자 요청 약 13,300건에 504가 3,292건 기록됐습니다. 겉보기 에러율이 25%입니다. API는 Fly.io의 shared-cpu-1x 머신에서 돌고 있는데, 이 머신은 버스트 크레딧 방식입니다. 보장되는 CPU 몫은 작고, 더 빨리 돌려면 크레딧을 씁니다. 8월 부하 테스트에서 지속 부하가 걸리면 초당 약 20~30 요청 수준으로 스로틀링된다는 것을 확인했었습니다. “피크 시간에 요청 4분의 1이 타임아웃"은 딱 그 스로틀링처럼 보였고, 그래서 API를 shared-cpu-2x로 옮겼습니다.

그런데 그 504는 전부 Early Hints 캐시 미스였습니다. requestSource로 나누고 오리진 자체 로그까지 다시 보니 이랬습니다.

  • 10월 28일부터 11월 4일까지 Cloudflare의 5xx는 하루 한 건 정도의 525를 빼면 모두 earlyHintsCache 조회였습니다. (확인한 525는 보안 스캐너에서 온 것이었습니다.)
  • 10월 24일부터 11월 4일까지 오리진 액세스 로그에는 5xx가 한 건도 없었고, 10초를 넘긴 요청은 한 건뿐이었습니다.
  • Fly 메트릭으로 보면 가장 많을 때도 초당 7요청 정도였고, 머신이 쓴 CPU는 코어 하나의 5분의 1을 넘지 않았습니다. 스로틀링 상한 근처에도 가지 않았습니다.

더 큰 머신은 아무것도 고치지 않았습니다. 고장 난 게 없었으니까요. API는 다시 shared-cpu-1x로 돌려놓았습니다.

request source로 나눠 보지 않은 5xx 차트를 근거로 용량을 결정하지 마세요. 오리진 자체 로그와도 맞춰 보세요. Cloudflare는 요청의 4분의 1이 실패했다는데 서버 로그에 에러가 없다면, 서버를 손대기 전에 Cloudflare가 무엇을 세고 있는지부터 확인하세요.

2026년 11월 5일 업데이트: 이 섹션의 첫 버전에는 그 주에 API가 shared-cpu-1x에서 실제로 무너졌다고 썼습니다. 실제로는 그렇지 않았습니다. 위 수치는 확인해서 나온 결과입니다.

HTTP 103은 에러인가?
#

아닙니다. 1xx 대의 상태 코드는 정보성 응답, 즉 서버가 진짜 응답 전에 보낼 수 있는 중간 응답입니다. RFC 8297에 정의된 103 Early Hints는 Link 헤더를 담고 있어서, 서버가 아직 페이지를 만드는 동안 브라우저가 핵심 CSS, JavaScript, 폰트를 미리 받거나 다른 오리진에 연결을 열어 둘 수 있게 해 줍니다. 최종 200은 같은 요청에서 뒤이어 옵니다.

지원 범위는 고르지 않지만 안전합니다. Chromium 계열 브라우저(Chrome 103 이상, Edge)와 최신 Firefox는 preload와 preconnect 힌트를 모두 처리합니다. Safari(17 이상)는 preconnect만 씁니다. 브라우저는 103을 HTTP/2와 HTTP/3에서만 처리합니다. 이해하지 못하는 브라우저는 그냥 평소처럼 200을 기다릴 뿐이니, 103은 대부분의 방문자에게는 출발 신호를 앞당겨 주고 나머지에게는 아무 영향이 없습니다.

제 설정이 비어 있던 이유: Link 헤더 vs <link rel=preload>#

여기서 시간을 가장 많이 썼습니다. 이미 동작해야 할 것처럼 보였기 때문입니다.

Early Hints가 캐시하는 것은 딱 하나, rel=preload나 rel=preconnect 관계를 가진 Link HTTP 응답 헤더입니다. Cloudflare는 평소 응답(HTML 또는 확장자 없는 URL의 200, 301, 302)에서 이 헤더를 읽어 저장해 두고, 다음 방문자에게 전체 응답이 준비되기 전에 103으로 다시 보내 줍니다.

제 페이지에는 이미 preload가 있었습니다. 다만 HTML <head> 안의 태그로요.

<link rel="preload" href="/_expo/static/css/global-7d77bd.css" as="style">
<link rel="preload" href="/_expo/static/js/web/entry-fd7fa4.js" as="script">
<link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin>

이 태그도 쓸모가 있습니다. 브라우저가 이걸 보고 움직이니까요. 하지만 브라우저는 HTML을 받아서 파싱을 시작한 뒤에야 이 태그를 봅니다. Early Hints가 앞지르려는 바로 그 순간이죠. Expo 익스포트와 제가 쓰는 Hugo 테마 둘 다 이 태그는 알아서 써 주지만, 헤더는 둘 다 설정하지 않습니다. 결국 기능은 켜져 있고 preload도 선언돼 있었지만, 둘은 한 번도 만나지 못했습니다.

해결책: 이미 가진 데이터로 헤더를 보내기
#

가장 깔끔한 해결책은 번들 해시가 바뀔 때마다 낡아 버리는 핵심 에셋 목록을 손으로 관리하는 것이 아닙니다. 각 페이지는 무엇을 미리 불러올지 이미 그 태그로 선언하고 있습니다. 그러니 페이지를 서빙할 때 그 태그를 Link 헤더로 옮겨 적으면 됩니다. 표준 라이브러리의 html.parser는 따옴표가 있는 속성도 없는 속성도(Hugo의 minifier는 따옴표를 떼어 냅니다) 처리해 주니, 정규식으로 틀릴 여지가 없습니다.

from html.parser import HTMLParser


class PreloadLinks(HTMLParser):
    def __init__(self):
        super().__init__()
        self.hints = []

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag != "link" or "preload" not in (a.get("rel") or "").split() or not a.get("href"):
            return
        hint = f"<{a['href']}>; rel=preload"
        if a.get("as"):
            hint += f"; as={a['as']}"
        if a.get("as") == "font" or "crossorigin" in a:
            hint += "; crossorigin"
        self.hints.append(hint)


def link_header(html: str, limit: int = 12) -> str | None:
    parser = PreloadLinks()
    parser.feed(html)
    return ", ".join(list(dict.fromkeys(parser.hints))[:limit]) or None

그리고 Django에서 HTML 응답에 붙입니다.

response = HttpResponse(html, content_type="text/html")
if (link := link_header(html)) and "Link" not in response:
    response["Link"] = link

저를 물었던 디테일 하나는 꼭 챙길 만합니다. 폰트에는 crossorigin을 유지하세요. 이게 없는 폰트 preload는 다른 요청으로 취급돼서 폰트를 두 번 내려받게 되고, 페이지는 빨라지기는커녕 느려집니다.

103을 직접 쓸 필요는 없습니다. ASGI, Granian, uvicorn에 중간 응답 코드를 추가할 일도 없습니다. Cloudflare가 여러분의 헤더로 103을 만들고, 여러분이 할 일은 헤더를 보내는 것뿐입니다.

다른 스택에서는
#

규칙은 어디서나 같습니다. HTML 응답에 Link 헤더를 달고 나머지는 Cloudflare에 맡기세요.

  • nginx에서는 HTML을 서빙하는 location에:

    location = / {
        add_header Link "</css/app.css>; rel=preload; as=style" always;
        add_header Link "</fonts/inter.woff2>; rel=preload; as=font; crossorigin" always;
    }
  • Laravel과 Vite 조합에는 이미 이를 위한 미들웨어 AddLinkHeadersForPreloadedAssets가 있어서, Vite의 preload를 Link 헤더로 바꿔 줍니다. 여기에 보고된 이슈 두 가지가 위의 함정과 똑같습니다. modulepreload 힌트가 crossorigin 없이 나가서 브라우저가 이를 버리고 스크립트를 두 번 받을 수 있었고, 큰 페이지에서는 헤더가 Apache의 약 8KB 헤더 한도를 넘기도 했습니다. 쓰는 버전에서 둘 다 확인하세요.

  • 그 밖의 환경: Link 헤더 하나에 쉼표로 이어 쓰거나, Link 헤더를 여러 개 보내면 됩니다. 둘 다 됩니다.

사람들이 가장 많이 과하게 하는 부분은 무엇을 힌트로 줄지입니다. 렌더링에 꼭 필요한 CSS, 진입 스크립트, 첫 화면에 쓰이는 폰트 한두 개만 힌트로 주고, 모든 청크를 다 넣지는 마세요. 목록이 길면 브라우저가 지금 필요한 것보다 아직 필요 없는 것을 먼저 받게 됩니다. 저는 12개로 제한했는데, 그것도 Expo 익스포트가 preload 목록을 이미 추려 주기 때문입니다.

Cloudflare 뒤에 있지 않거나 CDN이 Early Hints를 지원하지 않는다면, Link 헤더의 효과는 조금뿐입니다. 브라우저가 본문을 파싱하기 전, 응답 헤더가 도착하자마자 그 리소스를 받기 시작할 수 있다는 정도죠. 서버나 CDN이 보내 주지 않는 한 103은 나가지 않습니다.

제대로 동작하는지 확인하는 법
#

curl로 103을 바로 볼 수 있습니다. HTTP/2로 요청하고 상태 줄과 Link 헤더만 남기세요.

$ curl -sv --http2 -o /dev/null https://example.com/register 2>&1 | grep -E '^< (HTTP|link)'
< HTTP/2 103
< link: </_expo/static/css/reset-f5d396.css>; as=style; rel=preload, …
< HTTP/2 200
< link: </_expo/static/css/reset-f5d396.css>; rel=preload; as=style, …

103이 먼저 오고, 그다음에 200이 옵니다. Cloudflare가 힌트 안의 속성 순서를 바꾸는데, 문제없습니다. 모든 요청이 103을 받을 거라고 기대하지는 마세요. 제 경우 처음 세 번 중 두 번만 받았습니다. 변경 후 첫 방문은 헤더를 저장만 하고, 최종 응답 헤더가 이미 준비돼 있으면 Cloudflare가 103을 건너뜁니다.

그다음 위의 분석 쿼리로 돌아가 requestSource: "earlyHintsCache"로 필터링하고 상태 코드와 경로로 묶어 보세요. 고친 페이지에서는 미스 대신 히트가 보여야 합니다.

무엇이 바뀌었고, 무엇이 남았나
#

헤더를 배포하고 몇 분 만에 이 존은 처음으로 Early Hints 히트를 기록했고, curl에서는 웹 페이지에 진짜 103 응답이 보였습니다. 헤더 하나로 두 문제가 같이 풀렸습니다. 기능이 동작하기 시작했고, “504"의 일부가 사라졌습니다.

남은 문제는 JSON API에는 미리 불러올 것이 없다는 점입니다. API 엔드포인트는 계속 미스가 나고 504 줄을 계속 남기는데, API 비중이 큰 존에서는 이게 여전히 대부분입니다. 합리적인 선택지는 세 가지입니다. 효과가 있는 페이지에 헤더를 보내고, API 경로가 계속 미스를 남기는 것은 받아들이는 것. 기능은 동작하고, 차트는 조용해지지만 완전히 조용하지는 않습니다. 무엇을 결정하든 earlyHintsCache를 5xx 알림과 대시보드에서 제외하는 것. 내부 캐시 조회 때문에 누가 호출을 받아서는 안 됩니다. 아니면 Link 헤더를 앞으로도 보내지 않을 거라면 Early Hints를 끄는 것(대시보드의 Speed → Settings → Content Optimization, 또는 API의 존 설정 early_hints). HTML이 아주 작은 싱글 페이지 앱은 얻는 게 별로 없을 수 있고, 그러면 모든 조회가 멈추면서 노이즈도 같이 사라집니다.

저는 켜 두기로 했습니다. 웹 페이지는 이제 출발이 빨라졌고, API의 미스는 알림이 걸리는 모든 곳에서 걸러 냈습니다. 9월에 기능을 끄는 대신 했어야 할 일이 바로 이것이었습니다.