DEVELOPMENT NOTE3

런타임 전환 뒤 느려진 요청을 나눠 측정하기

Network와 route rendering, MDX compile 비용을 측정하고 각각의 cache 경계를 적용한 과정

#architecture#performance#nextjs#vercel#caching#mdx
아카이브로 돌아가기

독립성을 얻은 대신 요청 경로가 길어졌다

프런트엔드가 checkout과 생성 파일 없이 요청할 때마다 백엔드 API를 읽게 되면서 콘텐츠와 애플리케이션 배포는 분리됐다. 대신 요청은 Vercel Function과 OCI 백엔드, 검증을 지나고 상세에서는 MDX와 KaTeX, Shiki까지 실행했다.

이전 prerender 배포보다 route TTFB가 조건에 따라 약 200600ms 늘었다. Warm 외부 관찰에서 백엔드 API는 대체로 90103ms였지만 /blog는 약 415ms, 상세는 약 713ms였다. Historical deployment와 현재 운영은 글 수와 CDN, region, connection 조건이 달라 완전한 A/B는 아니었다.

구조를 되돌리기 전에 요청 안에서 시간을 나눠 측정했다.

외부 TTFB와 내부 구간을 분리했다

공통 client에는 backend fetch와 JSON parse, contract validation timing을 넣었다. 상세 loader는 backend와 adapter, MDX, 전체 시간을 기록했고 MDX 안에서도 asset rewrite와 KaTeX, Shiki, 나머지 compile을 나눴다. 본문이나 환경 변수는 로그에 넣지 않았다. 검색 Route Handler는 같은 값을 Server-Timing으로 제공했다.

iad1 상세 baseline 한 번에서는 backend 평균 56.2ms, MDX 355.4ms, loader 412.1ms였고 외부 TTFB는 945~1,218ms였다. MDX가 더 큰 application 비용이었지만 loader 밖에도 시간이 남아 외부 지연을 전부 MDX나 백엔드로 돌릴 수 없었다.

검색은 backend 233.6ms, 내부 total 234.3ms, 외부 TTFB 약 1,038ms였다. 내부 대부분은 backend 호출이었고 Function과 origin의 위치를 먼저 확인할 근거가 생겼다.

Function을 백엔드 가까이 옮겼다

Vercel Function은 미국 동부 iad1, OCI는 ap-chuncheon-1에 있었다. ISR이나 다른 cache는 바꾸지 않고 Function region만 Seoul icn1으로 옮겼다.

같은 조건의 region A/B에서 검색 backend span은 222.9ms에서 32.8ms로 줄었다. 백엔드 code가 빨라진 것이 아니라 Function과 origin 사이 network 거리의 영향이다. 앞선 233.6ms와는 다른 snapshot이므로 하나의 평균으로 합치지 않았다. icn1도 현재 사용자와 origin에 맞는 선택이지 전 세계의 보편적인 최적값은 아니다.

Data Cache와 route cache를 구분했다

Backend fetch에는 이미 300초 TTL과 houkago-posts tag가 있었지만 운영 route는 계속 x-vercel-cache: MISS였다. Fetch Data Cache가 hit해도 Server Component와 MDX는 다시 실행될 수 있었다.

Build에서 글 목록을 가져오지 않고 cache 가능한 dynamic segment에 on-demand ISR을 적용했다.

ts
export const revalidate = 300;
 
export function generateStaticParams() {
  return [];
}

Pagination과 상세, Category, Tag는 첫 요청에서 생성하고 이후 Full Route Cache를 사용한다. 콘텐츠 publication의 revalidateTag("houkago-posts")가 관련 data와 route를 지우면 다음 요청에서 최신 상태로 다시 만든다.

대표 상세에서 dynamic warm TTFB 약 946ms와 Full Route Cache HIT 약 376ms가 관찰됐다. /blog root는 build-time backend 결합을 피하려고 dynamic으로 남겼고 검색도 no-store를 유지했다. 백엔드에 연결할 수 없는 build도 계속 성공했다.

변경되지 않은 MDX 결과를 재사용했다

