Vercel Server-Timing 헤더: 8월 10일 변경과 측정·보안 점검법

HAGO··schedule15
공유

Vercel 배포에서 백엔드 구간을 브라우저까지 전달하려면 Server-Timing 응답 헤더를 쓸 수 있다. 지금까지 Vercel CDN은 이 헤더를 제거했지만, Vercel은 2026년 8월 10일부터 클라이언트로 통과시키겠다고 공지했다. 애플리케이션이 기록한 데이터베이스 조회 시간이나 캐시 판정 값을 브라우저 개발자 도구와 Performance API에서 읽을 수 있게 되는 변화다.

다만 헤더가 통과한다고 해서 느린 요청의 원인이 자동으로 밝혀지는 것은 아니다. 어느 프로세스가 어떤 구간을 쟀는지 직접 정해야 하고, CDN 대기 시간과 브라우저 네트워크 시간은 별도로 봐야 한다. 내부 테이블명이나 인프라 구조를 설명에 넣으면 그 정보도 사용자에게 공개된다. 이번 변경을 적용하기 전에 측정 경계, 공개 범위, 회귀 테스트를 함께 정리해야 하는 이유다.

8월 10일에 바뀌는 경계

Vercel의 2026년 7월 30일 공지가 보장하는 내용은 좁고 분명하다. 8월 10일부터 Vercel CDN이 Server-Timing 응답 헤더를 제거하지 않고 클라이언트로 전달한다. 별도 SDK 설치나 Next.js 업그레이드가 필요한 기능으로 소개되지는 않았다. 플랫폼의 CDN 처리 방식이 바뀌는 것이다.

요청과 응답의 경계를 나누면 다음과 같다.

브라우저
  -> Vercel CDN
  -> Next.js Route Handler 또는 백엔드
     (DB, 캐시, 외부 API 구간을 애플리케이션 시계로 측정)
  <- Server-Timing 응답 헤더 생성
  <- Vercel CDN이 헤더를 통과
브라우저 개발자 도구 / PerformanceResourceTiming.serverTiming

헤더의 값은 원본 서버나 함수가 만든다. CDN은 8월 10일 이후 그 값을 전달할 뿐, 데이터베이스 시간을 대신 측정하지 않는다. 브라우저는 수신한 값을 네트워크 패널에 표시하고 PerformanceResourceTiming.serverTiming 배열로 노출할 수 있다. MDN의 Server timing 설명에 따르면 독립적인 server-timing 성능 엔트리가 생기는 방식은 아니다. 탐색은 navigation, 이미지·fetch 같은 요청은 resource 엔트리에서 값을 읽는다.

이 구분을 놓치면 숫자를 잘못 해석하기 쉽다. 예를 들어 db;dur=53은 서버 코드가 선택한 데이터베이스 구간의 지속 시간이다. 브라우저의 전체 요청 시간이 120ms라고 해서 나머지 67ms가 전부 CDN 시간이라는 뜻은 아니다. 함수 스케줄링, 애플리케이션 코드, 직렬화, 원본과 CDN 사이 전송, 브라우저 연결이 섞여 있다. 서버와 브라우저, 중간 프록시의 시계도 동기화돼 있지 않으므로 서버 타임스탬프를 브라우저 startTime에 그대로 맞추면 안 된다.

확인된 변화와 아직 모르는 것

확인된 것은 헤더 통과 시작일과 현재 동작을 유지하는 삭제 설정이다. 모든 리전의 실제 전환 시각이나 사용자 체감 속도 개선은 공지만으로 알 수 없다. 헤더는 관측 데이터를 추가할 뿐 요청을 빠르게 만들지 않는다. JavaScript 접근은 기본적으로 same-origin이며, cross-origin 리소스에는 Timing-Allow-Origin 설정이 필요하다. 일부 브라우저에서는 HTTPS도 요구한다.

Next.js Route Handler에서 구간을 만드는 법

다음 예시는 Next.js App Router의 Route Handler가 데이터 조회와 직렬화 시간을 따로 측정하는 형태다. 기준 문서는 Next.js Route Handlers이며, 현재 공식 문서의 최신 선택 버전은 16.2.12다. Response 객체에 헤더를 넣는 방식이라 특정 Vercel 관측 SDK에 의존하지 않는다.

// app/api/catalog/route.ts
import { performance } from "node:perf_hooks";
import { db } from "@/lib/db";

