- Published on
Next.js 14 App Router TTFB 줄이는 이미지 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 렌더링 기반의 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의 이미지 처리 경로는 크게 두 가지입니다.
- 정적 파일로 제공:
/public또는 빌드 산출물에 포함된 이미지를 그대로 서빙(또는 CDN 캐시) - 이미지 최적화 런타임:
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번이 그대로 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) priority와 loading은 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를 줄이는” 이미지 최적화 우선순위
- HTML 경로에서 이미지 연산 제거: blur/메타/OG 생성이 페이지 렌더에 섞였는지 확인
- 원격 이미지면 원본 CDN 캐시: 원본이 느리면 Next가 대신 맞습니다
/_next/image결과 캐시 강화: Nginx/CloudFront/Vercel 캐시 정책 점검sizes정확화: 불필요한 큰 변형 요청을 줄여 캐시 효율을 올림- 사전 생성 파이프라인 도입: 트래픽이 커질수록 가장 확실한 해법
11) 운영 팁: 배포/빌드 파이프라인과 캐시 무효화 설계
이미지 파이프라인을 도입하면 “캐시 무효화”가 배포 안정성과 직결됩니다.
- 파일명에 해시를 붙여 영구 캐시(immutable)
- DB에 저장된 URL도 해시 기반으로 갱신
- CI/CD에서 잘못된 캐시 무효화로 무한 빌드/재배포 루프가 나지 않게 방지
관련해서 배포 파이프라인이 꼬일 때는 아래 글처럼 원인 분리가 도움이 됩니다.
마무리
Next.js 14 App Router에서 TTFB를 줄이려면, 단순히 next/image를 “사용”하는 것이 아니라 어디에서 이미지 연산이 발생하는지(HTML 경로 vs 이미지 요청 경로) 를 분리하고, 동적 최적화 비용을 캐시/빌드/업로드 시점 또는 CDN으로 이동시키는 전략이 필요합니다.
정리하면, TTFB 관점의 정답은 다음 한 줄입니다.
- 이미지 최적화는 ‘요청 시점’이 아니라 ‘사전/캐시된 결과’로 제공될 때 가장 빠르다.