RSS

한국에서 접속하는데 Cloudflare가 로스앤젤레스에서 응답했습니다

이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.

엣지 캐시에 적중한 응답입니다. 서울에서 서울로 오는 거라면 이렇게 나올 수가 없습니다.

colo=LAX, loc=KR

Cloudflare는 어느 엣지가 응답했는지 알려주는 진단 주소를 모든 도메인에 열어둡니다.

curl -s https://example.com/cdn-cgi/trace | grep -E '^(colo|loc)='

colo가 응답한 데이터센터 코드고 loc이 요청자의 국가입니다. 제 결과는 이랬습니다.

colo=LAX
loc=KR

한국에서 요청했는데 로스앤젤레스가 받았습니다. 서울(ICN) POP이 있는데도요.

먼저 제 탓인지 확인했습니다

처음에는 회선이나 DNS 문제라고 생각했습니다. 그러면 이 컴퓨터에서 나가는 모든 Cloudflare 트래픽이 똑같이 LA로 가야 합니다. 같은 순간에 다른 도메인을 찍어봤습니다.

for h in cdn.duolabs.co.kr duolabs.co.kr www.cloudflare.com; do
  colo=$(curl -s --max-time 20 "https://$h/cdn-cgi/trace" | awk -F= '/^colo=/{print $2}')
  printf "%-24s %s\n" "$h" "$colo"
done
cdn.duolabs.co.kr        LAX
duolabs.co.kr            LAX
www.cloudflare.com       ICN

같은 머신, 같은 회선, 같은 순간입니다. www.cloudflare.com은 서울에서 응답했습니다. 회선 문제가 아니었습니다.

IP 대역과 POP이 정확히 갈립니다

그러면 도메인마다 뭐가 다른지 봐야 합니다. DNS 응답을 같이 찍어봤습니다.

도메인 해석된 IP POP TLS 수립
www.cloudflare.com 104.16.123.96 ICN 37 ms
developers.cloudflare.com 104.16.4.189 ICN 34 ms
blog.cloudflare.com 104.18.28.7 ICN 37 ms
cdn.duolabs.co.kr 104.21.4.19 LAX 318 ms
zod.dev 172.67.180.179 LAX 295 ms
hono.dev 104.26.8.16 LAX 299 ms

104.16, 104.18 대역은 서울로 붙고 104.21, 104.26, 172.67 대역은 LA로 붙습니다. 마지막 두 줄은 제 도메인이 아니라 널리 쓰이는 오픈소스 문서 사이트인데, 같은 대역이면 똑같이 LA였습니다.

제 설정 문제도 아니었습니다. 어떤 IP 풀에 들어갔느냐가 어느 POP에서 서비스되는지를 가릅니다. 그리고 그 풀은 존의 요금제 등급에 따라 정해집니다.

한국은 통신사 피어링 비용이 세계에서 가장 비싼 축에 듭니다. 무료 등급 트래픽을 서울 POP에서 받지 않고 해외로 돌리는 건 그 비용 구조를 생각하면 이해가 갑니다. 서운하지만 놀랍지는 않았습니다.

얼마나 손해인가

응답을 구간별로 쪼개봤습니다. curl이 각 단계의 누적 시간을 알려줍니다.

curl -s -o /dev/null \
  -w "DNS %{time_namelookup}\nTCP %{time_connect}\nTLS %{time_appconnect}\nTTFB %{time_starttransfer}\n전체 %{time_total}\n" \
  https://example.com/

누적값이라 구간별 소요는 빼서 봐야 합니다. 다섯 번 재서 중앙값을 낸 결과입니다.

구간 무료 등급 도메인 (LAX) 비교용 (ICN)
TCP 연결 141 ms 14 ms
TLS 수립 152 ms 23 ms
서버 대기 413 ms 18 ms
전체 862 ms 453 ms

연결을 여는 데만 293ms를 씁니다. 이건 캐시가 걸렸든 아니든, 방문자가 페이지를 열 때마다 내는 고정 비용입니다.

더 아픈 쪽은 서버 대기 413ms입니다. LA 엣지가 한국에 있는 오리진까지 다녀오는 시간입니다. 요청 하나가 한국 → LA → 한국 → LA → 한국으로 태평양을 두 번 더 건넙니다.

여기서 좀 허탈했습니다. 제가 방금 공들여 켠 엣지 캐시의 적중 응답이 156ms인데, 한국 사용자가 한국 오리진에 직접 붙으면 그보다 빠를 수 있습니다. CDN이 오리진 부하는 덜어주면서 체감 지연은 늘리고 있었던 셈입니다.

POP은 못 바꿔도 횟수는 줄일 수 있습니다

POP 배정은 설정으로 못 바꿉니다. 그런데 위 표를 다시 보면, 862ms 중 413ms가 오리진 왕복입니다. POP을 못 바꿔도 태평양을 건너는 횟수는 줄일 수 있습니다. 엣지에 캐시된 것이 많을수록 오리진에 갈 일이 줄어드니까요.

그래서 HTML이 캐시되고 있는지 봤습니다.

cf-cache-status: DYNAMIC
cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