export async function GET() {
  const requestStart = performance.now();

  const dbStart = performance.now();
  const products = await db.product.findMany({
    take: 20,
    select: { id: true, name: true, price: true },
  });
  const dbDuration = performance.now() - dbStart;

  const serializeStart = performance.now();
  const body = JSON.stringify({ products });
  const serializeDuration = performance.now() - serializeStart;
  const appDuration = performance.now() - requestStart;

  const serverTiming = [
    `db;dur=${dbDuration.toFixed(1)}`,
    `serialize;dur=${serializeDuration.toFixed(1)}`,
    `app;dur=${appDuration.toFixed(1)}`,
  ].join(", ");

  return new Response(body, {
    headers: {
      "content-type": "application/json; charset=utf-8",
      "server-timing": serverTiming,
      "cache-control": "private, no-store",
    },
  });
}

이 코드는 설명을 의도적으로 생략했다. desc="primary-users-cluster"처럼 내부 역할이나 저장소 이름을 적으면 응답을 받는 사람에게 그대로 보일 수 있다. db, serialize, app처럼 짧고 안정적인 이름이면 헤더 크기도 줄고 대시보드 차원 수가 불필요하게 늘어나는 일도 피할 수 있다. MDN의 Server-Timing 헤더 문서는 이름만 보내거나 dur, desc를 선택적으로 붙일 수 있다고 설명하며, 전송 오버헤드를 줄이기 위해 이름과 설명을 짧게 유지하라고 권한다.

무엇을 타이머 안에 넣을지 먼저 정한다

dbfindMany 직전부터 Promise 완료까지다. 연결 풀 대기나 드라이버 처리가 포함될 수 있으므로 곧바로 "SQL 실행 시간"이라고 부르지 않는다. DB 쿼리 로그와 대조해야 경계를 알 수 있다.

app은 CDN이나 브라우저 시간을 포함하지 않는다. 스트리밍 응답에서는 Response 반환과 스트림 완료도 다르다. 전체 완료 시간은 스트림 close 지점이나 서버 측 추적으로 따로 잰다.

예시의 private, no-store는 캐시가 측정을 흐리지 않게 하려는 실험 조건이다. 공개 캐시를 쓰는 제품에서는 hit와 miss를 나눠 검사한다.

브라우저에서 값을 읽고 검증하는 방법

개발자 도구에서 응답 헤더가 보이는지만 확인하면 절반만 검증한 셈이다. JavaScript 수집 경로가 필요하다면 navigationresource 엔트리를 실제로 읽어야 한다. 아래 클라이언트 코드는 호출이 끝난 뒤 해당 URL의 최신 resource 엔트리를 찾는다.

export async function fetchCatalogTiming() {
  const url = "/api/catalog?timing-probe=1";
  const response = await fetch(url, { cache: "no-store" });
  if (!response.ok) throw new Error(`catalog failed: ${response.status}`);
  await response.json();

  const entries = performance.getEntriesByName(url, "resource");
  const entry = entries.at(-1) as PerformanceResourceTiming | undefined;

  return Object.fromEntries(
    (entry?.serverTiming ?? []).map((metric) => [
      metric.name,
      metric.duration,
    ]),
  );
}

fetch()의 Promise만 기다리고 타이머를 멈추는 테스트는 응답 본문 소비와 후속 처리를 놓칠 수 있다. 예시는 response.json()까지 완료한 뒤 Performance API를 읽는다. 그래도 이 값은 Route Handler가 보고한 구간이지 브라우저의 사용자 체감 지연 전체가 아니다. 사용자 관점에서는 PerformanceResourceTiming.duration, 화면 갱신 시각, Core Web Vitals나 실제 상호작용 지표를 함께 봐야 한다.

cross-origin 응답에서는 Access-Control-Allow-OriginTiming-Allow-Origin을 구분한다. 전자는 fetch의 CORS 권한, 후자는 Resource Timing 상세값의 공개 범위를 정한다. * 대신 실제 관측 출처를 지정하고 인증 응답의 노출 범위를 점검한다.

배포 회귀 테스트는 헤더와 API를 따로 본다

아래 Playwright 테스트는 두 계약을 분리한다. 첫 assertion은 네트워크 응답에 헤더가 도착했는지 확인한다. 두 번째는 브라우저 Performance API가 dbapp 항목을 읽는지 확인한다. 이 코드는 이 글을 작성하면서 실제 Vercel 프로젝트에 배포해 실행하지 않았으므로, 프로젝트의 테스트 URL과 인증 조건에 맞춰 실행해야 한다.

