웹사이트가 느리다고 느껴지는 첫 신호는, 솔직히 말해서, 화면이 비어 있는 그 어색한 시간입니다. 사용자가 링크를 클릭한 순간부터 브라우저가 첫 픽셀을 그리는 그 순간까지 — 이 모든 시간의 출발점이 바로 TTFB(Time to First Byte)죠. TTFB가 1.5초라면? LCP를 아무리 정성껏 다듬어도 사용자는 그냥 1.5초 동안 흰 화면을 응시하고 있을 뿐입니다.
저도 예전에 한 커머스 프로젝트에서 LCP 최적화에 두 달을 쏟았는데, 정작 P75 TTFB가 1.4초였다는 사실을 뒤늦게 알았던 경험이 있습니다. 그때부터는 어떤 성능 작업이든 무조건 TTFB 측정부터 시작합니다.
이 가이드는 2026년 기준으로 TTFB를 정확히 측정하고, 800ms 이하로 줄이기 위한 실전 전략을 다룹니다. CDN 엣지 캐싱, Early Hints(103), HTTP/3, 엣지 함수, 데이터베이스 최적화까지 — 한국 서비스 환경에서 그대로 갖다 쓸 수 있는 코드 예시와 함께 정리했습니다. 자, 그럼 시작해볼까요.
TTFB란 무엇인가
TTFB는 브라우저가 요청을 보낸 시점부터 서버 응답의 첫 번째 바이트를 수신할 때까지 걸린 시간입니다. 이름은 단순하지만, 그 사이에는 의외로 많은 단계가 들어 있습니다.
- 리다이렉트 시간 — HTTP → HTTPS, www 정규화, 트레일링 슬래시 처리 등
- 서비스 워커 시작 시간 — 등록된 서비스 워커가 있을 경우
- DNS 조회 — 도메인 → IP 주소 변환
- TCP 연결 — 3-way 핸드셰이크
- TLS 협상 — HTTPS 암호화 채널 수립
- 요청 처리(서버 사고 시간) — 라우팅, DB 쿼리, 템플릿 렌더링
- 네트워크 왕복(RTT) — 응답의 첫 바이트가 네트워크를 타고 돌아오는 시간
이 모든 시간이 누적되어 TTFB가 됩니다. 즉 TTFB는 단일 지표라기보다 여러 하위 단계의 합이고, 분해해서 보지 않으면 정확히 어디서 시간이 새는지 알기 어렵습니다(이게 핵심입니다).
TTFB와 Core Web Vitals의 관계
TTFB는 Core Web Vitals(LCP, CLS, INP)에 직접 포함된 메트릭은 아닙니다. 하지만 LCP와 FCP(First Contentful Paint)의 하한선(floor) 역할을 한다는 점이 중요합니다. TTFB가 1,000ms라면? LCP는 절대 1,000ms 미만이 될 수 없습니다. 물리적으로 불가능한 거죠.
Google 공식 권장 기준은 다음과 같습니다 (모두 75퍼센타일 기준).
- 좋음(Good): 800ms 이하
- 개선 필요(Needs Improvement): 800ms ~ 1,800ms
- 나쁨(Poor): 1,800ms 초과
한국 사용자 대상 서비스에서는 모바일 4G/5G 환경, 통신사별 DNS 캐시 차이, 그리고 글로벌 origin까지의 RTT(보통 일본 도쿄 130ms, 미국 서부 150ms 이상)를 모두 고려해야 합니다. 사실 이 부분이 한국 서비스의 가장 큰 변수입니다.
TTFB 측정하기 — 정확한 진단이 먼저다
TTFB를 줄이기 전에 먼저 현재 값과 그 구성을 측정해야 합니다. "전체 TTFB 1.2초"라는 단일 숫자만으로는 무엇부터 손대야 할지 도무지 알 수가 없거든요.
방법 1: web-vitals.js 라이브러리
실사용자 데이터(RUM)를 수집하는 가장 간단한 방법입니다. 2026년 기준 v5.x에서는 Attribution 빌드를 통해 TTFB의 하위 구성을 그대로 받을 수 있어서, 사실상 이게 정답에 가깝습니다.
import { onTTFB } from 'web-vitals/attribution';
onTTFB((metric) => {
const { value, attribution } = metric;
// attribution 객체에 하위 단계가 모두 들어있다
console.log({
total: value,
cacheDuration: attribution.cacheDuration,
dnsDuration: attribution.dnsDuration,
connectionDuration: attribution.connectionDuration,
requestDuration: attribution.requestDuration,
serverTiming: attribution.navigationEntry?.serverTiming,
});
// 분석 서버로 전송
navigator.sendBeacon('/analytics/ttfb', JSON.stringify(metric));
});
방법 2: PerformanceNavigationTiming API
라이브러리 없이 브라우저 네이티브 API만으로도 단계별 시간을 분해할 수 있습니다. 콘솔에서 한 번 찍어보고 싶을 때 유용해요.
const nav = performance.getEntriesByType('navigation')[0];
const breakdown = {
redirect: nav.redirectEnd - nav.redirectStart,
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
tls: nav.connectEnd - nav.secureConnectionStart,
request: nav.responseStart - nav.requestStart, // 서버 처리 + 네트워크 RTT
ttfb: nav.responseStart - nav.startTime, // 전체 TTFB
};
console.table(breakdown);
requestDuration이 큰 값이라면 서버 처리 또는 네트워크 RTT가 병목이고, dns·tcp·tls가 크다면 연결 수립 단계를 손봐야 합니다. 단순하지만 강력한 진단법이죠.
방법 3: Server-Timing 헤더로 백엔드 분해
서버 처리 시간이 의심된다면 백엔드에서 단계별로 측정해 Server-Timing 헤더로 노출하세요. Chrome DevTools Network 탭과 navigationEntry.serverTiming에서 바로 확인할 수 있습니다.
// Node.js / Express 예시
app.get('/article/:slug', async (req, res) => {
const t0 = performance.now();
const article = await db.articles.findOne({ slug: req.params.slug });
const t1 = performance.now();
const html = await renderTemplate(article);
const t2 = performance.now();
res.set('Server-Timing', [
`db;dur=${(t1 - t0).toFixed(1)}`,
`render;dur=${(t2 - t1).toFixed(1)}`,
`total;dur=${(t2 - t0).toFixed(1)}`,
].join(', '));
res.send(html);
});
Cloudflare, Fastly, Vercel 등 주요 CDN도 cf-cache-status, x-vercel-cache 같은 응답 헤더로 캐시 적중 여부를 알려주니 함께 모니터링하세요.
전략 1: CDN 엣지 캐싱 — 가장 큰 효과
TTFB를 가장 빠르게 줄이는 단일 결정은 HTML 자체를 CDN 엣지에 캐시하는 것입니다. 한국에서 미국 origin까지 RTT 150ms를 왕복하는 대신, 서울 PoP에서 5~10ms로 응답할 수 있죠. 이거 한 줄로 정리하자면 — 가장 가성비 좋은 최적화입니다.
HTML 캐싱 — Cache-Control과 stale-while-revalidate
Cache-Control: public, s-maxage=60, stale-while-revalidate=86400
CDN-Cache-Control: public, s-maxage=300
s-maxage=60— CDN 엣지에서 60초간 fresh로 간주stale-while-revalidate=86400— 만료 후에도 24시간 동안은 stale을 반환하면서 백그라운드에서 재검증CDN-Cache-Control— CDN 전용 지시어(브라우저는 무시)로 엣지 TTL을 길게 유지
이 조합이면 캐시 히트 시 TTFB가 origin과 무관하게 거의 일정해지고, 만료 직후 사용자도 stale 응답을 즉시 받게 됩니다. 사용자 입장에서는 "느린 순간"이 사실상 사라지는 거예요.
한국 사용자에게는 PoP 위치가 중요하다
모든 CDN이 한국 PoP를 운영하지는 않습니다(은근히 자주 잊히는 사실입니다). 2026년 기준 한국 내 PoP를 운영하는 주요 CDN은 다음과 같습니다.
- Cloudflare — 서울, 부산
- Fastly — 서울
- Akamai — 다수 PoP
- AWS CloudFront — 서울 4개 엣지
- 네이버 클라우드 CDN, KT GiGA CDN — 국내 트래픽 비중이 높을 때 효율적
국내 트래픽이 80% 이상이라면, 의외로 글로벌 CDN보다 국내 CDN이 평균 TTFB가 더 낮은 경우가 흔합니다. 반면 글로벌 사용자가 섞여 있다면 Cloudflare/Fastly 같은 글로벌 anycast 네트워크가 유리합니다. 정답은 트래픽 분포에 달려 있다고 보면 됩니다.
전략 2: Early Hints (HTTP 103)
서버가 HTML을 생성하는 동안에도 브라우저는 놀고 있을 필요가 없습니다. HTTP 103 Early Hints는 200 응답이 준비되기 전에 "이 리소스를 미리 받아두라"는 힌트를 먼저 던져주는 메커니즘입니다.
2026년 기준 Chrome 94+, Edge, Firefox, Safari가 모두 지원하며 — HTTP/2 또는 HTTP/3 위에서만 동작한다는 점은 기억해두세요.
Express + Cloudflare 환경 예시
// origin에서 Link 헤더와 함께 103을 먼저 flush
app.get('/article/:slug', (req, res) => {
res.writeEarlyHints({
link: [
'</static/critical.css>; rel=preload; as=style',
'</static/hero.avif>; rel=preload; as=image; fetchpriority=high',
'<https://cdn.example.com>; rel=preconnect',
],
});
// 무거운 처리(DB, 외부 API)는 그 다음에
renderArticle(req.params.slug).then((html) => res.send(html));
});
Cloudflare는 origin이 보낸 Link 헤더를 캐시했다가, 다음 요청부터는 origin이 응답하기 전에 엣지에서 직접 103을 송출합니다. 즉 캐시 미스 상황에서도 브라우저는 critical CSS와 LCP 이미지 다운로드를 시작할 수 있다는 뜻입니다(이게 의외로 큰 이득이에요).
주의할 점
- preload 힌트는 1~3개로 제한하세요. Shopify의 실측 데이터에 따르면 4개 이상부터는 오히려 TTFB가 늘어납니다.
- 모바일에서는 40퍼센타일 이상 구간에서 Early Hints가 오히려 손해인 경우가 있으므로, 반드시 RUM으로 검증하세요.
- 크로스 오리진 리다이렉트가 발생하면 Early Hints는 폐기됩니다.
전략 3: 엣지 컴퓨팅으로 origin RTT 제거
HTML이 동적이라 캐시할 수 없다면? 코드 자체를 엣지로 옮기는 방법이 있습니다. 사용자에서 가장 가까운 PoP에서 SSR을 수행하면 origin 왕복 자체가 사라지죠. 좀 과격해 보이지만, 효과는 확실합니다.
Cloudflare Workers — V8 isolates, 5ms 미만 콜드스타트
// worker.ts
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const cache = caches.default;
const cached = await cache.match(req);
if (cached) return cached;
const article = await env.DB.prepare(
'SELECT * FROM articles WHERE slug = ?'
).bind(new URL(req.url).pathname.split('/').pop()).first();
const html = renderHtml(article);
const res = new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, s-maxage=60, stale-while-revalidate=86400',
},
});
// 백그라운드 캐싱
req.signal.addEventListener('abort', () => {});
return res;
},
};
Workers는 컨테이너가 아니라 V8 isolate에서 실행되므로 콜드스타트가 사실상 0에 가깝습니다. 330개+ 도시의 PoP에서 동일하게 동작하고요.
Vercel Edge / Fluid Compute
Next.js를 사용한다면 Vercel Fluid Compute가 자연스러운 선택입니다. 실행 컨텍스트를 재사용해 콜드스타트를 거의 제거하고, React SSR에서는 Workers 대비 1.2~5배 빠른 처리량을 보입니다. 단, 글로벌 P50 지연은 Cloudflare(10~30ms) 대비 50~150ms로 더 높을 수 있으니 트래픽 분포에 맞게 골라 쓰세요.
전략 4: 연결 수립 시간 줄이기
HTTP/3 (QUIC) 활성화
HTTP/3는 UDP 기반의 QUIC 프로토콜 위에서 동작하며, TLS 1.3 핸드셰이크를 첫 RTT에 통합합니다. TCP+TLS의 2~3 RTT가 1 RTT로 줄어들고, 연결 마이그레이션도 지원하죠.
// Nginx 1.25+ 예시
server {
listen 443 quic reuseport;
listen 443 ssl;
http2 on;
ssl_protocols TLSv1.3;
add_header Alt-Svc 'h3=":443"; ma=86400';
...
}
Alt-Svc 헤더로 브라우저에 "다음 요청부터는 HTTP/3로 와도 된다"고 알려주는 방식입니다. 모바일 환경에서 RTT가 큰 한국 사용자에게 특히 효과가 큽니다.
0-RTT와 세션 재개
TLS 1.3의 0-RTT(early data)를 사용하면 재방문 시 핸드셰이크 비용 없이 바로 요청을 보낼 수 있습니다. 단, replay 공격에 취약하므로 GET처럼 idempotent한 요청에만 허용하세요. 결제 같은 거에 켜두면 곤란해집니다.
DNS 최적화
- 권위(Authoritative) DNS를 Cloudflare DNS, NS1, Route 53 같은 anycast 네트워크로 이전
TTL을 너무 짧게(60초 이하) 설정하지 않기 — 캐시 효율 저하- 외부 도메인이 있다면
<link rel="dns-prefetch" href="//cdn.example.com">
전략 5: 백엔드 처리 시간 줄이기 (서버 사고 시간)
CDN 캐시 미스 상황의 TTFB는 결국 서버 처리 속도에 달려 있습니다. 그리고 거의 항상 — 데이터베이스가 범인이에요.
데이터베이스가 거의 항상 범인이다
EXPLAIN ANALYZE로 실행 계획 확인 — 누락된 인덱스, sequential scan, 잘못된 조인 순서- N+1 쿼리 제거 — ORM의 eager loading, DataLoader 패턴
- 읽기 부하는 read replica로 분산
- 자주 쓰이는 결과는 Redis/Memcached에 캐싱(
SET key value EX 60)
스트리밍 SSR로 TTFB를 즉시 반환
전체 HTML을 다 만들고 보내는 대신, <head>와 셸 부분을 먼저 flush하면 TTFB가 극적으로 줄어듭니다. React 19의 renderToPipeableStream, Next.js App Router, Astro의 view-transitions 모두 기본으로 지원합니다.
import { renderToPipeableStream } from 'react-dom/server';
app.get('/article/:slug', (req, res) => {
const { pipe } = renderToPipeableStream(
<App slug={req.params.slug} />,
{
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res); // 셸이 준비되는 즉시 첫 바이트 송출
},
}
);
});
Brotli/Zstd 압축
HTML은 텍스트라 압축률이 굉장히 높습니다. gzip(level 6) 대비 Brotli(level 4)는 같은 CPU 시간에 더 작은 결과를 만들고, Cloudflare가 2024년부터 지원하기 시작한 Zstandard(zstd)는 또 한 단계 위입니다. 정적 파일은 미리 압축(.br, .zst)해두고, 동적 HTML은 level 4~5로 실시간 압축하는 게 일반적입니다.
전략 6: 리다이렉트 체인 제거
리다이렉트 한 번이 평균 200~400ms를 추가합니다. 이게 누적되면 진짜 무섭죠. 흔한 누적 패턴은 다음과 같습니다.
http://example.com/article
→ 301 https://example.com/article
→ 301 https://www.example.com/article
→ 301 https://www.example.com/article/ (트레일링 슬래시)
→ 200 OK
이 체인은 4번의 RTT를 만듭니다. 한국 모바일이라면 1초 가까이 그냥 사라지는 거예요. 해결책은 다음과 같습니다.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload— 브라우저가 첫 요청부터 HTTPS로 직행- HSTS preload 리스트(hstspreload.org) 등록
- www/non-www, 트레일링 슬래시는 처음부터 단일 정규형으로 통일
- internal 링크는 모두 최종 URL로 작성
전략 7: 서비스 워커로 즉시 TTFB 만들기
서비스 워커가 캐시에서 직접 응답하면 TTFB는 네트워크와 무관하게 수 ms로 떨어집니다. stale-while-revalidate를 워커 레벨에서 구현하는 패턴이 가장 보편적입니다.
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith((async () => {
const cache = await caches.open('html-v1');
const cached = await cache.match(event.request);
const networkPromise = fetch(event.request).then((res) => {
cache.put(event.request, res.clone());
return res;
});
// 캐시가 있으면 즉시 반환, 백그라운드에서 갱신
return cached || networkPromise;
})());
});
주의할 점 하나 — 서비스 워커 startup time 자체가 TTFB의 cacheDuration에 잡힙니다. 워커 코드를 가볍게 유지하고, navigationPreload를 활성화해 워커가 부팅되는 동안 네트워크 요청을 병렬로 시작하도록 해두세요.
실전 진단 체크리스트
- web-vitals.js Attribution으로 P75 TTFB와 하위 항목 분포를 30일간 수집
- 가장 큰 항목이
requestDuration이라면 → 서버/엣지 캐시 + 백엔드 최적화 - 가장 큰 항목이
cacheDuration이라면 → 서비스 워커 코드 슬림화 + navigationPreload - 가장 큰 항목이
connectionDuration이라면 → HTTP/3, 0-RTT, anycast DNS cf-cache-status: MISS비율이 높다면 → Cache-Control과 캐시 키 정규화 점검- 리다이렉트 체인이 있다면 → HSTS preload + 정규화
- 모든 변경 후 P75/P95 모두 측정 — 평균만 보지 말 것(이 부분 진짜 중요합니다)
자주 묻는 질문 (FAQ)
TTFB가 정확히 몇 ms 이하면 좋은가요?
Google 공식 권장은 75퍼센타일 기준 800ms 이하입니다. 1,800ms를 넘으면 "나쁨"으로 분류되고 LCP에 직접 누적됩니다. 다만 TTFB는 Core Web Vitals 메트릭이 아니므로, LCP/INP/CLS가 모두 좋음 구간이라면 TTFB가 약간 800ms를 초과해도 검색 순위에는 영향이 없습니다.
TTFB와 FCP, LCP는 어떻게 다른가요?
TTFB는 첫 바이트 수신, FCP는 첫 픽셀(텍스트나 이미지) 렌더링, LCP는 가장 큰 콘텐츠 요소가 그려지는 시점입니다. 셋은 누적 관계(TTFB ≤ FCP ≤ LCP)예요. 그래서 LCP를 줄이고 싶다면 가장 먼저 TTFB부터 잡아야 한다는 결론이 자연스럽게 나옵니다.
CDN을 사용 중인데도 TTFB가 1초가 넘습니다. 왜인가요?
대부분 캐시 미스가 원인입니다. 응답 헤더의 cf-cache-status 또는 x-cache를 확인해 MISS 비율을 측정해보세요. 쿼리스트링이 캐시 키를 깨뜨리고 있거나, Cache-Control: private·Set-Cookie 헤더가 캐시를 막고 있는 경우가 흔합니다. 또한 HTML 자체는 캐시하지 않고 정적 자산만 캐시하는 설정이라면, HTML TTFB는 origin 성능에 그대로 좌우됩니다.
WordPress·Shopify 같은 플랫폼에서도 TTFB를 줄일 수 있나요?
네, 충분히요. WordPress는 페이지 캐시 플러그인(WP Rocket, LiteSpeed Cache)과 객체 캐시(Redis), Cloudflare APO 조합으로 TTFB를 200ms 이하까지 낮출 수 있습니다. Shopify는 Online Store 2.0의 Hydrogen + Oxygen 스택과 Early Hints를 기본 활용하면 됩니다. 어느 플랫폼이든 핵심 원칙은 같습니다 — HTML을 엣지에 캐시하고, 미스 시에는 백엔드 처리 시간을 줄일 것.
Early Hints가 TTFB를 늘리는 경우도 있다는데 사실인가요?
사실입니다. Shopify의 RUM 데이터에 따르면 preload 힌트가 4개 이상이면 모바일 환경에서 오히려 TTFB가 증가합니다. 헤더가 길어져서 첫 패킷 안에 응답이 다 들어가지 못하기 때문이에요. critical CSS 1개와 LCP 이미지 1개 정도로 제한하고, A/B 테스트로 실측 효과를 검증하는 걸 권장합니다.
한국 origin인데 글로벌 CDN보다 국내 CDN이 더 빠른가요?
한국 내 트래픽이 80% 이상이고 origin도 국내라면, 네이버 클라우드 CDN이나 KT GiGA CDN이 평균 RTT가 더 낮은 경우가 많습니다. 반면 글로벌 사용자가 섞여 있거나 해외 origin을 쓴다면 Cloudflare나 Fastly의 anycast 네트워크가 전체 P75 TTFB를 더 잘 끌어내립니다. 가장 정확한 답? 두 곳에 카나리 트래픽을 보내고 RUM으로 직접 비교하는 겁니다.
마무리
TTFB는 모든 웹 성능 지표의 출발선입니다. 마법 같은 단일 해결책은 없고, 측정 → 분해 → 가장 큰 항목부터 공략의 순서가 정석이에요. 다음 우선순위로 접근하면 대부분의 사이트에서 P75 TTFB를 800ms 이하로 끌어내릴 수 있습니다.
- HTML을 CDN 엣지에 캐시 + stale-while-revalidate
- 리다이렉트 체인과 비효율적 DB 쿼리 제거
- HTTP/3 + TLS 1.3 활성화
- 스트리밍 SSR과 Early Hints로 첫 바이트를 더 빨리 흘려보내기
- 필요하면 엣지 컴퓨팅으로 origin 자체를 사용자 가까이로
측정 없이는 최적화도 없습니다. 오늘부터 web-vitals.js Attribution을 붙여 P75 데이터부터 모아보세요. 일주일만 데이터가 쌓여도 어디부터 손대야 할지 답이 명확해질 겁니다.