헤더는 멀쩡합니다. s-maxage=300은 "공유 캐시는 5분간 저장해달라"는 명시적 요청입니다. 그런데 DYNAMIC입니다.

Cloudflare는 HTML을 기본적으로 캐시하지 않습니다. 기본 캐시 대상은 확장자로 판별되는 정적 파일이고, HTML은 여기 들어가지 않습니다. Cache-Control을 아무리 정확히 줘도 요청이 애초에 캐시 대상으로 판정되지 않으면 그 헤더는 읽히지 않습니다.

DYNAMICBYPASS는 여기서 뜻이 다릅니다. BYPASS는 캐시 대상으로 보긴 했는데 저장하지 않은 것이고, DYNAMIC애초에 대상이 아니라고 판정한 것입니다. 헤더를 더 세게 줘서 고칠 수 있는 건 앞쪽뿐입니다.

HTML을 캐시하려면 Cache Rule을 따로 만들어야 합니다. 무료 등급에도 있는 기능입니다.

그런데 Next.js에는 함정이 하나 있습니다

규칙을 만들기 전에 응답을 한 번 더 봤습니다. App Router로 만든 사이트라면 확인해보시길 권합니다.

curl -s https://example.com/blog | head -c 60
curl -s -H 'RSC: 1' https://example.com/blog | head -c 60
<!DOCTYPE html><html lang="ko" ...
1:"$Sreact.fragment" 2:I[12929,[...

같은 URL인데 본문이 다릅니다. 아래쪽은 화면 전환에 쓰는 RSC 페이로드로, content-typetext/x-component로 따로 나갑니다.

Next.js는 정직하게 알려줍니다.

Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding

"이 헤더들에 따라 응답이 달라지니 캐시 키에 넣어달라"는 뜻입니다. 문제는 Cloudflare가 Accept-Encoding 외의 Vary를 캐시 키로 쓰지 않는다는 점입니다.

이 상태로 HTML 캐시를 켜면 엣지가 둘을 같은 것으로 봅니다. 브라우저가 HTML을 기대하는데 RSC 페이로드가 나가거나 그 반대가 되어, 화면이 깨지거나 링크 이동이 먹통이 됩니다. 캐시를 켜자마자 사이트가 이상해지는데 원인은 캐시 설정이 아니라 여기 있습니다.

저는 이걸 이미지 쪽에서 한 번 겪고 왔습니다. 이미지도 Accept 헤더로 AVIF를 협상하느라 Vary: Accept가 붙어 있었고, 그래서 캐시가 안 걸렸습니다. 협상과 엣지 캐시는 양립하지 않습니다.

규칙은 이렇게 만듭니다

RSC 헤더가 없는 요청만 캐시 대상으로 잡습니다. RSC 요청은 규칙에 걸리지 않아 지금처럼 오리진으로 갑니다.

(http.host eq "example.com"
 and (starts_with(http.request.uri.path, "/blog")
      or starts_with(http.request.uri.path, "/docs"))
 and not starts_with(http.request.uri.path, "/docs/internal")
 and not any(http.request.headers.names[*] eq "rsc"))

동작은 Eligible for cache로 두고 TTL은 오리진 헤더를 존중하게 둡니다. 앱이 이미 s-maxagestale-while-revalidate를 잘 정해놨다면 그 값을 그대로 쓰는 편이 낫습니다.

경로를 좁히는 게 중요합니다. /api는 캐시하면 안 되고, 로그인이 필요한 경로도 빼야 합니다. "전부 캐시"는 편하지만 인증이 걸린 응답을 엣지에 올려놓는 사고로 이어집니다.

대가도 알고 켜야 합니다. s-maxage=300이면 글을 고쳐도 최대 5분 뒤에 반영됩니다. stale-while-revalidate 때문에 그 사이 방문자는 옛 버전을 받고 갱신은 뒤에서 일어납니다. 즉시 반영이 필요하면 해당 URL을 퍼지해야 합니다.

배운 것

측정값이 어색하면 그 어색함을 따라가야 합니다. 156ms는 "빨라졌다"로 넘어갈 수 있는 숫자였습니다. 그걸 넘겼으면 훨씬 큰 문제를 못 봤을 겁니다. 개선 폭만 보고 절대값을 안 보면 이런 걸 놓칩니다.

원인을 좁힐 때는 내가 통제하는 변수부터 지워야 합니다. 회선 탓인지 확인하는 데 명령 세 줄이면 됐고, 그 세 줄이 "내 설정 문제"라는 잘못된 방향으로 반나절 쓰는 걸 막아줬습니다.

고칠 수 없는 원인을 만나도 할 일은 남습니다. POP 배정은 제가 못 바꿉니다. 그런데 오리진 왕복을 줄이는 건 무료 기능으로 됩니다. 원인을 제거하지 못할 때는 그 원인이 몇 번 발동하는지를 줄이는 쪽으로 보면 길이 생깁니다.

남은 선택지는 둘입니다. 유료 등급으로 올려 POP 배정을 바꾸거나, 서울 POP을 두고 사용량만큼 받는 CDN으로 옮기는 것입니다. 어느 등급부터 서울 POP이 열리는지는 확인하지 못했습니다. 확실한 방법은 한 달 올려보고 colo=를 다시 찍어보는 것입니다.