저번에 최적화에 대한 이야기를 하면서 캐싱이라는 단어를 언급한 적이 있었습니다. 그런데 문득 “과연 나는 캐싱을 제대로 알고 있을까?”라는 생각이 들었고, 지금까지 웹 개발을 하며 캐싱과 관련된 다양한 경험들을 되짚어보기로 했습니다. React Query 같은 라이브러리에서도 캐싱을 자주 접하게 되는데, 이 기회를 통해 프론트엔드에서 캐싱이 어떤 방식으로 활용되는지 면밀하게 분석하고, 그 내용을 여러분께도 공유하고자 합니다. 이번 글이 캐싱에 대한 실질적인 이해와 도움을 줄 수 있기를 바라면서 시작합니다.
캐싱(Cache)은 자주 접근하는 데이터를 미리 저장해 두고, 다시 필요할 때 빠르게 가져오는 방식입니다. 네트워크 요청을 매번 새로 하지 않고, 이전 데이터를 재사용함으로써 성능을 최적화하고 응답 속도를 향상시키는 것이 주요 목적입니다. 캐싱은 데이터 소모를 줄여주고, 특히 사용자 경험을 높이는 데 큰 역할을 합니다. 예를 들어, 매번 같은 이미지를 로드하기보다 캐시에 저장해 두었다가 재사용하면 훨씬 빠르게 화면을 렌더링할 수 있습니다.
캐싱이 필요한 이유는 크게 두 가지로 요약됩니다:
프론트엔드 개발자가 캐싱을 이해하고 사용할 때 중요한 점은, 캐싱의 범위와 목적을 고려하여 필요한 캐싱 전략을 선택하는 것입니다. 데이터가 어떤 특성을 가지고 있고, 어디에 저장되느냐에 따라 캐싱 방식이 달라지기 때문입니다.
프론트엔드에서 캐싱을 분류하는 방법으로는 상태 기반 캐싱, 웹 캐싱, 외부 서버 캐싱의 세 가지가 있습니다. 각 방식은 서로 다른 특성을 가지며, 특정 상황에서 효과적으로 활용할 수 있습니다.
이제, 각각의 캐싱 방식이 어떤 역할을 하고 어떻게 활용되는지 자세히 살펴보겠습니다.
상태 기반 캐싱은 애플리케이션에서 자주 요청하는 API 데이터나 컴포넌트 상태를 효율적으로 관리하기 위한 방식입니다. 예를 들어, React Query
와 같은 라이브러리를 사용하면 서버 데이터를 상태로 관리하면서도, 캐싱을 통해 불필요한 네트워크 요청을 줄일 수 있습니다. 캐싱된 데이터는 일정 시간 이후 자동으로 갱신되거나, 사용자 인터랙션이 발생할 때 갱신될 수 있어 매우 유연하게 동작합니다.
클라이언트에서 서버 데이터를 캐싱하고 필요한 경우만 다시 요청하여 성능을 높입니다.
사용자 프로필 데이터를 React Query로 관리하면 컴포넌트가 언마운트된 후에도 데이터가 캐시에 남아, 다른 컴포넌트에서 중복된 API 호출을 방지합니다.
import { useQuery } from 'react-query';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery(['user', userId], () =>
fetch(`/api/users/${userId}`).then((res) => res.json())
);
if (isLoading) return <p>Loading...</p>;
return <div>{user.name}</div>;
}
Tanstack Query Documentation - Caching
SWR Documentation - Fetch and Revalidation
웹 캐싱은 브라우저 캐싱과 서버에서의 미리 렌더링된 캐싱으로 나눌 수 있습니다. 웹에서의 캐싱은 주로 네트워크 요청을 줄이고 로딩 시간을 단축하여 사용자 경험을 개선하는 데 중점을 둡니다. 특히 Next.js의 프리렌더링(SSG, ISR)과 프리페칭/프리로딩은 각각 다른 방식으로 웹 성능 최적화를 달성하며, 넓은 의미에서 캐싱과 유사한 효과를 낼 수 있습니다.
브라우저는 Cache-Control 헤더와 ETag를 통해 캐싱 정책을 설정합니다. 예를 들어, Cache-Control 헤더를 통해 리소스의 만료 시간을 지정하여, 일정 시간 동안 리소스를 캐싱할 수 있습니다.
예시: 웹 서버의 정적 자원에 Cache-Control: max-age=3600
을 설정하여 한 시간 동안 브라우저 캐시에 유지할 수 있습니다.
Cache-Control: max-age=3600
ETag: "abc123" // 브라우저가 리소스를 다시 요청할 때, 서버와 비교하여 최신 여부를 판단합니다.
MDN - Cache-Control
MDN - ETag
프리페칭(Prefetching)은 다음에 필요할 리소스를 미리 가져오는 방식입니다.
예를 들어, Next.js의 Link
컴포넌트에서 prefetch
속성을 설정하면, 사용자가 현재 페이지에 있을 때 다음 페이지에 필요한 리소스를 미리 로드합니다. 이를 통해 빠른 페이지 전환이 이루어질 수 있습니다.
<Link href="/profile" prefetch={true}> // 다음 페이지 리소스를 미리 로드
<a>Go to Profile</a>
</Link>
프리로딩(Preloading)은 현재 페이지의 초기 로딩 속도를 높이기 위해 중요한 리소스를 미리 로드하는 방식입니다. 크리티컬 CSS, 자바스크립트 파일, 이미지 등을 프리로딩하면, 페이지가 완전히 렌더링되기 전에 필요한 자원들을 미리 확보하여 빠르게 화면을 표시할 수 있습니다.
Next.js Documentation - Prefetching
MDN - Resource Hints (preload)
Next.js에서는 프리렌더링을 통해 페이지를 미리 생성하여 사용자에게 빠르게 제공합니다. 이 방식은 초기 로딩 속도를 높이는 데 매우 효과적이며, 캐싱과 비슷한 역할을 합니다.
SSG (Static Site Generation)은 빌드 시점에 HTML을 미리 생성하여 CDN에 저장하고, 모든 사용자에게 동일한 정적 콘텐츠를 제공합니다. 주로 자주 변경되지 않는 콘텐츠에 적합하며, 서버의 부하를 줄이고 로딩 속도를 극대화할 수 있습니다.
예시: 블로그 글처럼 고정된 콘텐츠는 SSG를 통해 미리 생성해 두고, 사용자에게 빠르게 전달할 수 있습니다.
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
};
}
ISR (Incremental Static Regeneration)은 SSG의 변형으로, 정적 페이지를 주기적으로 갱신하는 방식입니다. 일정 시간이 지나면 서버가 페이지를 다시 생성하여 최신 데이터를 반영합니다. 이는 실시간 데이터가 필요한 페이지에서도 정적 페이지의 속도를 유지할 수 있게 합니다.
예시: 뉴스 사이트의 헤드라인처럼 데이터가 자주 변경되지만 매번 실시간 데이터를 불러올 필요가 없는 경우 ISR을 사용해 성능을 최적화할 수 있습니다.
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts },
revalidate: 60, // 매 60초마다 페이지를 갱신
};
}
Next.js Documentation - Static Generation
Next.js Documentation - Incremental Static Regeneration
외부 서버 캐싱은 CDN이나 Redis와 같은 외부 서버를 사용해 데이터를 캐싱하여 전체 애플리케이션 성능을 최적화하는 방식입니다. 특히 대규모 트래픽이 발생하는 애플리케이션에서는 CDN과 Redis를 함께 사용하여 정적 콘텐츠와 동적 데이터 모두에서 고속의 응답을 제공할 수 있습니다. 이를 통해 서버 부하를 줄이고 사용자에게 빠른 데이터를 전달할 수 있습니다.
CDN은 전 세계 여러 위치의 엣지 서버에 정적 자원(이미지, CSS, JavaScript 파일 등)을 분산하여 캐싱하는 방식입니다. 사용자는 자신과 가까운 위치의 서버에서 리소스를 제공받기 때문에, 지연 시간이 줄어들고 빠른 응답이 가능해집니다.
예시: 글로벌 커머스 웹사이트에서 제품 이미지와 CSS 파일을 CDN에 캐싱합니다. 사용자가 웹사이트를 방문하면, CDN 서버에서 가까운 위치의 이미지와 스타일 시트를 제공받아 페이지 로딩 속도가 빨라집니다.
사용 코드 예시 (Next.js와 CDN을 연동해 정적 파일을 제공):
// next.config.js
module.exports = {
images: {
domains: ['cdn.example.com'], // CDN 도메인 등록
},
};
// 페이지 컴포넌트에서 이미지 사용
import Image from 'next/image';
function ProductPage() {
return (
<div>
<Image
src="https://cdn.example.com/product-image.jpg"
alt="Product Image"
width={500}
height={500}
/>
</div>
);
}
Redis는 동적 데이터나 실시간 API 응답을 캐싱하는 데 적합합니다. Redis는 메모리 기반 데이터 저장소이므로, 데이터베이스에 접근하지 않고도 메모리에서 즉시 데이터를 제공하여 반복적인 데이터베이스 요청을 줄일 수 있습니다.
외부 캐시 서버로 Redis를 선택하는 이유가 많더군요? 실제 제가 개발한 경험이 없어 코드 예시는 의미가 없다고 생각해서 Redis가 선택되는 이유에 대해 몇가지 조사를 해봤습니다.
고속 데이터 접근: Redis는 메모리 기반 데이터 저장소이므로, 디스크 기반 데이터베이스보다 훨씬 빠르게 데이터에 접근할 수 있습니다. 이를 통해 API 응답 시간을 줄이고, 사용자에게 실시간 데이터를 제공하는 데 유리합니다.
데이터베이스 부하 감소: 자주 요청되는 데이터를 Redis에 캐싱하면 데이터베이스에 대한 요청 수를 줄일 수 있어, 데이터베이스의 부하가 줄어들고 안정성이 높아집니다.
간단한 데이터 구조 지원: Redis는 문자열, 리스트, 셋, 해시 등 다양한 데이터 구조를 지원합니다.
만료 시간 설정: Redis는 데이터를 일정 시간 동안만 유지하도록 설정할 수 있어, 세션 관리나 캐시 데이터의 만료를 비교적 쉽게 관리할 수 있습니다. 이는 실시간 데이터가 빈번히 갱신될 때 유용합니다.
다소 얕은 지식으로 조사한 내용이기에 혹시나 제가 잘못 조사한 부분이 있다면 댓글로 알려주세요!
캐싱 전략은 성능 최적화에 큰 효과가 있지만, 이를 위한 비용을 이해하고 관리하는 것이 필수적입니다. 캐싱이 불러오는 저장 공간, 관리 복잡성, 무효화 비용 등의 문제는 단순히 캐시를 사용한다고 해서 해결되지 않습니다. 따라서 다음과 같은 비용 요소를 신중히 고려해야 합니다.
캐싱의 효과를 극대화하면서도 비용을 줄일 수 있는 최적의 전략을 찾는 것이 중요합니다.
프론트엔드 개발자들이 캐싱을 무작정 사용하기 보다는 실제 캐싱의 성능과 비용을 균형 있게 고려하고 사용할 수 있는데에 도움이 되었으면 합니다!