
Next.js API Cache 실험 - fetch vs axios (로그로 직접 확인하기)
Next.js에서 fetch와 axios의 캐싱 동작 차이를 비교하고, React cache()를 활용한 API 최적화 방법을 실제 테스트 프로젝트와 로그 결과를 통해 살펴봅니다.
들어가며
Next.js 프로젝트에서 API 호출을 구현할 때 가장 많이 쓰이는 방법은 fetch와 axios입니다.
문법 차이만 있는 것처럼 보이지만, 실제 운영 환경에서는 캐시 동작 방식에서 큰 차이가 발생합니다.
이를 검증하기 위해 간단한 테스트 프로젝트를 만들고, 동일한 API를 fetch와 axios로 호출했을 때의 캐시 동작과 성능 차이를 로그를 통해 확인해 보았습니다.
아래 링크를 통해 직접 실행하고 실험을 재현할 수 있습니다.
테스트 환경 구성
테스트 프로젝트를 로컬 환경에서 실행하여 캐시 동작을 검증할 수 있습니다:
# 프로젝트 클론 및 실행
git clone https://github.com/sijunnoh/blog-api-cache
cd blog-api-cache
pnpm install && pnpm dev
# 브라우저와 터미널 콘솔을 동시에 열어두고 테스트 진행
# 브라우저: http://localhost:3000
# 터미널: 서버 로그 확인
테스트 대상 페이지:
/fetch-test
- Next.js fetch의 자동 캐싱 동작 검증/axios-test
- axios의 기본 동작 (캐시 없음) 검증/axios-cache-test
- React cache()를 적용한 axios 동작 검증/axios-cache-broken-test
- cache() 부적절한 사용 사례 검증
각 페이지 접속 시 터미널 콘솔에서 API 호출 횟수를, 브라우저 Network 탭에서 실제 네트워크 요청을 모니터링하여 결과를 확인할 수 있습니다.
본 글에서는 테스트 결과를 바탕으로 다음과 같은 내용을 체계적으로 분석하였습니다:
- 테스트 환경 구성 - 실험을 위한 프로젝트 설정 방법
- fetch의 자동 캐싱 메커니즘 - Next.js의 내장 최적화 기능 분석
- axios의 캐시 전략 - React cache() API를 활용한 최적화 방안
- 실험 결과 분석 - 정량적 성능 비교 및 로그 분석
- 실무 적용 방안 - 프로젝트 상황별 선택 기준
2. fetch의 자동 캐싱 메커니즘
Next.js 13+ App Router 환경에서 fetch는 단순한 네트워크 요청 도구가 아니라 빌트인 캐싱 전략과 결합되어 동작합니다.
테스트 케이스: /fetch-test
페이지 분석
프로젝트 실행 후 /fetch-test
페이지에 접속하면 다음 코드가 실행됩니다:
// layout.tsx, generateMetadata(), page.tsx에서 각각 fetch 호출
export const generateMetadata = async (): Promise<Metadata> => {
const timeResponse = await fetch("http://localhost:3000/api/current-time")
const timeResponse2 = await fetch("http://localhost:3000/api/current-time")
const time1 = await timeResponse.json()
const time2 = await timeResponse2.json()
console.log("generateMetadata fetch 1", time1)
console.log("generateMetadata fetch 2", time2)
// ...
}
const FetchTestPage = async () => {
const timeResponse = await fetch("http://localhost:3000/api/current-time")
const timeResponse2 = await fetch("http://localhost:3000/api/current-time")
// ...
}
실행 결과 분석
터미널 콘솔 출력 (서버 측):
Current time API is being called # 1회만 출력됨
브라우저 콘솔 출력:
layout fetch 1: { currentTime: "2024-01-01T12:00:00.000Z" }
layout fetch 2: { currentTime: "2024-01-01T12:00:00.000Z" } // 동일한 값
generateMetadata fetch 1: { currentTime: "2024-01-01T12:00:00.000Z" } // 동일한 값
generateMetadata fetch 2: { currentTime: "2024-01-01T12:00:00.000Z" } // 동일한 값
page fetch 1: { currentTime: "2024-01-01T12:00:00.000Z" } // 동일한 값
page fetch 2: { currentTime: "2024-01-01T12:00:00.000Z" } // 동일한 값
분석 결과
- 총 6회의 fetch 호출이 발생하였으나 실제 API는 1회만 실행됨
- 모든 응답이 동일한 시간 값을 반환 (캐시된 결과 활용)
- Next.js의 Request Memoization 메커니즘을 통한 자동 최적화
- 별도의 캐싱 구현 없이 성능 최적화 효과 확인
3. axios의 캐시 전략
axios는 자체 캐싱 기능이 없으며, 매번 네트워크 요청을 발생시킵니다. 다만 React 18 cache() API를 조합하면 fetch와 유사한 최적화를 구현할 수 있습니다.
테스트 케이스: /axios-test
대비 /axios-cache-test
비교 분석
기본 axios 동작 (캐싱 미적용)
/axios-test
페이지에서 실행되는 코드:
// 기본 axios - 매번 새로운 요청
export const generateMetadata = async (): Promise<Metadata> => {
const timeResponse = await axios.get("http://localhost:3000/api/current-time")
const timeResponse2 = await axios.get("http://localhost:3000/api/current-time")
console.log("generateMetadata axios 1", timeResponse.data)
console.log("generateMetadata axios 2", timeResponse2.data)
// ...
}
실행 결과:
# 터미널 콘솔 출력 (서버 측) - 6회 모두 호출됨
Current time API is being called
Current time API is being called
Current time API is being called
Current time API is being called
Current time API is being called
Current time API is being called
// 브라우저 콘솔 출력 - 모든 응답이 상이함
layout axios 1: { currentTime: "2024-01-01T12:00:00.123Z" }
layout axios 2: { currentTime: "2024-01-01T12:00:00.456Z" } // 다른 값
generateMetadata axios 1: { currentTime: "2024-01-01T12:00:00.789Z" } // 다른 값
// 이후 모든 호출에서 서로 다른 시간 값 반환
axios + React cache() 적용 (최적화)
/axios-cache-test
페이지에서 실행되는 코드:
import { cache } from "react"
import axios from "axios"
// React cache로 감싼 axios 함수
const fetchTimeWithCache = cache(async () => {
console.log("fetchTimeWithCache 호출됨")
const response = await axios.get("http://localhost:3000/api/current-time")
return response.data
})
export const generateMetadata = async (): Promise<Metadata> => {
const time1 = await fetchTimeWithCache()
const time2 = await fetchTimeWithCache()
// ...
}
실행 결과:
# 터미널 콘솔 출력 (서버 측) - fetch와 동일한 패턴
fetchTimeWithCache 호출됨 # 1회만 출력
Current time API is being called # 1회만 출력
주의사항: cache() 부적절한 사용 사례
/axios-cache-broken-test
페이지에서 확인할 수 있는 부적절한 구현 패턴:
// ❌ 잘못된 사용 - 매개변수 참조 문제
const fetchTimeWithBrokenCache = cache(async (options = {}) => {
const response = await axios.get("/api/current-time")
return response.data
})
// 매번 새로운 빈 객체를 전달 → 캐시 무효화
const time1 = await fetchTimeWithBrokenCache({}) // 새 객체 #1
const time2 = await fetchTimeWithBrokenCache({}) // 새 객체 #2
React cache()는 매개변수의 참조 동일성을 기준으로 캐시를 판단하는 메커니즘을 사용합니다. 따라서 매번 새로운 객체를 전달할 경우 캐시가 무효화되어 의도한 최적화 효과를 얻을 수 없습니다.
4. 실험 결과 정량적 분석
각 테스트 케이스의 실행 결과를 체계적으로 분석한 결과는 다음과 같습니다:
방법 | 페이지 | 실제 API 호출 | 응답 일관성 | 캐싱 방식 |
---|---|---|---|---|
fetch | /fetch-test | 1회 | ✅ 동일 | 자동 (Next.js) |
axios | /axios-test | 6회 | ❌ 다름 | 없음 |
axios + cache() | /axios-cache-test | 1회 | ✅ 동일 | 수동 (React) |
axios + cache() | /axios-cache-broken-test | 6회 | ❌ 다름 | 무효화 |
네트워크 요청 분석
브라우저 개발자 도구의 Network 탭을 통해 확인한 실제 네트워크 요청 수:
- fetch: 1개의 네트워크 요청
- axios: 6개의 네트워크 요청
- axios + cache(): 1개의 네트워크 요청
서버 측 실행 로그 분석
각 페이지 접속 시 터미널에 출력되는 "Current time API is being called" 메시지의 빈도가 실제 API 함수 실행 횟수를 나타냅니다.
5. 실무 적용 방안 및 선택 기준
fetch 사용 권장 상황
// 간단한 API 호출, 별도 설정 불필요
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
- 단순한 HTTP 요청이 주된 사용 사례인 경우
- Next.js의 자동 캐싱 메커니즘을 최대한 활용하려는 경우
- 번들 크기 최소화가 중요한 요구사항인 경우
- 팀의 학습 비용을 최소화하려는 경우
axios + cache() 사용 권장 상황
import { cache } from "react"
import axios from "axios"
// 복잡한 설정이 필요한 API 호출
const api = axios.create({
baseURL: process.env.API_BASE_URL,
timeout: 5000,
headers: {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
}
})
const getUser = cache(async (id: string) => {
const response = await api.get(`/users/${id}`)
return response.data
})
- 인터셉터, 타임아웃, 커스텀 헤더 등 고급 HTTP 기능이 필요한 경우
- 세밀한 에러 핸들링 제어가 요구되는 경우
- 기존 프로젝트에서 axios 생태계를 활용 중인 경우
- 복잡한 요청/응답 변환 로직이 필요한 경우
권장하지 않는 구현 패턴
// ❌ cache()에 매번 새로운 객체 전달
const getUser = cache(async (options = {}) => {
return axios.get('/api/user', options)
})
getUser({}) // 새 객체 #1
getUser({}) // 새 객체 #2 → 캐시 미스!
// ✅ 올바른 사용
const getUser = cache(async () => {
return axios.get('/api/user')
})
getUser() // 캐시 적용
getUser() // 캐시된 결과 반환
마무리
이번 실험을 통해 확인한 바는 명확합니다.
- fetch는 Next.js의 빌트인 캐싱 메커니즘과 결합되어, 별도의 설정 없이도 효율적인 캐시 전략을 제공합니다.
- axios는 편리한 기능과 확장성을 제공하지만, 캐싱은 직접 관리해야 하며 React 18
cache()
같은 도구를 조합해야 fetch와 유사한 효과를 얻을 수 있습니다.
특히 axios를 사용할 때는 매개변수로 객체를 전달하는 경우를 주의해야 합니다.
React cache()
는 참조 동일성(reference equality) 을 기준으로 캐시를 판별하기 때문에, 매번 새로운 객체가 생성되면 캐시가 무효화됩니다.
따라서 axios 요청 함수를 설계할 때는 객체 매개변수를 최소화하거나, 안정적인 키 값(문자열/프리미티브 값)을 인자로 사용하는 방식이 필요합니다.
결론적으로, 단순한 데이터 요청과 Next.js 기본 최적화를 활용하고 싶다면 fetch를,
복잡한 요청 흐름 제어나 고급 HTTP 기능이 필요하다면 axios + cache() 조합을 선택하는 것이 합리적입니다.
무엇보다 중요한 것은 프로젝트 요구사항과 팀 상황에 맞는 도구를 선택하는 것입니다.
이번 테스트 프로젝트를 직접 실행해 보면서, 각 방식이 어떻게 다른 결과를 만들어내는지 확인해 보시기를 추천드립니다.