Published on

Next.js 14 App Router TTFB 줄이는 이미지 최적화

Authors

서버 렌더링 기반의 Next.js 14(App Router)에서 TTFB(Time To First Byte) 가 느려지는 대표 원인 중 하나가 “이미지”입니다. 많은 팀이 LCP나 CLS 같은 프론트 지표만 보고 next/image를 붙이면 끝이라고 생각하지만, 실제 운영에서는 이미지 최적화가 서버에서 동적으로 수행되면서 오히려 TTFB를 끌어올리는 케이스가 흔합니다.

이 글에서는 App Router 환경에서 TTFB를 악화시키는 이미지 경로를 식별하고, 동적 최적화 비용을 캐시/빌드 단계로 밀어내거나 CDN으로 옮겨 서버 응답 시간을 줄이는 실전 패턴을 정리합니다.

1) App Router에서 이미지가 TTFB를 올리는 구조 이해

Next.js의 이미지 처리 경로는 크게 두 가지입니다.

  1. 정적 파일로 제공: /public 또는 빌드 산출물에 포함된 이미지를 그대로 서빙(또는 CDN 캐시)
  2. 이미지 최적화 런타임: next/image가 요청 시점에 리사이즈/포맷 변환(WebP/AVIF) 등을 수행

문제는 2번입니다. 특히 다음 조건에서 TTFB가 튑니다.

  • 원본 이미지가 크고(수 MB) 리사이즈 비용이 큼
  • 원격 이미지(remotePatterns)이며 원본 다운로드까지 서버가 수행
  • 서버리스/엣지 환경에서 cold start + 이미지 처리 비용이 겹침
  • 캐시가 비어 있어 첫 요청이 항상 “미스”
  • 이미지 요청이 HTML 요청과 같은 리전에 묶여 병목이 발생

즉, HTML의 TTFB 자체가 느린 게 아니라도, 사용자가 체감하는 “첫 화면이 늦다”가 이미지에서 시작되는 경우가 많습니다. 하지만 실제로는 다음처럼 HTML TTFB도 같이 느려지는 케이스가 있습니다.

  • RSC(Server Components)에서 이미지 메타데이터(가로/세로, blurDataURL) 생성이 동적으로 실행
  • generateMetadata에서 OG 이미지 생성/가공을 동기적으로 수행
  • 요청당 sharp를 호출하는 커스텀 이미지 파이프라인이 같은 런타임에서 돌아감

이런 경우는 이미지 최적화가 HTML 응답 경로에 섞여 들어가면서 TTFB가 직접 증가합니다.

2) TTFB 관점에서 먼저 측정: 어디서 느린지 분리하기

이미지 최적화는 “느린 것”이 아니라 “느리게 만들 수 있는 것”입니다. 먼저 병목을 분리해야 합니다.

2.1 서버 로그로 HTML과 이미지 요청을 분리

Vercel이든 자체 호스팅이든, 최소한 다음을 분리해서 봅니다.

  • / 같은 문서 요청의 서버 처리 시간
  • /_next/image?... 요청의 처리 시간(최적화 엔드포인트)
  • 원격 이미지 원본 호스트 응답 시간

자체 호스팅(Node)라면 간단히 미들웨어/서버 로그로도 감을 잡을 수 있습니다.

// middleware.ts (간단한 요청 분류 로깅)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(req: NextRequest) {
  const url = req.nextUrl.pathname
  if (url.startsWith('/_next/image')) {
    console.log('[IMG_OPT]', req.nextUrl.toString())
  }
  return NextResponse.next()
}

export const config = {
  matcher: ['/:path*'],
}

2.2 RSC/메타데이터 경로에서 이미지 연산이 섞였는지 확인

App Router에서는 generateMetadata와 Server Component 내부에서 이미지 관련 연산을 넣기 쉽습니다.

  • DB에서 이미지 크기 조회
  • 원본 이미지 다운로드 후 blur placeholder 생성
  • OG 이미지 동적 생성

이게 HTML TTFB를 직접 끌어올립니다. 관련 이슈로 “서버/클라이언트 경계 문제”가 같이 터지기도 하니, 렌더링 경계가 의심될 때는 아래 글도 함께 참고하면 좋습니다.

3) next/image로 TTFB를 줄이려면: ‘동적 최적화’ 비용을 통제하라

next/image 자체는 성능에 도움이 되지만, 기본값 그대로 쓰면 서버에서 매 요청 최적화를 하게 되는 지점이 있습니다. 목표는 다음입니다.

  • 첫 요청 비용을 줄이거나(사전 생성)
  • 첫 요청은 비싸더라도 이후는 빠르게(강한 캐시)
  • 가능하면 서버가 아니라 CDN/이미지 전용 서비스로 오프로딩

4) 원격 이미지(remote) 최적화는 TTFB 지뢰밭: 우선순위는 캐시