// tests/server-timing.spec.ts
import { test, expect } from "@playwright/test";

test("Vercel CDN passes Server-Timing to the browser", async ({ page }) => {
  await page.goto("/");

  const responsePromise = page.waitForResponse((response) =>
    response.url().includes("/api/catalog?timing-probe=1"),
  );

  const metrics = await page.evaluate(async () => {
    const response = await fetch("/api/catalog?timing-probe=1", {
      cache: "no-store",
    });
    await response.json();

    const entry = performance
      .getEntriesByName("/api/catalog?timing-probe=1", "resource")
      .at(-1) as PerformanceResourceTiming | undefined;

    return (entry?.serverTiming ?? []).map(({ name, duration }) => ({
      name,
      duration,
    }));
  });

  const response = await responsePromise;
  expect(response.headers()["server-timing"]).toContain("db;dur=");
  expect(metrics.some((metric) => metric.name === "db")).toBe(true);
  expect(metrics.some((metric) => metric.name === "app")).toBe(true);
  expect(metrics.every((metric) => metric.duration >= 0)).toBe(true);
});

로컬 통과는 애플리케이션의 헤더 생성을, Preview와 Production 통과는 각 배포 경로의 전달을 확인한다. 8월 10일 이전과 이후 결과는 분리해 기록한다.

성능 수치는 이렇게 비교한다

한 번의 요청으로 병목을 정하지 않는다. 첫 요청은 함수 초기화, 연결 풀 생성, DNS, TLS, 코드 캐시의 영향을 받을 수 있다. 같은 URL을 여러 번 호출하되 첫 실행과 warm 실행을 분리하고, 캐시 hit와 miss도 별도 집합으로 남긴다. p50만 보면 드문 지연을 놓치고 p99만 보면 표본 수가 부족할 수 있으니 요청 수와 분위수를 함께 기록한다.

브라우저에서는 요청 시작, 첫 바이트, 응답 완료, JSON 처리 완료, 화면 반영을 나눈다. 서버에서는 함수 진입, DB 시작·완료, 직렬화 완료, 응답 스트림 완료를 나눈다. Vercel CDN 내부 구간은 애플리케이션 Server-Timing만으로 직접 측정되지 않는다. 브라우저 전체 시간에서 db를 빼서 CDN 시간이라고 부르는 계산도 피해야 한다.

메트릭 이름과 설명이 길면 전송량과 분석 비용도 늘어난다. 중요한 경로 몇 개부터 짧은 항목으로 시작하고 샘플링 비율과 보존 기간을 정한다. 사용자 지연 개선은 헤더 도입 전후가 아니라 실제 최적화 전후의 동일 조건 데이터로 판단해야 한다.

노출이 부담되면 기존 동작을 유지할 수 있다

Vercel은 헤더를 계속 제거하고 싶은 프로젝트를 위해 vercel.json의 response header transform 예시를 제공했다. 다음 설정은 모든 응답에서 소문자 키 server-timing을 삭제한다.

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "routes": [
    {
      "src": "/(.*)",
      "transforms": [
        {
          "type": "response.headers",
          "op": "delete",
          "target": { "key": "server-timing" }
        }
      ]
    }
  ]
}

이 설정이 Vercel 공지에 나온 현재 동작 유지 방법이라는 점은 확인됐다. 하지만 기존 프로젝트의 routes, rewrites, headers 설정과 합쳤을 때 우선순위가 어떻게 되는지는 현재 구성으로 검증해야 한다. 설정 문법은 Vercel transform 객체 문서와 대조하고 Preview 배포에서 응답 헤더가 실제로 사라지는지 확인한다.

8월 10일 배포 전에는 세 가지를 끝내면 된다. 어떤 서버 구간을 측정했는지 이름과 타이머 경계를 문서화한다. Preview와 Production에서 헤더 전달, Performance API 접근, cross-origin 권한을 각각 검사한다. 마지막으로 헤더에 인프라 이름, 쿼리 원문, 사용자 식별자, 비밀값이 들어가지 않는지 응답을 직접 살핀다. 이 조건을 통과한 뒤에야 Server-Timing은 단순한 숫자 묶음이 아니라 프런트엔드와 백엔드가 함께 쓸 수 있는 진단 신호가 된다.

공유

관련 글


댓글 (0)

첫 댓글을 남겨주세요.