매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요? 당연히 후자일 것입니다. 웹 개발에서 캐싱(Caching)이란 바로 이 ‘책상 위 책’과 같은 개념입니다. 한번 가져온 데이터를 나중에 또 사용하기 위해 가까운 곳에 임시로 저장해두는 기술을 말합니다.
캐싱을 잘 활용하면, 사용자는 더 빠른 로딩 속도를 경험하게 되고, 서버는 불필요한 반복 요청을 받지 않아 부담이 줄어듭니다. 그야말로 모두에게 이로운 기술이죠.
이번 글에서는 캐싱의 핵심 개념들을 알아보고, React 환경에서 재사용 가능한 커스텀 훅(useCache
)을 만들어 똑똑한 데이터 그리드를 구현하는 방법을 단계별로 완벽하게 파헤쳐 보겠습니다.
📚 주요 개념 설명
캐싱 전략을 세우기 위한 네 가지 핵심 도구를 소개합니다.
- 메모리 캐시 (Memory Cache): “포스트잇 메모”와 같습니다. 앱이 실행되는 동안에만 데이터를 잠깐 기억해두는 임시 저장소입니다. 페이지를 새로고침하거나 탭을 닫으면 기억했던 내용이 모두 사라지지만, 접근 속도가 매우 빠르다는 장점이 있습니다.
- 영구 캐시 (Persistent Cache): “나만의 노트 필기”와 같습니다. 브라우저의
localStorage
나IndexedDB
같은 곳에 데이터를 저장하여, 브라우저를 껐다 켜도 내용이 그대로 남아있는 영구적인 저장소입니다. - 캐시 무효화 (Cache Invalidation): “오래된 정보 버리기”입니다. 책상 위에 꺼내둔 책의 개정판이 나왔다면, 헌 책은 버리고 새 책으로 바꿔야겠죠? 캐시된 데이터가 더 이상 최신이 아닐 때, 이 캐시를 삭제하고 새로운 데이터로 교체하는 매우 중요한 과정입니다.
- TTL (Time To Live): “우유의 유통기한”과 같습니다. 캐시된 데이터가 언제까지 유효한지 ‘생존 시간’을 정해두는 것입니다. 이 유통기한이 지나면 캐시는 자동으로 무효화(삭제)됩니다.
💡 실제 사용 예제: 캐시를 활용한 초고속 데이터 그리드
이제 이 개념들을 바탕으로, 한번 불러온 사용자 목록은 캐시에 저장해두고, 다음번에는 API 호출 없이 캐시에서 바로 데이터를 읽어오는 똑똑한 데이터 그리드를 만들어 보겠습니다.
1단계: 만능 캐시 관리자, useCache
훅 만들기
먼저, 캐싱과 관련된 모든 복잡한 로직(저장, 조회, 유통기한 확인 등)을 처리하는 재사용 가능한 커스텀 훅을 만듭니다. 이 훅 하나로 우리는 메모리 캐시와 영구 캐시를 모두 다룰 수 있게 됩니다.
// hooks/useCache.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
// ... (CacheOptions 인터페이스, memoryCache Map 정의는 동일) ...
// 제네릭 타입 T를 사용하는 범용 캐시 훅
export function useCache<T>({
key, // "이 캐시의 이름표는 뭐야?"
ttl = 300, // "유통기한은 며칠이야?" (초 단위)
storage = 'memory' // "어디에 보관할까? (포스트잇 or 노트)"
}: CacheOptions) {
const [data, setData] = useState<T | null>(null);
// 캐시에 데이터를 저장하는 함수
const setCache = useCallback((value: T) => {
const cacheEntry = { data: value, timestamp: Date.now() };
if (storage === 'memory') {
memoryCache.set(key, cacheEntry);
} else {
localStorage.setItem(key, JSON.stringify(cacheEntry));
}
setData(value);
}, [key, storage]);
// 캐시에서 데이터를 꺼내는 함수 (유통기한 체크 포함!)
const getCache = useCallback((): T | null => {
const rawCache = storage === 'memory' ? memoryCache.get(key) : localStorage.getItem(key);
if (!rawCache) return null;
const cached = typeof rawCache === 'string' ? JSON.parse(rawCache) : rawCache;
// 유통기한(TTL)이 지났는지 확인
const isExpired = (Date.now() - cached.timestamp) / 1000 > ttl;
if (isExpired) {
// 유통기한이 지났으면 캐시를 삭제하고 null 반환
storage === 'memory' ? memoryCache.delete(key) : localStorage.removeItem(key);
return null;
}
return cached.data;
}, [key, storage, ttl]);
// 컴포넌트가 처음 렌더링될 때 캐시에서 데이터를 로드 시도
useEffect(() => {
const cachedData = getCache();
if (cachedData) {
setData(cachedData);
}
}, [getCache]);
return { data, setCache, getCache };
}
2단계: 캐시 관리자를 사용하는 똑똑한 데이터 그리드 만들기
이제 위에서 만든 useCache
훅을 사용하여 데이터 그리드 컴포넌트를 구현합니다. 이 컴포넌트는 캐시된 데이터가 있으면 그것을 먼저 사용하고, 없으면 서버에서 데이터를 가져와 캐시에 저장합니다.
// components/DataGrid/CachedDataGrid.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useCache } from '@/hooks/useCache'; // 우리의 만능 캐시 관리자!
import { ReactTabulator } from 'react-tabulator';
import { Paper, Box, Typography, Button, CircularProgress, Alert } from '@mui/material';
// ... (UserData 인터페이스, columns 설정은 동일) ...
export function CachedDataGrid() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 1. useCache 훅 사용!
// 'users-data'라는 이름으로, 5분(300초) 유통기한을 가진 영구 캐시(localStorage)를 사용
const { data: users, setCache } = useCache<UserData[]>({
key: 'users-data',
ttl: 300,
storage: 'local'
});
// 2. 서버에서 데이터를 가져오는 함수
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('데이터 로드 실패');
const data = await response.json();
// === 핵심: 받아온 데이터를 캐시에 저장! ===
setCache(data);
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 오류');
} finally {
setLoading(false);
}
}, [setCache]);
// 3. 컴포넌트가 처음 뜰 때, 캐시된 데이터(users)가 없으면 loadData() 호출
useEffect(() => {
if (!users) {
loadData();
}
}, [users, loadData]);
return (
<Paper sx={{ p: 3 }}>
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6">캐시된 사용자 목록</Typography>
{/* '새로고침' 버튼은 캐시 유무와 상관없이 항상 최신 데이터를 강제로 불러옴 */}
<Button variant="outlined" onClick={loadData} disabled={loading}>
새로고침
</Button>
</Box>
{/* 로딩 중이거나 에러가 있을 때 UI 처리 */}
{loading && <CircularProgress />}
{error && <Alert severity="error">{error}</Alert>}
{/* users 데이터가 있을 때만 그리드를 보여줌 */}
{users && (
<ReactTabulator data={users} columns={columns} options={{ /* ... */ }}/>
)}
</Paper>
);
}
💾 캐시 관리 예제 설명
이 예제를 통해 우리는 무엇을 배울 수 있을까요?
- 목적과 기능: 자주 바뀌지 않는 사용자 목록 데이터를 한번 받아온 뒤 캐시에 저장하여, 불필요한 API 호출을 최소화합니다. 이는 사용자 경험(더 빠른 로딩)을 개선하고 서버 부하를 줄이는 두 마리 토끼를 모두 잡는 효과가 있습니다.
- 캐시 구현 방식의 선택:
- 메모리 캐시: 탭 내에서 페이지를 이동할 때처럼, 잠깐 동안만 데이터를 기억해두고 싶을 때 유용합니다.
- 로컬스토리지 캐시: 사용자가 브라우저를 껐다 켜도 데이터를 유지하고 싶을 때 사용합니다. 우리 예제처럼 사용자 목록은 로컬스토리지에 저장하는 것이 더 나은 사용자 경험을 제공할 수 있습니다.
- TTL, 캐시의 생명줄: 유통기한(TTL) 설정은 매우 중요합니다. 너무 길면 사용자가 오래된 데이터를 보게 되고, 너무 짧으면 캐싱의 효과가 떨어집니다. 데이터의 성격에 맞게 적절한 TTL을 설정하는 것이 캐싱 전략의 핵심입니다.
- 강제 새로고침 기능 제공: 아무리 캐싱이 좋아도, 사용자가 원할 때는 언제든지 최신 데이터를 볼 수 있도록 ‘새로고침’과 같은 수동 업데이트 기능을 제공하는 것이 좋습니다. 이는 사용자에게 제어권을 주어 신뢰를 높입니다.