원격 이미지를 next/image로 최적화하면, Next 서버는 보통 다음을 수행합니다.

  1. 원격 호스트에서 원본 다운로드
  2. 리사이즈/포맷 변환
  3. 결과를 캐시

원본 호스트가 느리면 1번이 그대로 TTFB/체감 지연으로 전가됩니다.

4.1 remotePatterns 최소화 + 안전한 도메인만

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/**',
      },
    ],
  },
}

remotePatterns를 넓게 열어두면(예: **) 예상치 못한 외부 이미지가 유입되어 캐시 효율이 떨어지고, 최적화 엔드포인트가 불필요하게 바빠집니다.

4.2 캐시 헤더(원본)와 Next 이미지 캐시(결과)를 동시에 설계

  • 원본 호스트(예: S3/CloudFront)에 Cache-Control: public, max-age=31536000, immutable
  • Next 이미지 최적화 결과도 CDN에 캐시되도록 구성

자체 호스팅에서 Nginx를 앞단에 둔다면 /_next/image를 적극 캐시하는 것이 효과적입니다.

# Nginx 예시: /_next/image 캐시
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=nextimg:100m max_size=10g inactive=30d use_temp_path=off;

server {
  location /_next/image {
    proxy_pass http://next_upstream;
    proxy_cache nextimg;
    proxy_cache_valid 200 301 302 30d;
    add_header X-Cache-Status $upstream_cache_status;
  }
}

핵심은 “이미지 최적화는 한 번만 비싸야 한다”입니다. 캐시가 없다면 Next 서버는 계속 sharp를 돌리고, 그 비용은 곧 TTFB(혹은 이미지 응답 TTFB)로 보입니다.

5) blurDataURL(플레이스홀더) 생성은 빌드/저장 시점으로 이동

placeholder="blur"는 UX에 좋지만, blurDataURL을 요청 시점에 만들면 서버 CPU를 태웁니다. 특히 App Router에서 아래처럼 서버 컴포넌트에서 동적으로 생성하면 HTML TTFB가 늘어납니다.

5.1 안티 패턴: 요청마다 blur 생성

// app/products/[id]/page.tsx (안티 패턴 예시)
import Image from 'next/image'

async function getBlur(url: string) {
  // 원격 다운로드 + 리사이즈 + base64 변환 같은 작업이 들어간다고 가정
  const res = await fetch(url)
  const buf = Buffer.from(await res.arrayBuffer())
  // ...sharp 처리
  return 'data:image/jpeg;base64,...'
}

export default async function Page({ params }: { params: { id: string } }) {
  const imageUrl = `https://images.example.com/p/${params.id}.jpg`
  const blurDataURL = await getBlur(imageUrl) // HTML TTFB에 직접 영향

  return (
    <Image
      src={imageUrl}
      alt="product"
      width={800}
      height={800}
      placeholder="blur"
      blurDataURL={blurDataURL}
    />
  )
}

5.2 권장: 업로드/배치 처리로 blurDataURL을 미리 저장

  • 이미지 업로드 시점에 blurDataURL 생성 후 DB에 저장
  • 또는 빌드/배치 잡에서 생성
  • 페이지 렌더에서는 저장된 blurDataURL만 읽기
// 예: 업로드 파이프라인에서 메타데이터 생성(개념 코드)
export async function processImageOnUpload(file: Buffer) {
  // sharp로 썸네일/blur/메타 생성
  const blurDataURL = 'data:image/jpeg;base64,...'
  const width = 800
  const height = 800

  // DB에 저장
  await db.images.insert({ width, height, blurDataURL })
}

이렇게 하면 페이지 요청 시점에는 DB 조회만 하므로 HTML TTFB를 안정적으로 낮출 수 있습니다.

6) App Router에서 ‘데이터 fetch’가 이미지 최적화보다 먼저 병목이 되는 경우

이미지 자체 최적화가 아니라, 이미지 URL을 만들기 위한 데이터 fetch가 느려서 TTFB가 늘어나는 경우도 많습니다.

  • 상품 상세에서 이미지 목록을 DB에서 가져옴
  • 권한 체크/서명 URL 발급(S3 presigned URL)
  • 외부 API 호출로 썸네일 URL 생성

이때는 fetch 캐싱 전략을 명시해 요청당 재계산을 막는 것이 중요합니다.

// app/lib/products.ts
import { cache } from 'react'

export const getProduct = cache(async (id: string) => {
  const res = await fetch(`${process.env.API_BASE}/products/${id}`, {
    // Next.js fetch 캐시 힌트
    next: { revalidate: 300 },
  })
  if (!res.ok) throw new Error('Failed')
  return res.json()
})
  • cache()로 동일 요청 내 중복 호출 제거
  • revalidate로 서버가 매 요청마다 외부 API를 때리지 않게 함

이런 기본기만으로도 이미지 때문에 느린 것처럼 보이던 TTFB가 크게 내려가는 경우가 있습니다.

7) priorityloading은 TTFB가 아니라 “경쟁”을 최적화한다

자주 헷갈리는 부분입니다.

  • priority는 해당 이미지를 preload하여 LCP를 개선하는 데 도움
  • 하지만 서버에서 이미지를 동적 최적화 중이라면, priority는 오히려 초기 트래픽을 더 당겨 서버 부하를 빠르게 올릴 수 있음

권장 패턴:

  • 폴드 상단 LCP 후보 1장만 priority
  • 나머지는 기본 lazy
  • sizes를 정확히 줘서 불필요한 큰 리사이즈 요청을 줄임
<Image
  src={heroUrl}
  alt="hero"
  fill
  priority
  sizes="(max-width: 768px) 100vw, 1200px"
  style={{ objectFit: 'cover' }}
/>

sizes가 부정확하면 Next가 더 큰 이미지를 선택하거나, 불필요한 변형이 늘어 이미지 최적화 엔드포인트 부하 → 캐시 미스 증가 → 응답 지연으로 이어질 수 있습니다.

8) 가장 강력한 해법: 이미지 최적화를 Next 런타임 밖으로 빼기

트래픽이 늘면 결국 /_next/image는 병목이 됩니다. 특히 자체 호스팅에서 CPU가 빡빡하면 더 빨리 한계가 옵니다. 이때는 “Next가 이미지를 최적화한다”는 전제를 버리고, 다음 중 하나로 옮기는 게 TTFB에 가장 확실합니다.

  • CloudFront + Lambda@Edge/Cloudflare Images/Imgix 같은 이미지 CDN
  • 업로드 시점에 다양한 사이즈/포맷을 미리 생성해 S3에 저장

8.1 업로드 시점에 파생 이미지 생성(개념)

  • 원본: p/123.jpg
  • 파생: p/123@400.webp, p/123@800.webp, p/123@1200.avif

그 후 프론트에서는 <Image unoptimized /> 또는 일반 <img>로도 충분히 빠르게 만들 수 있습니다(이미 CDN에서 최적화된 파일을 내려주므로).

// CDN/사전 생성 파이프라인이 있다면 next/image 최적화를 끄는 선택지
import Image from 'next/image'

export function ProductImage({ src }: { src: string }) {
  return (
    <Image
      src={src}
      alt="product"
      width={800}
      height={800}
      unoptimized
    />
  )
}

주의: unoptimized는 “최적화를 포기”가 아니라 “최적화 주체를 Next에서 CDN/파이프라인으로 이동”할 때만 정답입니다.

9) OG 이미지/동적 썸네일 생성이 TTFB를 망치는 전형적인 패턴

App Router에서 opengraph-image.tsx로 OG 이미지를 동적으로 만들 수 있는데, 이를 페이지 요청과 같은 자원 풀에서 처리하면 CPU가 묶이고 전체 TTFB가 흔들립니다.

권장:

  • OG 이미지는 사전 생성하거나
  • 별도 워커/큐로 생성하고 결과만 서빙
  • 최소한 캐시 헤더를 강하게
// app/opengraph-image.tsx (캐시를 강하게 걸어 재생성 빈도 줄이기)
export const revalidate = 86400 // 하루

export default async function OGImage() {
  // 동적 생성 로직
}

10) 체크리스트: “TTFB를 줄이는” 이미지 최적화 우선순위

  1. HTML 경로에서 이미지 연산 제거: blur/메타/OG 생성이 페이지 렌더에 섞였는지 확인
  2. 원격 이미지면 원본 CDN 캐시: 원본이 느리면 Next가 대신 맞습니다
  3. /_next/image 결과 캐시 강화: Nginx/CloudFront/Vercel 캐시 정책 점검
  4. sizes 정확화: 불필요한 큰 변형 요청을 줄여 캐시 효율을 올림
  5. 사전 생성 파이프라인 도입: 트래픽이 커질수록 가장 확실한 해법

11) 운영 팁: 배포/빌드 파이프라인과 캐시 무효화 설계

이미지 파이프라인을 도입하면 “캐시 무효화”가 배포 안정성과 직결됩니다.

  • 파일명에 해시를 붙여 영구 캐시(immutable)
  • DB에 저장된 URL도 해시 기반으로 갱신
  • CI/CD에서 잘못된 캐시 무효화로 무한 빌드/재배포 루프가 나지 않게 방지

관련해서 배포 파이프라인이 꼬일 때는 아래 글처럼 원인 분리가 도움이 됩니다.

마무리

Next.js 14 App Router에서 TTFB를 줄이려면, 단순히 next/image를 “사용”하는 것이 아니라 어디에서 이미지 연산이 발생하는지(HTML 경로 vs 이미지 요청 경로) 를 분리하고, 동적 최적화 비용을 캐시/빌드/업로드 시점 또는 CDN으로 이동시키는 전략이 필요합니다.

정리하면, TTFB 관점의 정답은 다음 한 줄입니다.

  • 이미지 최적화는 ‘요청 시점’이 아니라 ‘사전/캐시된 결과’로 제공될 때 가장 빠르다.