Next.js API 성능 최적화 완전 가이드: 캐시 전략, 스트리밍 응답, 엣지 컴퓨팅 실전

금요일 밤 9시, 제품 매니저가 단체 채팅방에 스크린샷 하나를 올렸습니다. 사용자 테스트 영상에서 테스터가 휴대폰으로 블로그 목록 페이지를 열었는데, Loading 아이콘이 꼬박 5초 동안 돌았고 페이지는 여전히 하얗게 비어 있었습니다. 오른쪽 아래에는 이런 말이 적혀 있었습니다. “요즘 시대에 이런 사이트가 있나요?”
Chrome DevTools를 열어 보니 API 요청에 3200ms가 걸리고 있었습니다. 그 순간 조금 당황했습니다. 이 API가 느리다는 건 알고 있었지만, 최적화할 시간이 없어서 미뤄 두고 있었고, 이렇게까지 느릴 줄은 몰랐습니다.
이후 이틀 동안 Next.js 성능 최적화를 파고들어 보니 생각보다 복잡하지 않았습니다. 캐시 전략을 제대로 고르고, 스트리밍 응답과 엣지 컴퓨팅을 더하니 응답 시간이 3초에서 500ms 이내로 바로 줄었습니다. 더 중요했던 건 어떤 상황에 어떤 방법을 써야 하는지를 이해했다는 점입니다. 어떤 기술이 있는지 아는 것보다 이 판단이 훨씬 중요합니다.
오늘은 이 세 가지를 이야기해 보겠습니다. 캐시 전략은 어떻게 고를지, 스트리밍 응답은 어떻게 구현할지, Edge Functions는 어떤 상황에 적합한지입니다. 코드는 실제로 실행해 본 예제이고, 성능 데이터도 직접 측정한 값이라 바로 가져다 써도 됩니다.
왜 Next.js API가 이렇게 느릴까요?
먼저 흔한 성능 병목부터 보겠습니다. 당시 3초가 걸리던 API를 추적해 보니 몇 가지 전형적인 문제가 있었습니다.
데이터베이스 쿼리가 최적화되어 있지 않았습니다. 코드 안에 반복문이 있었고, 각 글마다 작성자 정보를 따로 조회하고 있었습니다. 전형적인 N+1 쿼리입니다. 글이 100개면 데이터베이스 요청도 100번입니다. 느리지 않을 수가 없습니다. 더 골치 아픈 건 일부 테이블에는 인덱스도 없었다는 점입니다.
캐시가 전혀 없었습니다. 사용자가 페이지를 새로고침할 때마다 서버는 데이터베이스를 다시 조회하고, 다시 계산하고, 다시 포맷했습니다. 설정 정보는 한 달에 한 번 바뀔까 말까 한데 매초 같은 계산을 반복하고 있었습니다.
모든 데이터를 한 번에 반환했습니다. API는 글 100개의 전체 내용을 한 번에 반환했고, 본문까지 포함하고 있었습니다. JSON 응답이 2MB를 넘어서 네트워크 전송에만 1초가 걸렸습니다. 사실 목록 페이지에는 본문이 필요 없습니다. 제목과 요약이면 충분합니다.
서버의 지리적 위치도 문제였습니다. 서버를 미국 서부에 배포해 두었기 때문에 중국 사용자 입장에서는 왕복만 해도 200ms부터 시작했습니다. 여기에 GFW의 영향까지 더해지면 더 말할 것도 없습니다.
Next.js 16이 가져온 캐시 변화
2025년 10월, Next.js 16은 꽤 중요한 업데이트를 내놓았습니다. 암묵적 캐시에서 명시적 캐시로 바뀐 것입니다.
이전에는 Next.js가 많은 것을 자동으로 캐시해 주었습니다. 듣기에는 편하지만 실제로 쓰다 보면 자주 헷갈렸습니다. 이게 캐시된 건지, 얼마나 캐시되는지, 어떻게 지워야 하는지 알기 어려웠습니다. 데이터는 분명 업데이트됐는데 페이지에는 오래된 내용이 계속 보이고, 한참 디버깅한 뒤에야 캐시 때문이라는 걸 알게 되는 일이 많았습니다.
이제는 무엇을 캐시할지, 얼마나 캐시할지 Next.js에 명확히 알려줘야 합니다. 조금 번거롭지만 적어도 무슨 일이 일어나는지는 알 수 있고, 제어하기도 훨씬 쉬워졌습니다.
성능 최적화의 세 가지 방향
문제를 파악하고 나면 최적화 방향은 분명해집니다.
- 캐시: 이미 한 일을 반복하지 않습니다
- 스트리밍 응답: 모두 준비된 뒤 반환하지 말고 계산하면서 보냅니다
- 엣지 컴퓨팅: 서버를 사용자와 더 가까운 곳으로 옮깁니다
이제 하나씩 살펴보겠습니다.
캐시 전략: 방법을 제대로 고르면 절반은 끝납니다
Next.js의 캐시 메커니즘은 Request Memoization, Data Cache, Full Route Cache, Router Cache 네 가지입니다. 저도 문서를 처음 봤을 때는 헷갈렸습니다. 이렇게 많은 유형을 어떻게 기억해야 할까요?
사실 전부 외울 필요는 없습니다. API Routes에서 가장 자주 쓰는 것은 Data Cache입니다. 데이터베이스 쿼리 결과나 외부 API 응답을 캐시하는 방식입니다.
상황 1: 정적 데이터 캐시
웹사이트 설정이나 분류 목록처럼 거의 변하지 않는 데이터는 한두 시간 캐시해도 괜찮습니다.
// app/api/categories/route.js
export async function GET() {
const data = await fetch('https://api.example.com/categories', {
next: { revalidate: 3600 } // 1시간 캐시
})
return Response.json(await data.json())
}
이렇게 간단합니다. revalidate: 3600은 1시간 동안 캐시한 뒤 자동으로 갱신한다는 뜻입니다.
상황 2: 사용자 관련 데이터 캐시
사용자 프로필 같은 데이터는 자주 바뀌지는 않지만, 오래된 값을 계속 보여줘서도 안 됩니다. 이때는 stale-while-revalidate 전략을 쓸 수 있습니다.
// app/api/user/profile/route.js
export async function GET(request) {
const user = await getUserFromDB()
return new Response(JSON.stringify(user), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 's-maxage=60, stale-while-revalidate=300'
}
})
}
이 전략은 꽤 똑똑합니다. 먼저 캐시된 데이터를 반환하고, 그 데이터가 조금 오래됐더라도 동시에 백그라운드에서 캐시를 비동기로 갱신합니다. 사용자는 지연을 체감하지 못하지만 데이터도 너무 오래되지는 않습니다.
s-maxage=60은 60초 동안 캐시가 신선하다는 뜻이고, stale-while-revalidate=300은 만료 후 300초 동안 오래된 데이터를 계속 사용할 수 있으며 그 사이 백그라운드에서 갱신한다는 뜻입니다.
상황 3: 실시간 데이터는 캐시하지 않습니다
주가나 채팅 메시지처럼 실시간성이 중요한 데이터는 캐시하지 않는 편이 좋습니다. 완전히 캐시하지 않거나 WebSocket 또는 Server-Sent Events로 푸시해야 합니다.
export async function GET() {
const price = await getStockPrice()
return new Response(JSON.stringify(price), {
headers: {
'Cache-Control': 'no-store' // 캐시하지 않음
}
})
}
캐시 무효화: 데이터가 업데이트된 뒤에는 어떻게 할까요?
사용자가 프로필을 수정했는데 캐시에는 여전히 오래된 값이 남아 있다면 어떻게 해야 할까요? 이때는 캐시를 수동으로 지워야 합니다.
Next.js는 revalidateTag와 revalidatePath 두 API를 제공합니다.
// app/api/user/update/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const data = await request.json()
await updateUserProfile(data)
// 사용자 관련 캐시 제거
revalidateTag('user-profile')
return Response.json({ success: true })
}
이에 맞춰 조회 API에서는 캐시에 태그를 붙입니다.
export async function GET() {
const data = await fetch('db-api/user', {
next: {
revalidate: 3600,
tags: ['user-profile'] // 태그
}
})
return Response.json(await data.json())
}
이렇게 하면 프로필을 수정한 뒤 관련 캐시가 즉시 무효화되고, 다음 요청에서는 최신 데이터를 가져옵니다.
자주 밟는 함정
첫 번째 함정: 과도한 캐시. 주문 상태를 1시간 동안 캐시한 사례를 본 적이 있습니다. 사용자가 결제한 뒤에도 한참 동안 주문 상태가 바뀌지 않았습니다. 캐시 시간은 데이터의 성격에 맞춰 정해야지, 길수록 좋은 것이 아닙니다.
두 번째 함정: 캐시 워밍업을 잊는 것. 첫 요청은 여전히 느립니다. 캐시가 비어 있기 때문입니다. 배포 후 주요 API를 한 번씩 호출해 인기 데이터를 미리 캐시에 올려 둘 수 있습니다.
세 번째 함정: 캐시 key 설계가 부적절한 것. 예를 들어 사용자 A의 데이터가 캐시됐는데 사용자 B도 A의 데이터를 받는 상황입니다. 캐시 key에는 사용자 ID처럼 구분에 필요한 정보를 반드시 포함해야 합니다.
스트리밍 응답: 대용량 데이터 전송도 버벅이지 않게
캐시는 반복 계산 문제를 해결합니다. 하지만 어떤 데이터는 애초에 계산이 느리거나 양이 많습니다. 이때 스트리밍 응답이 필요합니다.
스트리밍 응답이란 무엇인가요?
기존 API 응답은 식당에서 음식을 주문하는 것과 비슷합니다. 요리사가 모든 음식을 다 만든 뒤 한꺼번에 테이블로 가져옵니다. 10가지 메뉴를 주문했다면 가장 늦게 완성되는 음식까지 기다려야 합니다.
스트리밍 응답은 다릅니다. 하나가 완성되면 하나씩 내옵니다. 손님은 먹으면서 다음 음식을 기다릴 수 있습니다. 전체 시간은 비슷할 수 있지만, 손님은 훨씬 일찍 식사를 시작합니다.
사용자 입장에서는 “하얀 화면을 3초 동안 바라보는 것”이 “500ms 만에 앞의 몇 개 데이터를 보고 먼저 탐색하는 것”으로 바뀝니다. 체감 경험은 완전히 다릅니다.
언제 스트리밍 응답을 써야 할까요?
제가 정리한 대표적인 상황은 다음과 같습니다.
- 긴 목록 데이터: 상품 목록, 글 목록, 검색 결과
- AI 생성 콘텐츠: ChatGPT 같은 타자기 효과도 사실 스트리밍 응답입니다
- 대용량 파일 처리: Excel 내보내기, PDF 생성
- 실시간 로그: 빌드 로그, 작업 진행 상황
기본적으로 데이터가 많거나 계산 시간이 길다면 스트리밍 응답을 검토할 만합니다.
Next.js에서는 어떻게 구현할까요?
가장 흔한 방식은 ReadableStream을 쓰는 것입니다.
// app/api/posts/stream/route.js
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// 데이터를 나누어 가져오기
for (let page = 0; page < 5; page++) {
// 한 번에 20개 조회
const posts = await fetchPostsFromDB({ page, limit: 20 })
// 이번 묶음 전송
const chunk = JSON.stringify(posts) + '\n'
controller.enqueue(encoder.encode(chunk))
// 처리 시간 시뮬레이션
await new Promise(r => setTimeout(r, 100))
}
// 데이터 전송 완료
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked'
}
})
}
코드는 복잡하지 않습니다. 핵심은 네 가지입니다.
ReadableStream을 만듭니다start메서드 안에서 데이터를 나누어 가져옵니다controller.enqueue()로 각 묶음을 전송합니다- 모두 보낸 뒤
controller.close()를 호출합니다
프론트엔드에서는 어떻게 받을까요?
백엔드가 스트리밍 데이터를 보냈다면 프론트엔드도 그에 맞게 처리해야 합니다.
async function fetchStreamData() {
const response = await fetch('/api/posts/stream')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allPosts = []
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('데이터 수신 완료')
break
}
// 데이터 디코딩
const chunk = decoder.decode(value)
// JSON 파싱(한 줄에 하나)
const posts = JSON.parse(chunk)
allPosts = [...allPosts, ...posts]
// UI 실시간 업데이트
updatePostList(allPosts)
}
}
사용자가 페이지를 열면 목록이 오래 비어 있는 대신 콘텐츠가 점진적으로 표시됩니다.
실제 효과 비교
블로그 목록에 스트리밍 응답을 추가한 뒤 결과는 이랬습니다.
전체 시간은 1300ms 정도만 빨라졌지만, 사용자가 느끼는 속도는 두 배 이상 좋아졌습니다. 500ms 만에 상호작용할 수 있었고, 남은 시간에는 사용자가 콘텐츠를 보고 있었기 때문에 기다린다는 느낌이 거의 없었습니다.
작은 팁 하나
데이터가 특히 많다면 가상 스크롤(Virtual Scrolling)을 함께 사용할 수 있습니다. 프론트엔드는 보이는 영역의 데이터만 렌더링하고, 나머지는 일단 렌더링하지 않습니다. 이렇게 하면 1000개 데이터를 받아도 페이지가 버벅이지 않습니다.
React에서는 react-window나 react-virtualized를 쓸 수 있고, Vue에서는 vue-virtual-scroller를 쓸 수 있습니다.
Edge Functions: API를 사용자 가까이로 옮기기
캐시와 스트리밍 응답은 소프트웨어 계층의 최적화입니다. 하지만 더 직접적인 방법도 있습니다. 서버를 사용자와 가까운 곳으로 옮기는 것입니다.
물리적 거리의 영향
네트워크 요청 지연은 주로 물리적 거리에서 옵니다. 빛의 속도에는 한계가 있고, 데이터 패킷이 베이징에서 미국 서부까지 왕복하려면 최소 200ms가 걸립니다. 이것은 물리 법칙이라 최적화할 수 없습니다.
예전에는 서버를 한 고정 위치에만 배포할 수 있었습니다. 예를 들어 Alibaba Cloud 베이징 리전에 배포하면 베이징 사용자는 빠르지만 미국 사용자는 느렸습니다.
Edge Functions의 생각은 단순합니다. 코드를 전 세계 수십 개, 많게는 수백 개 노드에 배포하고, 사용자가 접속하면 가장 가까운 노드로 자동 라우팅합니다. 베이징 사용자는 베이징 노드에, 뉴욕 사용자는 뉴욕 노드에 접속하므로 지연 시간을 50ms 이내로 낮출 수 있습니다.
Edge Runtime과 Node.js Runtime의 차이
Next.js의 API Routes는 기본적으로 Node.js Runtime에서 실행됩니다. fs, crypto, 데이터베이스 연결 등 모든 Node.js API를 사용할 수 있습니다.
Edge Runtime은 다릅니다. Chrome 브라우저가 쓰는 V8 엔진 기반이며 완전한 Node.js 환경이 아닙니다. 장점은 시작이 매우 빠르다는 점(0-5ms)이고, 단점은 많은 Node.js API를 사용할 수 없다는 점입니다.
간단히 비교하면 다음과 같습니다.
| 특성 | Node.js Runtime | Edge Runtime |
|---|---|---|
| 시작 속도 | 100-500ms | 0-5ms |
| 사용 가능한 API | 모든 Node.js API | 제한적(Web 표준 API만) |
| 적합한 상황 | 복잡한 비즈니스 로직, 데이터베이스 작업 | 가벼운 로직, 인증, 프록시 |
| 전 세계 지연 시간 | 배포 위치에 따라 다름 | 전 세계 <50ms |
| 메모리 제한 | 비교적 높음 | 비교적 낮음(128MB) |
어떤 상황에 Edge Functions가 적합할까요?
모든 API가 Edge로 옮기기에 적합한 것은 아닙니다. 제가 정리한 대표적인 상황은 다음과 같습니다.
상황 1: 인증
Edge에 가장 적합한 상황은 인증입니다. JWT token 확인이나 API key 검증 같은 가벼운 로직은 엣지에서 끝내면 됩니다. 유효하지 않은 요청은 중앙 서버까지 도달할 필요가 없습니다.
// app/api/auth/route.js
export const runtime = 'edge'
export async function GET(request) {
const token = request.headers.get('authorization')
if (!token) {
return new Response('Unauthorized', { status: 401 })
}
// token 검증(jose 라이브러리 사용 가능, Edge 지원)
const isValid = await verifyToken(token)
if (!isValid) {
return new Response('Invalid token', { status: 401 })
}
return Response.json({ user: 'authenticated' })
}
상황 2: 위치 기반 개인화
사용자 IP에 따라 다른 콘텐츠를 반환합니다. 예를 들어 언어, 통화, 추천 콘텐츠를 다르게 줄 수 있습니다.
export const runtime = 'edge'
export async function GET(request) {
// 사용자 위치 가져오기(Vercel이 자동 주입)
const country = request.geo?.country || 'US'
const city = request.geo?.city || 'Unknown'
// 위치에 따라 다른 콘텐츠 반환
const content = getLocalizedContent(country)
return Response.json({
country,
city,
content,
currency: country === 'CN' ? 'CNY' : 'USD'
})
}
데이터베이스를 조회할 필요 없이 엣지에서 바로 처리하므로 매우 빠릅니다.
상황 3: API 프록시
프론트엔드가 여러 외부 API를 호출해야 할 때가 있습니다. Edge 계층에서 이를 모아 주면 클라이언트 요청 횟수를 줄일 수 있습니다.
export const runtime = 'edge'
export async function GET(request) {
// 여러 API를 병렬 요청
const [weather, news] = await Promise.all([
fetch('https://api.weather.com/...'),
fetch('https://api.news.com/...')
])
return Response.json({
weather: await weather.json(),
news: await news.json()
})
}
사용자는 요청을 하나만 보내고, 백엔드는 병렬로 처리하므로 전체 지연 시간이 크게 줄어듭니다.
상황 4: A/B 테스트
엣지 계층에서 어떤 버전의 콘텐츠를 반환할지 결정하면 메인 애플리케이션을 수정할 필요가 없습니다.
export const runtime = 'edge'
export async function GET(request) {
const userId = request.headers.get('x-user-id')
// 단순한 A/B 분기 로직
const variant = parseInt(userId) % 2 === 0 ? 'A' : 'B'
const content = variant === 'A' ? getContentA() : getContentB()
return Response.json({ variant, content })
}
Edge Functions의 제한
Edge가 이렇게 좋다면 왜 전부 옮기지 않을까요? 제한이 꽤 많기 때문입니다.
제한 1: Node.js 전용 API를 사용할 수 없습니다
fs, path, child_process 같은 API를 사용할 수 없습니다. 코드에서 이런 API를 쓰고 있다면 Edge로 옮길 때 오류가 납니다.
제한 2: 데이터베이스 연결
전통적인 데이터베이스 연결 방식인 pg, mysql2는 사용할 수 없습니다. Node.js의 net 모듈에 의존하기 때문입니다. 대신 HTTP 기반 방식을 써야 합니다. 예를 들면 다음과 같습니다.
- Prisma Data Proxy
- PlanetScale(MySQL)
- Supabase(PostgreSQL)
- Redis(HTTP API 지원)
제한 3: 메모리와 실행 시간 제한
Edge Functions에는 보통 메모리 제한(128MB)과 실행 시간 제한(30초)이 있습니다. 복잡한 계산이나 대용량 데이터 처리는 적합하지 않습니다.
제 제안: 혼합해서 쓰기
둘 중 하나만 고를 필요는 없습니다. 저는 이렇게 나눕니다.
- 엣지 계층(Edge): 인증, 위치 판단, 단순 프록시
- 중앙 계층(Node.js): 복잡한 비즈니스 로직, 데이터베이스 작업, 파일 처리
Edge 계층이 유효하지 않은 요청과 단순 요청을 먼저 막고, 복잡한 요청만 중앙 서버로 전달합니다. 이렇게 하면 지연 시간도 낮추고 Edge의 제한도 피할 수 있습니다.
성능 실측
Medium의 한 benchmark 연구에 따르면 다음과 같습니다.
- Vercel Edge Functions: 평균 지연 시간 48.3ms
- Cloudflare Workers(커스텀): 평균 지연 시간 36.37ms
- 전통적인 Node.js API(단일 지역): 평균 지연 시간 200-500ms
Edge가 빠른 것은 사실입니다. 다만 실제 효과는 사용자 분포를 봐야 합니다. 사용자가 모두 중국 안에 있다면 중국 내에 배포한 전통적인 서버가 더 빠를 수도 있습니다.
종합 실전 사례: 블로그 글 목록 API 최적화
앞에서 세 가지 기술을 살펴봤으니 이제 실제로 어떻게 함께 쓰는지 보겠습니다. 처음에 사용자에게 지적받았던 느린 블로그 목록 API를 예로 들어 보겠습니다.
최적화 전 문제
먼저 기존 코드를 보겠습니다.
// app/api/posts/route.js
export async function GET() {
// 문제 1: 매번 데이터베이스 조회, 캐시 없음
const posts = await db.post.findMany({
take: 100,
include: {
author: true, // 문제 2: N+1 쿼리
tags: true
}
})
// 문제 3: 전체 글 본문을 반환해 데이터가 큼
return Response.json(posts)
}
성능 데이터는 이랬습니다.
- 응답 시간: 2800ms
- JSON 크기: 2.3MB
- 사용자 경험: 3초 동안 하얀 화면
첫 번째 단계: 데이터베이스 쿼리 최적화
먼저 N+1 쿼리 문제를 해결하고 필요한 필드만 반환합니다.
export async function GET() {
const posts = await db.post.findMany({
take: 100,
select: {
id: true,
title: true,
summary: true, // 요약만 필요하고 전문은 필요 없음
createdAt: true,
author: {
select: { name: true, avatar: true }
}
}
})
return Response.json(posts)
}
효과는 응답 시간이 800ms로 줄고, JSON 크기가 2.3MB에서 180KB로 줄었습니다.
두 번째 단계: 캐시 추가
글 목록은 자주 바뀌지 않으므로 5분 동안 캐시할 수 있습니다.
export async function GET() {
const posts = await db.post.findMany({
// ... 위와 동일
}, {
next: {
revalidate: 300, // 5분 캐시
tags: ['posts']
}
})
return Response.json(posts)
}
글을 발행할 때 캐시를 지우도록 연결합니다.
// app/api/posts/publish/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const newPost = await request.json()
await db.post.create({ data: newPost })
// 글 목록 캐시 제거
revalidateTag('posts')
return Response.json({ success: true })
}
효과는 캐시 적중 시 응답 시간 50ms, 서버 부하 90% 감소였습니다.
세 번째 단계: 스트리밍 응답으로 전환
이미 꽤 빨라졌지만 최초 방문, 즉 캐시 미스 상황에서는 여전히 800ms를 기다려야 합니다. 이를 스트리밍 응답으로 바꿉니다.
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const batchSize = 20
for (let page = 0; page < 5; page++) {
const posts = await db.post.findMany({
skip: page * batchSize,
take: batchSize,
select: { /* 위와 동일 */ }
})
const chunk = JSON.stringify(posts) + '\n'
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson', // Newline Delimited JSON
'Cache-Control': 's-maxage=300, stale-while-revalidate=600'
}
})
}
효과는 첫 데이터 묶음이 300ms에 반환되고, 사용자가 바로 탐색할 수 있었다는 점입니다. 전체 소요 시간은 800ms지만 사용자는 거의 기다린다고 느끼지 않습니다.
네 번째 단계: Edge 계층 인증(선택)
인증이 필요하다면 Edge 계층에서 1차 검증을 할 수 있습니다.
// app/api/posts/route.js (Edge 인증 계층)
export const runtime = 'edge'
export async function GET(request) {
const token = request.headers.get('authorization')
if (!token) {
return new Response('Unauthorized', { status: 401 })
}
// 검증 통과 후 실제 API(Node.js Runtime)로 전달
return fetch(`${process.env.API_BASE_URL}/posts/internal`, {
headers: { authorization: token }
})
}
유효하지 않은 요청은 엣지에서 차단되므로 중앙 서버까지 가지 않습니다.
최적화 효과 비교
| 지표 | 최적화 전 | 최적화 후 | 개선 |
|---|---|---|---|
| 최초 방문 응답 시간 | 2800ms | 300ms(첫 묶음) | 89% ↓ |
| 캐시 적중 응답 시간 | - | 50ms | 98% ↓ |
| JSON 크기 | 2.3MB | 180KB | 92% ↓ |
| 사용자 상호작용 가능 시간 | 2800ms | 300ms | 89% ↓ |
| 서버 부하 | 100% | 10% | 90% ↓ |
이제 사용자가 “요즘 시대에 이런 사이트가 있나요?”라고 말할 일은 없어졌습니다.
성능 모니터링과 지속적인 최적화
최적화가 끝났다고 일이 끝나는 것은 아닙니다. 계속 모니터링해야 효과를 알 수 있습니다.
핵심 지표
제가 보는 지표는 다음과 같습니다.
-
응답 시간 분포(P50, P95, P99)
- P50(중앙값): 사용자 절반의 경험
- P95: 사용자 95%의 경험
- P99: 가장 느린 1% 사용자의 경험이며 예외 상황을 드러낼 수 있습니다
-
캐시 적중률
- 적중률이 <70%라면 캐시 전략에 문제가 있을 수 있습니다
- 적중률이 >95%라면 캐시 시간이 너무 길어 데이터가 신선하지 않을 수 있습니다
-
오류율
- 최적화 후 오류율이 올라가지 않았는지 확인해야 합니다
- 스트리밍 응답은 중간에 실패할 수 있으므로 특히 주의해야 합니다
-
지역별 분포
- 지역별 사용자 지연 시간 차이
- Edge Functions가 필요한지 판단하는 기준이 됩니다
모니터링 도구
Vercel Analytics: Vercel에 배포했다면 기본 성능 모니터링이 제공됩니다. 각 API의 응답 시간 분포를 볼 수 있습니다.
Next.js Instrumentation API(2026년 신규 기능): 코드 안에 모니터링 지점을 넣을 수 있습니다.
// instrumentation.js
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
require('./monitoring')
}
}
// monitoring.js
export function onRequestEnd(info) {
console.log(`API ${info.url} took ${info.duration}ms`)
// 모니터링 플랫폼으로 전송
sendToMonitoring({
url: info.url,
duration: info.duration,
status: info.status
})
}
직접 만든 로그: 단순하지만 효과적입니다.
export async function GET() {
const start = Date.now()
const data = await fetchData()
const duration = Date.now() - start
console.log(`API /posts took ${duration}ms`)
return Response.json(data)
}
지속적인 최적화 제안
- 캐시 전략을 정기적으로 점검하기: 비즈니스가 바뀌면 캐시 전략도 조정해야 합니다
- A/B 테스트하기: 어떤 방법이 더 나은지 확신이 없다면 테스트해 봅니다
- 실제 데이터에 맞춰 조정하기: 감으로 결정하지 말고 모니터링 데이터를 보고 판단합니다
성능 최적화는 한 번에 끝나는 일이 아니라 지속적인 과정입니다.
정리
많이 이야기했으니 핵심만 정리해 보겠습니다.
캐시 전략: 데이터 성격에 맞춰 선택합니다. 정적 데이터는 긴 캐시, 사용자 데이터는 짧은 캐시와 stale-while-revalidate, 실시간 데이터는 캐시하지 않습니다. 데이터가 업데이트된 뒤 캐시를 지우는 것도 잊지 마세요.
스트리밍 응답: 데이터가 많거나 계산이 느릴 때 특히 효과적입니다. 사용자가 콘텐츠를 더 빨리 보게 하고, 하얀 화면만 바라보며 기다리게 하지 않습니다. 프론트엔드에서 가상 스크롤을 함께 쓰면 더 좋습니다.
Edge Functions: 인증, 위치 판단, API 프록시처럼 가벼운 로직에 적합합니다. 복잡한 비즈니스 로직까지 처리하길 기대하지 말고, Node.js Runtime과 섞어 쓰는 것이 현실적인 선택입니다.
최적화는 한 번에 완벽하게 끝내는 작업이 아닙니다. 가장 느린 API부터 시작해 이 세 가지 방법을 적용하고, 실제 효과를 측정한 뒤 다시 조정하면 됩니다. 한 걸음씩 가면 됩니다.
제가 맡았던 블로그 목록 API는 3초에서 300ms로 줄었고, 사용자 경험은 눈에 띄게 좋아졌습니다. 여러분도 느린 API 하나를 골라 오늘부터 최적화를 시작해 보세요. 궁금한 점이 있으면 댓글로 이야기해 주세요. 같이 더 나은 방향을 찾아봅시다.
FAQ
Next.js API 캐시는 언제 만료되나요?
스트리밍 응답은 모든 API에 적합한가요?
Edge Functions에는 어떤 제한이 있나요?
캐시 전략은 어떻게 선택해야 하나요?
최적화 후 효과는 어떻게 검증하나요?
5분 읽기 · 게시일: 2026년 1월 5일 · 수정일: 2026년 10월 21일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js API Routes 완벽 가이드: Route Handlers부터 오류 처리 모범 사례까지
Next.js API Routes 완벽 가이드입니다. Route Handlers 생성 방법, 요청 처리 기법, 오류 처리 모범 사례, 응답 형식 설계를 자세히 살펴보고 Next.js로 백엔드 API를 쉽게 개발하는 방법을 알아봅니다.
51편 중 18편
다음
Next.js API 인증과 보안: JWT부터 속도 제한까지 완벽 실전 가이드
JWT 인증, CORS 설정, 속도 제한, 입력 검증부터 최신 보안 취약점 대응까지 안전하고 신뢰할 수 있는 프로덕션급 Next.js API를 구축하는 실전 가이드입니다.
51편 중 20편



댓글
GitHub로 로그인하여 댓글을 남기세요