새 slug의 첫 요청이나 revalidation 뒤에는 여전히 MDX compile이 필요했다. 평소 약 300~460ms였고 한 운영 cold regeneration에서는 2,643.1ms까지 올라갔다. 해당 code-heavy 문서에서는 Shiki 초기화와 highlighting이 큰 후보였다.

Highlighter promise를 module scope에서 재사용했지만 serverless instance가 달라지면 다시 초기화된다. 더 가벼울 것으로 예상한 JavaScript regex engine도 대표 C++ 글에서 output은 같았지만 cold 시간이 악화돼 채택하지 않았다. Durable evidence가 없어 새 수치는 붙이지 않았다.

대신 serialized MDX의 key를 다음 입력으로 만들었다.

text
renderer version + rawBody + assetBaseUrl

같은 입력은 24시간 bounded Data Cache에서 재사용한다. Metadata만 바뀌면 route는 다시 만들어도 MDX 결과는 재사용하고, 본문이나 asset origin이 바뀌면 새 key로 compile한다. 한 동일-content 비교에서 cold MDX 2,643.1ms가 reuse 13.0ms로 줄었다. 전체 상세의 평균이 아니라 같은 결과를 재사용한 특정 비교다.

Full Route Cache는 route 전체를 우회하고 MDX cache는 route를 다시 만들 때 변경되지 않은 변환을 우회한다.

검색은 작은 응답 대신 실행 비용을 냈다

기존 생성 색인은 258개 글, 687,611바이트였다. 요청마다 수행하는 검색은 질의에 필요한 요약 페이지만 보내며 최종 snapshot의 한 응답은 1,371바이트였다. 생성 색인 크기와 네트워크 전송량, 질의별 응답을 같은 기준으로 나눠 개선률을 계산하지는 않았다.

캐시가 준비된 검색의 백엔드·내부 구간은 평균 59.9·61.1ms였지만 외부 TTFB·전체는 706.4·706.8ms였다. 내부 밖의 플랫폼과 연결 비용이 남았다. 생성 색인으로 돌아가면 실행 비용 일부는 줄지만 콘텐츠 변경마다 프런트엔드 결과물을 다시 빌드하고 배포해야 하므로 채택하지 않았다.

숫자는 각 단계 안에서만 비교했다

단계비교관찰값
상세 baseline내부 구간Backend 56.2ms, MDX 355.4ms, total 412.1ms
검색 baseline내부 구간Backend 233.6ms, total 234.3ms
Region A/Biad1icn1Backend 222.9ms → 32.8ms
상세 routeDynamic → HITTTFB 약 946ms → 376ms
상세 MDXCold → 같은 결과 reuse2,643.1ms → 13.0ms

서로 다른 deployment와 cache 상태를 한 연속 benchmark로 읽지 않았다. 직접 비교는 각 행 안에서만 가능하다.

2026년 8월 21일 한국 외부 client의 warm snapshot은 다음과 같았다.

RouteTTFB / total
/blog650.7 / 770.5ms
Category398.1 / 503.1ms
Tag479.8 / 482.1ms
Detail382.3 / 486.2ms
Page 2418.7 / 523.5ms
Search706.4 / 706.8ms

curl은 새 connection이었고 CDN과 platform 편차가 포함됐다. Cache 가능한 segment는 다시 HIT가 됐지만 /blog와 검색은 설계대로 MISS였다. 장기 평균이나 SLO가 아니며 모든 화면의 단일 개선률도 만들지 않았다.

Runtime이 static보다 항상 빠른 것은 아니다

새 본문의 첫 MDX compile과 cache 재생성 첫 요청은 비용을 낸다. /blog와 검색도 request-time 경로를 지난다. 300초 TTL은 무효화 실패에 대비하는 대신 짧은 stale window를 허용한다. Renderer version과 여러 cache도 운영해야 한다.

Runtime을 선택한 이유는 모든 요청을 static보다 빠르게 만들기 위해서가 아니었다. 원본과 프런트엔드 lifecycle을 분리하고 공개 데이터의 authority를 백엔드 하나로 모으기 위해서였다. 현재 규모에서 근거 없는 MySQL tuning과 content precompile, generated 검색 복귀, 강제 static화를 하지 않았다. 대신 timing을 남겨 network와 application, MDX 비용을 이후에도 구분할 수 있게 했다.