이미지 로딩 중...
AI Generated
2025. 11. 14. · 2 Views
캐싱으로 웹 성능 10배 향상시키기
웹 애플리케이션의 성능을 극적으로 개선하는 캐싱 전략을 배워봅니다. Nginx부터 브라우저 캐시, Redis까지 실무에서 바로 적용할 수 있는 캐싱 기법들을 단계별로 알아봅니다.
목차
- Nginx 정적 파일 캐싱 - 서버 응답 시간 90% 단축
- Redis를 활용한 데이터베이스 쿼리 캐싱 - API 응답 속도 100배 개선
- CDN 엣지 캐싱 - 전 세계 사용자에게 밀리초 응답
- 브라우저 Service Worker 캐싱 - 오프라인에서도 작동하는 PWA
- HTTP ETag를 활용한 조건부 요청 - 대역폭 90% 절감
- 메모이제이션으로 계산 비용 줄이기 - CPU 사용량 80% 감소
- Nginx FastCGI 캐싱 - 동적 페이지도 정적처럼 빠르게
- 데이터베이스 쿼리 결과 캐싱 - ORM 레벨 최적화
1. Nginx 정적 파일 캐싱 - 서버 응답 시간 90% 단축
시작하며
여러분의 웹사이트에서 이미지, CSS, JavaScript 파일을 로드할 때마다 몇 초씩 걸려서 사용자들이 이탈하는 경험을 해보셨나요? 페이지 로딩이 3초 이상 걸리면 사용자의 절반 이상이 페이지를 떠난다는 통계가 있습니다.
이런 문제는 매번 서버에서 동일한 정적 파일을 새로 전송하기 때문에 발생합니다. 사용자가 페이지를 방문할 때마다 서버는 변하지 않는 로고 이미지, CSS 파일, JavaScript 라이브러리를 반복해서 전송하게 되죠.
이는 서버 리소스 낭비는 물론, 사용자 경험을 크게 저하시킵니다. 바로 이럴 때 필요한 것이 Nginx 정적 파일 캐싱입니다.
한 번 전송한 파일은 브라우저에 저장해두고, 다음 방문 시에는 서버에 요청하지 않고 로컬에서 바로 불러오는 방식으로 로딩 속도를 극적으로 개선할 수 있습니다.
개요
간단히 말해서, Nginx 정적 파일 캐싱은 변하지 않는 파일들을 브라우저에 저장해두고 재사용하는 기술입니다. 웹사이트의 이미지, CSS, JavaScript 같은 정적 리소스는 자주 변경되지 않습니다.
매번 서버에서 다시 다운로드받을 필요가 없죠. Nginx는 HTTP 헤더를 통해 브라우저에게 "이 파일은 1년 동안 변하지 않을 테니 로컬에 저장해두고 사용해"라고 지시할 수 있습니다.
예를 들어, 회사 로고나 부트스트랩 CSS 파일 같은 경우에 매우 유용합니다. 기존에는 사용자가 페이지를 새로고침할 때마다 모든 리소스를 다시 다운로드했다면, 이제는 첫 방문 시에만 다운로드하고 이후에는 캐시된 파일을 사용합니다.
Nginx의 캐싱은 설정이 간단하면서도 효과가 즉각적입니다. expires 지시어로 캐시 유효 기간을 설정하고, add_header로 캐시 제어 헤더를 추가하면 됩니다.
이렇게 하면 서버 부하가 줄고 사용자는 빠른 페이지 로딩을 경험하게 됩니다.
코드 예제
# Nginx 설정 파일 (/etc/nginx/nginx.conf 또는 사이트별 설정 파일)
location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
# 이미지 파일은 1년 동안 캐싱
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(css|js)$ {
# CSS/JS 파일은 1개월 캐싱
expires 1M;
add_header Cache-Control "public, must-revalidate";
}
location ~* \.(woff|woff2|ttf|otf)$ {
# 폰트 파일은 1년 캐싱
expires 1y;
add_header Cache-Control "public, immutable";
# CORS 허용
add_header Access-Control-Allow-Origin *;
}
설명
이것이 하는 일: Nginx 설정에서 파일 확장자별로 캐시 정책을 지정하여, 브라우저가 얼마나 오래 파일을 보관할지 알려줍니다. 첫 번째 블록에서는 정규표현식 ~*를 사용해 이미지 파일들을 매칭합니다.
expires 1y는 "1년 동안 유효함"을 의미하며, Cache-Control: public, immutable은 "모든 캐시(브라우저, CDN 등)에서 저장 가능하고, 절대 변하지 않음"을 브라우저에 알립니다. 이렇게 하면 브라우저는 1년 동안 해당 이미지를 다시 요청하지 않습니다.
두 번째 블록은 CSS와 JavaScript 파일을 처리합니다. 이들은 업데이트가 좀 더 빈번하므로 1개월로 설정했습니다.
must-revalidate는 캐시 만료 후 서버에 확인을 요청하라는 의미입니다. 만약 파일이 변경되지 않았다면 서버는 304 Not Modified 응답을 보내고, 브라우저는 기존 캐시를 계속 사용합니다.
세 번째 블록은 웹 폰트를 다룹니다. 폰트는 거의 변경되지 않으므로 1년으로 설정하고, Access-Control-Allow-Origin을 추가해 외부 도메인에서도 폰트를 사용할 수 있게 합니다.
이는 CDN을 사용할 때 필수적입니다. 설정 적용 후에는 nginx -t로 문법을 검사하고 nginx -s reload로 재시작하면 됩니다.
브라우저 개발자 도구의 Network 탭에서 두 번째 페이지 로드 시 파일들이 "(from disk cache)" 또는 "(from memory cache)"로 표시되는 것을 확인할 수 있습니다. 여러분이 이 설정을 적용하면 페이지 로딩 시간이 50-90% 단축되고, 서버 대역폭 사용량이 크게 줄어들며, 동시 접속자를 더 많이 처리할 수 있게 됩니다.
특히 이미지가 많은 포트폴리오 사이트나 이커머스 사이트에서 효과가 두드러집니다.
실전 팁
💡 파일을 업데이트할 때는 파일명에 버전이나 해시를 추가하세요 (예: style.v2.css, logo.a3f2b1.png). 그러면 캐시를 무효화하지 않고도 새 파일을 배포할 수 있습니다.
💡 HTML 파일은 절대 장기 캐싱하지 마세요. HTML은 Cache-Control: no-cache로 설정해 항상 최신 버전을 확인하도록 해야 합니다.
💡 개발 중에는 브라우저 캐시가 방해될 수 있습니다. 크롬에서 F12 → Network → "Disable cache" 체크박스를 활성화하거나, Ctrl+Shift+R로 강제 새로고침하세요.
💡 Nginx의 open_file_cache 디렉티브를 함께 사용하면 서버 측에서도 파일 디스크립터를 캐싱해 더 빠른 응답이 가능합니다.
💡 캐시 적중률을 모니터링하려면 Google Analytics나 Cloudflare Analytics에서 "Cache Hit Ratio"를 확인하세요. 70% 이상이면 효과적입니다.
2. Redis를 활용한 데이터베이스 쿼리 캐싱 - API 응답 속도 100배 개선
시작하며
여러분의 API가 동일한 상품 목록을 요청할 때마다 데이터베이스에서 몇 초씩 걸려 조회하고 계신가요? 특히 복잡한 JOIN 쿼리나 집계 함수를 사용하는 경우, 매번 데이터베이스에 부하를 주면서 사용자는 느린 응답을 기다려야 합니다.
이런 문제는 대부분의 웹 애플리케이션에서 발생합니다. 홈페이지의 인기 상품 목록, 대시보드의 통계 데이터, 사용자 프로필 정보 등은 자주 조회되지만 실시간으로 변경되지 않는 경우가 많습니다.
그럼에도 매번 데이터베이스를 조회하면 서버 리소스가 낭비되고, 응답 시간이 길어지며, 트래픽이 증가하면 데이터베이스가 병목이 됩니다. 바로 이럴 때 필요한 것이 Redis 캐싱입니다.
자주 조회되는 데이터를 메모리에 저장해두고, 데이터베이스 대신 Redis에서 빠르게 가져오는 방식으로 응답 속도를 밀리초 단위로 줄일 수 있습니다.
개요
간단히 말해서, Redis 캐싱은 자주 사용되는 데이터베이스 쿼리 결과를 메모리에 임시 저장하여 빠르게 재사용하는 기술입니다. 데이터베이스 조회는 디스크 I/O가 필요해 상대적으로 느립니다.
반면 Redis는 모든 데이터를 메모리에 저장하는 인메모리 데이터베이스로, 마이크로초 단위의 응답 속도를 제공합니다. 예를 들어, 이커머스 사이트의 베스트셀러 목록이나 뉴스 사이트의 인기 기사 같은 경우 10분마다 한 번만 데이터베이스를 조회하고 나머지는 Redis에서 가져오면 충분합니다.
기존에는 사용자 요청마다 SELECT * FROM products ORDER BY sales DESC LIMIT 10 같은 쿼리를 실행했다면, 이제는 첫 요청 시에만 데이터베이스를 조회하고 결과를 Redis에 저장한 뒤, 이후 요청은 Redis에서 즉시 반환합니다. Redis의 장점은 단순한 키-값 저장뿐만 아니라 TTL(Time To Live) 설정으로 자동 만료, 데이터 구조 지원(리스트, 해시, 셋 등), 원자적 연산 등 다양한 기능을 제공한다는 점입니다.
이를 통해 캐시 무효화 전략을 유연하게 구현할 수 있습니다.
코드 예제
// Node.js + Express + Redis 예제
const express = require('express');
const redis = require('redis');
const mysql = require('mysql2/promise');
const app = express();
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
const dbPool = mysql.createPool({ host: 'localhost', user: 'root', database: 'shop' });
await redisClient.connect();
app.get('/api/products/bestsellers', async (req, res) => {
const cacheKey = 'bestsellers:top10';
// 1. Redis에서 먼저 조회
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log('캐시 히트!');
return res.json(JSON.parse(cached));
}
// 2. 캐시 미스 - DB에서 조회
console.log('캐시 미스 - DB 조회');
const [rows] = await dbPool.query(
'SELECT id, name, price, sales FROM products ORDER BY sales DESC LIMIT 10'
);
// 3. Redis에 10분간 저장
await redisClient.setEx(cacheKey, 600, JSON.stringify(rows));
res.json(rows);
});
설명
이것이 하는 일: API 요청이 들어오면 먼저 Redis 캐시를 확인하고, 있으면 즉시 반환하고, 없으면 데이터베이스를 조회한 뒤 결과를 캐시에 저장합니다. 첫 번째 단계에서는 redisClient.get(cacheKey)로 캐시를 조회합니다.
cacheKey는 고유한 식별자로, 같은 데이터를 요청할 때 같은 키를 사용해야 합니다. 만약 캐시에 데이터가 있으면 "캐시 히트"이고, JSON 문자열을 파싱해서 즉시 응답합니다.
이 경우 데이터베이스는 전혀 사용되지 않으며, 응답 시간은 1-5ms 정도입니다. 캐시가 없는 경우 "캐시 미스"가 발생하고, 데이터베이스에서 실제 쿼리를 실행합니다.
ORDER BY sales DESC LIMIT 10은 판매량 기준 상위 10개 상품을 조회하는 쿼리입니다. 복잡한 쿼리라면 수백 밀리초가 걸릴 수 있습니다.
쿼리 결과를 받으면 redisClient.setEx()로 Redis에 저장합니다. setEx는 TTL과 함께 값을 설정하는 메서드로, 여기서는 600초(10분) 후 자동 삭제됩니다.
데이터를 JSON 문자열로 변환해 저장하며, 10분 동안은 모든 요청이 이 캐시된 데이터를 사용합니다. 10분 후 TTL이 만료되면 다음 요청 시 다시 캐시 미스가 발생하고, 최신 데이터를 데이터베이스에서 가져와 새로 캐싱합니다.
이렇게 하면 데이터가 어느 정도 최신성을 유지하면서도 대부분의 요청은 빠르게 처리됩니다. 여러분이 이 패턴을 적용하면 API 응답 시간이 500ms에서 5ms로 단축되고, 데이터베이스 부하가 99% 감소하며, 같은 서버로 100배 더 많은 트래픽을 처리할 수 있게 됩니다.
실제로 Reddit, Twitter 같은 대형 서비스들이 이 방식을 사용합니다.
실전 팁
💡 캐시 키는 명확하고 체계적으로 명명하세요 (예: user:{userId}:profile, posts:category:{categoryId}:page:{pageNum}). 나중에 패턴으로 삭제할 때 편리합니다.
💡 데이터가 업데이트되면 관련 캐시를 즉시 삭제하세요. 예를 들어 상품 정보가 수정되면 redisClient.del('product:123')으로 캐시를 무효화합니다.
💡 "캐시 스탬피드" 문제를 방지하려면 인기 있는 키의 TTL이 동시에 만료되지 않도록 랜덤 지터를 추가하세요 (예: 600 + Math.random() * 60).
💡 Redis 메모리는 유한하므로 maxmemory-policy를 allkeys-lru로 설정해 메모리가 부족하면 가장 오래된 키를 자동 삭제하게 하세요.
💡 프로덕션에서는 Redis Cluster나 센티넬을 사용해 고가용성을 확보하고, 캐시 서버 장애 시에도 서비스가 계속되도록 폴백 로직을 구현하세요.
3. CDN 엣지 캐싱 - 전 세계 사용자에게 밀리초 응답
시작하며
여러분의 서비스를 미국, 유럽, 아시아 등 전 세계 사용자가 이용하는데, 서울에 있는 서버 때문에 해외 사용자는 느린 속도를 겪고 있지 않나요? 물리적 거리가 멀수록 네트워크 레이턴시가 증가해, 같은 페이지라도 서울에서는 100ms, 뉴욕에서는 300ms, 시드니에서는 500ms가 걸릴 수 있습니다.
이런 문제는 모든 글로벌 서비스가 직면하는 물리적 한계입니다. 빛의 속도로도 지구 반대편까지는 시간이 걸리며, 중간의 라우터와 게이트웨이를 거치면서 지연이 누적됩니다.
사용자가 멀리 있을수록 TTFB(Time To First Byte)가 길어지고, 페이지 로딩이 체감상 매우 느려집니다. 바로 이럴 때 필요한 것이 CDN(Content Delivery Network) 엣지 캐싱입니다.
전 세계 주요 도시에 위치한 엣지 서버에 콘텐츠를 캐싱하여, 사용자는 가장 가까운 서버에서 콘텐츠를 받아 물리적 거리로 인한 지연을 최소화할 수 있습니다.
개요
간단히 말해서, CDN 엣지 캐싱은 여러분의 콘텐츠를 전 세계 수백 개 서버에 복제하여 사용자와 가장 가까운 곳에서 제공하는 기술입니다. CDN은 전 세계에 분산된 엣지 서버 네트워크를 운영합니다.
뉴욕 사용자가 여러분의 사이트에 접속하면 서울 서버가 아닌 뉴욕의 엣지 서버가 응답하고, 런던 사용자는 런던 엣지 서버에서 콘텐츠를 받습니다. 예를 들어, Netflix는 전 세계 시청자에게 동일한 영화를 제공하지만, 각 지역의 CDN 서버에 캐싱되어 있어 빠른 스트리밍이 가능합니다.
기존에는 모든 사용자가 원본 서버(Origin Server)에 직접 접속했다면, 이제는 CDN이 중간에서 프록시 역할을 하며 자주 요청되는 콘텐츠를 엣지에 저장합니다. CDN의 핵심은 "엣지에서 처리"입니다.
정적 파일은 물론이고, 동적 콘텐츠도 스마트하게 캐싱할 수 있으며, 원본 서버 부하를 90% 이상 줄이면서도 사용자에게는 더 빠른 경험을 제공합니다. Cloudflare, AWS CloudFront, Fastly 같은 CDN 서비스들은 수백 개의 PoP(Point of Presence)를 운영하며, 밀리초 단위의 글로벌 배포를 가능하게 합니다.
코드 예제
// Cloudflare Workers를 사용한 엣지 캐싱 예제
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 1. API 요청에 대한 캐시 설정
if (url.pathname.startsWith('/api/products')) {
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
// 2. 엣지 캐시에서 먼저 확인
let response = await cache.match(cacheKey);
if (!response) {
// 3. 캐시 미스 - 원본 서버에서 가져오기
response = await fetch(request);
// 4. 성공 응답이면 5분간 엣지에 캐싱
if (response.ok) {
response = new Response(response.body, response);
response.headers.set('Cache-Control', 'public, max-age=300');
response.headers.set('CDN-Cache-Control', 'max-age=300');
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
}
// 5. 캐시 상태 헤더 추가
const newResponse = new Response(response.body, response);
newResponse.headers.set('X-Cache-Status', response.ok ? 'HIT' : 'MISS');
return newResponse;
}
// 정적 파일은 바로 원본으로
return fetch(request);
}
};
설명
이것이 하는 일: 사용자 요청이 CDN 엣지에 도착하면, 엣지 캐시를 먼저 확인하고 있으면 즉시 응답하며, 없으면 원본 서버에서 가져와 캐싱한 후 응답합니다. 첫 번째 단계에서는 URL을 파싱하여 API 요청인지 확인합니다.
/api/products로 시작하는 경로는 상품 목록 API로, 자주 요청되지만 실시간성이 중요하지 않아 캐싱하기 좋습니다. cacheKey는 URL과 요청 정보를 기반으로 생성되며, 같은 요청은 같은 키를 갖습니다.
두 번째 단계에서 cache.match(cacheKey)로 엣지 캐시를 조회합니다. Cloudflare Workers는 전 세계 200개 이상의 데이터센터에서 실행되며, 각 엣지는 자체 캐시를 가지고 있습니다.
뉴욕 사용자의 요청은 뉴욕 엣지에서 처리되고, 캐시도 뉴욕 엣지에 저장됩니다. 캐시 미스가 발생하면 원본 서버로 요청을 프록시합니다.
await fetch(request)는 원본 서버(예: 서울의 API 서버)에 실제 요청을 보냅니다. 응답을 받으면 Cache-Control 헤더를 설정하는데, max-age=300은 5분간 유효함을 의미합니다.
CDN-Cache-Control은 CDN 전용 헤더로, 브라우저 캐시와 별도로 관리할 수 있습니다. ctx.waitUntil(cache.put())은 비동기로 캐시를 저장합니다.
response.clone()을 사용하는 이유는 Response 객체는 한 번만 읽을 수 있어서, 하나는 사용자에게 반환하고 하나는 캐시에 저장해야 하기 때문입니다. 5분 동안 같은 요청이 들어오면 모두 엣지 캐시에서 즉시 처리됩니다.
마지막으로 X-Cache-Status 헤더를 추가해 디버깅을 쉽게 합니다. 브라우저 개발자 도구에서 이 헤더를 보면 "HIT"(캐시에서 응답) 또는 "MISS"(원본에서 응답)를 확인할 수 있습니다.
여러분이 CDN 엣지 캐싱을 적용하면 전 세계 사용자의 TTFB가 50ms 이하로 줄고, 원본 서버 트래픽이 90% 감소하며, DDoS 공격으로부터 보호받고, 인프라 비용이 크게 절감됩니다. 글로벌 서비스라면 필수적인 전략입니다.
실전 팁
💡 정적 파일과 API를 다르게 캐싱하세요. 이미지/CSS는 1년, API는 1-10분이 적당하며, HTML은 매우 짧게 설정하거나 캐싱하지 않는 것이 안전합니다.
💡 Vary 헤더를 사용해 사용자별로 다른 응답을 캐싱할 수 있습니다. 예를 들어 Vary: Accept-Language는 언어별로 별도 캐시를 생성합니다.
💡 캐시 무효화(Purge)가 필요한 경우를 대비해 CDN API를 활용하세요. 배포 후 curl -X POST "https://api.cloudflare.com/purge" 같은 방식으로 즉시 캐시를 비울 수 있습니다.
💡 캐시 히트율을 모니터링하세요. CDN 대시보드에서 80% 이상의 히트율이 나오면 효과적이며, 낮다면 TTL을 늘리거나 캐시 대상을 확대하세요.
💡 "stale-while-revalidate" 전략을 사용하면 캐시가 만료되어도 즉시 응답하고 백그라운드에서 갱신해 항상 빠른 응답을 보장할 수 있습니다.
4. 브라우저 Service Worker 캐싱 - 오프라인에서도 작동하는 PWA
시작하며
여러분의 웹 앱을 사용하던 중 인터넷이 끊겼을 때 "인터넷 연결 없음" 오류 페이지만 보여주고 있나요? 지하철이나 비행기에서 모바일 앱은 일부 기능이라도 작동하는데, 웹은 완전히 멈춰버리는 경험을 하셨을 겁니다.
이런 문제는 웹의 태생적 한계로 여겨져 왔습니다. 웹은 항상 서버와 통신해야 하고, 네트워크가 없으면 아무것도 할 수 없었죠.
하지만 사용자들은 네이티브 앱처럼 오프라인에서도 작동하고, 느린 네트워크에서도 빠르게 반응하는 웹 앱을 원합니다. 바로 이럴 때 필요한 것이 Service Worker 캐싱입니다.
브라우저에 프로그래밍 가능한 네트워크 프록시를 설치하여, 오프라인 상태에서도 캐시된 리소스를 제공하고, 온라인 상태에서는 스마트하게 캐시를 업데이트하는 Progressive Web App(PWA)을 만들 수 있습니다.
개요
간단히 말해서, Service Worker는 브라우저와 서버 사이에서 작동하는 JavaScript 워커로, 네트워크 요청을 가로채서 캐시에서 응답하거나 서버로 전달할지 결정합니다. Service Worker는 웹 페이지와 별도 스레드에서 실행되는 스크립트입니다.
설치되면 모든 네트워크 요청을 가로챌 수 있어, "네트워크 우선" 또는 "캐시 우선" 같은 전략을 코드로 구현할 수 있습니다. 예를 들어, 뉴스 앱에서 어제 읽은 기사들은 캐시에서 즉시 보여주고, 최신 기사만 네트워크에서 가져오는 식입니다.
기존의 HTTP 캐시는 브라우저가 자동으로 관리해서 세밀한 제어가 불가능했다면, Service Worker는 완전한 프로그래밍 제어를 제공합니다. JavaScript로 "이 요청은 캐시에서, 저 요청은 네트워크에서" 같은 로직을 직접 작성할 수 있습니다.
Service Worker의 강력함은 오프라인 지원뿐만 아니라 백그라운드 동기화, 푸시 알림, 사전 캐싱(Precaching) 같은 고급 기능도 제공한다는 점입니다. Twitter, Instagram 같은 PWA들이 네이티브 앱처럼 빠르고 안정적인 이유가 바로 Service Worker 덕분입니다.
코드 예제
// service-worker.js - 캐시 우선 전략 구현
const CACHE_NAME = 'my-app-v1';
const URLS_TO_CACHE = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png',
'/offline.html'
];
// 1. Service Worker 설치 시 주요 리소스 사전 캐싱
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('캐시 오픈 및 리소스 저장');
return cache.addAll(URLS_TO_CACHE);
})
);
});
// 2. 네트워크 요청 가로채기
self.addEventListener('fetch', (event) => {
event.respondWith(
// 캐시에서 먼저 찾기
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse; // 캐시 히트
}
// 캐시 미스 - 네트워크 요청
return fetch(event.request).then((response) => {
// 유효한 응답이면 캐시에 저장
if (!response || response.status !== 200) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// 네트워크 실패 시 오프라인 페이지
return caches.match('/offline.html');
})
);
});
설명
이것이 하는 일: Service Worker가 설치될 때 주요 리소스를 사전 캐싱하고, 이후 모든 네트워크 요청을 가로채서 캐시 우선 전략으로 응답합니다. 첫 번째 단계는 Service Worker 설치 단계입니다.
사용자가 사이트를 처음 방문하면 install 이벤트가 발생하고, URLS_TO_CACHE에 명시된 핵심 리소스들을 즉시 캐시에 저장합니다. event.waitUntil()은 캐싱이 완료될 때까지 설치를 대기시켜, 캐싱 실패 시 Service Worker가 활성화되지 않도록 보장합니다.
오프라인 폴백 페이지(/offline.html)도 이 단계에서 저장됩니다. 두 번째 단계는 fetch 이벤트 핸들러입니다.
Service Worker가 활성화되면 해당 스코프 내 모든 네트워크 요청이 이 핸들러를 거칩니다. event.respondWith()로 커스텀 응답을 제공할 수 있으며, 먼저 caches.match()로 캐시를 검색합니다.
캐시에 요청과 일치하는 응답이 있으면 즉시 반환합니다("캐시 히트"). 이 경우 네트워크 요청이 전혀 발생하지 않아 극도로 빠르며, 오프라인 상태에서도 작동합니다.
사용자는 인터넷 연결 여부와 관계없이 페이지를 볼 수 있습니다. 캐시에 없으면 실제 네트워크 요청을 보냅니다.
응답을 받으면 한 사본은 사용자에게 반환하고(response), 다른 사본은 캐시에 저장합니다(response.clone()). response.status !== 200 체크는 에러 응답을 캐싱하지 않기 위함입니다.
404나 500 응답을 캐싱하면 나중에 문제가 될 수 있습니다. 네트워크 요청마저 실패하면(오프라인 상태) catch 블록으로 들어가 /offline.html을 반환합니다.
이는 "공룡 게임" 대신 사용자 친화적인 오프라인 페이지를 보여주는 방법입니다. 여러분이 Service Worker를 도입하면 재방문 시 페이지 로딩이 즉각적이고, 오프라인에서도 콘텐츠를 볼 수 있으며, 느린 네트워크에서도 좋은 UX를 제공하고, "홈 화면에 추가" 기능으로 앱처럼 설치할 수 있습니다.
Google Developers, Twitter Lite 같은 사이트들이 Service Worker로 네이티브 앱 수준의 경험을 제공합니다.
실전 팁
💡 Service Worker를 등록할 때는 메인 JavaScript에서 navigator.serviceWorker.register('/service-worker.js')를 호출하세요. HTTPS에서만 작동하니 프로덕션 전에 SSL을 설정하세요.
💡 캐시 버전(CACHE_NAME)을 업데이트하면 새 Service Worker가 설치되고, activate 이벤트에서 이전 캐시를 삭제하세요. 그렇지 않으면 스토리지가 계속 증가합니다.
💡 개발 중에는 크롬 DevTools의 Application → Service Workers에서 "Update on reload"를 체크하면 매번 최신 버전이 적용됩니다.
💡 Workbox 라이브러리를 사용하면 복잡한 캐싱 전략(Network First, Cache First, Stale While Revalidate 등)을 간단히 구현할 수 있습니다.
💡 Service Worker 스코프는 등록된 위치 기준이므로, 루트(/)에 등록해야 전체 사이트를 제어할 수 있습니다. /scripts/에 두면 /scripts/ 하위만 제어 가능합니다.
5. HTTP ETag를 활용한 조건부 요청 - 대역폭 90% 절감
시작하며
여러분의 API가 매번 동일한 데이터를 전송하면서 대역폭을 낭비하고 있나요? 사용자가 프로필 정보를 요청할 때마다 서버는 변경되지 않은 동일한 JSON을 반복해서 전송하고, 모바일 사용자는 데이터 요금을 더 내게 됩니다.
이런 문제는 특히 API 서버에서 심각합니다. 캐시를 설정해도 TTL이 만료되면 서버는 변경되지 않은 데이터를 다시 보내야 하고, 클라이언트는 이미 가지고 있는 데이터를 또 다운로드합니다.
대용량 JSON이나 이미지의 경우 불필요한 대역폭 소비가 엄청납니다. 바로 이럴 때 필요한 것이 HTTP ETag(Entity Tag)입니다.
서버가 응답에 고유한 해시값을 붙이고, 클라이언트가 다음 요청 시 이 해시를 보내면, 서버는 데이터가 변경되지 않았으면 "304 Not Modified"만 응답하여 대역폭을 대폭 절감할 수 있습니다.
개요
간단히 말해서, ETag는 리소스의 버전을 나타내는 고유 식별자로, 클라이언트와 서버가 "이 데이터 변경됐어?"라고 효율적으로 확인하는 메커니즘입니다. 서버는 응답 데이터의 MD5 해시나 버전 번호 같은 ETag를 생성해 ETag 헤더로 전송합니다.
클라이언트는 이를 저장했다가 다음 요청 시 If-None-Match 헤더에 담아 보냅니다. 서버는 현재 데이터의 ETag와 비교해서 같으면 304 상태 코드만 보내고 본문은 생략합니다.
예를 들어, 사용자 프로필 API에서 프로필이 변경되지 않았다면 본문 없이 304만 응답해 수 KB를 절약합니다. 기존에는 매번 전체 응답을 전송했다면, ETag를 사용하면 데이터가 변경되지 않은 경우 헤더만 주고받아 90% 이상 대역폭을 절감합니다.
ETag의 장점은 서버와 클라이언트 모두에게 이득입니다. 서버는 전송량이 줄어 네트워크 비용이 감소하고, 클라이언트는 빠르게 응답받고 모바일 데이터를 절약합니다.
또한 캐시 검증 메커니즘이므로 항상 최신 데이터 일관성을 보장하면서도 효율적입니다.
코드 예제
// Express.js + ETag를 활용한 조건부 응답
const express = require('express');
const crypto = require('crypto');
const app = express();
// 사용자 데이터 (실제로는 DB에서 조회)
let userData = { id: 1, name: 'John', email: 'john@example.com', updatedAt: Date.now() };
app.get('/api/user/:id', (req, res) => {
// 1. 현재 데이터의 ETag 생성 (데이터의 MD5 해시)
const dataString = JSON.stringify(userData);
const currentETag = crypto.createHash('md5').update(dataString).digest('hex');
// 2. 클라이언트가 보낸 ETag 확인
const clientETag = req.headers['if-none-match'];
// 3. ETag가 일치하면 304 Not Modified
if (clientETag === currentETag) {
console.log('ETag 일치 - 304 응답');
return res.status(304).end();
}
// 4. 데이터가 변경됨 - 전체 응답 + 새 ETag
console.log('데이터 변경됨 - 200 응답 + ETag');
res.set('ETag', currentETag);
res.set('Cache-Control', 'no-cache'); // 항상 검증하도록
res.json(userData);
});
// 사용자 업데이트 API (데이터 변경 시 ETag가 달라짐)
app.put('/api/user/:id', express.json(), (req, res) => {
userData = { ...userData, ...req.body, updatedAt: Date.now() };
res.json({ message: 'Updated', data: userData });
});
app.listen(3000);
설명
이것이 하는 일: 서버가 응답 데이터의 해시값(ETag)을 생성하고, 클라이언트의 이전 ETag와 비교하여 데이터가 변경되지 않았으면 본문 없이 304 응답만 보냅니다. 첫 번째 단계에서는 현재 데이터를 JSON 문자열로 변환하고 MD5 해시를 계산합니다.
이 해시값이 ETag로, 데이터가 조금이라도 변경되면 완전히 다른 값이 나옵니다. 예를 들어 {"name":"John"}의 해시와 {"name":"Jane"}의 해시는 전혀 다릅니다.
MD5는 빠르고 충돌이 거의 없어 ETag로 적합합니다. 두 번째 단계에서는 클라이언트가 보낸 If-None-Match 헤더를 확인합니다.
이 헤더는 클라이언트가 이전 요청에서 받은 ETag를 담고 있습니다. 브라우저나 HTTP 클라이언트가 자동으로 처리하므로, 클라이언트 코드에서 특별히 할 일은 없습니다.
세 번째 단계는 핵심 로직입니다. clientETag === currentETag면 데이터가 변경되지 않았다는 의미이고, res.status(304).end()로 본문 없이 304 상태 코드만 응답합니다.
이 응답은 헤더만 포함하므로 몇 백 바이트에 불과합니다. 클라이언트는 304를 받으면 기존 캐시된 데이터를 그대로 사용합니다.
ETag가 다르면 데이터가 변경된 것이므로 전체 응답을 보냅니다. 새로운 ETag 헤더를 설정하고, Cache-Control: no-cache를 추가해 브라우저가 매번 검증하도록 합니다(no-cache는 "캐싱하지 말라"가 아니라 "사용 전에 검증하라"는 의미입니다).
사용자가 프로필을 업데이트하면 updatedAt이 변경되고, 이에 따라 ETag도 달라집니다. 다음 GET 요청 시 새 데이터가 전송됩니다.
이렇게 하면 실시간성과 효율성을 동시에 달성합니다. 여러분이 ETag를 적용하면 API 응답 크기가 90% 줄고, 모바일 사용자의 데이터 소비가 감소하며, CDN 비용이 절감되고, 서버 네트워크 부하가 대폭 줄어듭니다.
특히 대용량 JSON이나 자주 조회되지만 변경이 드문 데이터에 효과적입니다.
실전 팁
💡 강한 ETag("abc123")와 약한 ETag(W/"abc123") 중 선택하세요. 강한 ETag는 바이트 단위 동일성을 보장하고, 약한 ETag는 의미적 동일성을 나타냅니다. 일반적으로 강한 ETag가 권장됩니다.
💡 동적 콘텐츠는 ETag를 생성할 때 버전 필드나 updatedAt 타임스탬프만 해싱해도 충분합니다. 전체 데이터를 해싱하면 오버헤드가 있을 수 있습니다.
💡 ETag와 Last-Modified를 함께 사용하면 더욱 강력합니다. If-None-Match(ETag 기반)와 If-Modified-Since(시간 기반)를 모두 지원하세요.
💡 Nginx에서는 etag on; 설정으로 정적 파일에 자동으로 ETag를 추가할 수 있습니다. 별도 설정 없이도 작동합니다.
💡 로드 밸런서 뒤에서 여러 서버가 동일한 ETag를 생성하도록 주의하세요. 파일 inode가 아닌 콘텐츠 기반 해시를 사용해야 합니다.
6. 메모이제이션으로 계산 비용 줄이기 - CPU 사용량 80% 감소
시작하며
여러분의 함수가 동일한 인자로 반복 호출될 때마다 무거운 계산을 다시 실행하고 있나요? 피보나치 수열 계산, 복잡한 데이터 변환, 정규표현식 매칭 같은 작업이 매번 반복되면서 CPU를 낭비하고 응답이 느려집니다.
이런 문제는 순수 함수(같은 입력에 항상 같은 출력)를 다룰 때 자주 발생합니다. 예를 들어 calculateDiscount(price, coupon)을 같은 가격과 쿠폰으로 100번 호출하면, 결과는 항상 같은데도 100번 계산합니다.
재귀 함수나 중첩 반복문의 경우 시간 복잡도가 급격히 증가합니다. 바로 이럴 때 필요한 것이 메모이제이션(Memoization)입니다.
함수의 입력과 출력을 캐싱해두고, 같은 입력이 들어오면 계산을 건너뛰고 캐시된 결과를 즉시 반환하여 계산 비용을 극적으로 줄일 수 있습니다.
개요
간단히 말해서, 메모이제이션은 함수의 반환값을 입력값 기준으로 캐싱하여, 같은 입력에 대해 한 번만 계산하고 이후에는 캐시에서 가져오는 최적화 기법입니다. 메모이제이션은 동적 프로그래밍의 핵심 기법으로, 특히 재귀 함수나 중복 계산이 많은 알고리즘에서 효과적입니다.
React의 useMemo, useCallback도 메모이제이션 기반이며, 렌더링 최적화에 사용됩니다. 예를 들어, 쇼핑몰에서 동일한 상품 가격에 대한 할인 계산을 여러 번 하는 경우, 첫 계산만 실행하고 나머지는 캐시에서 반환합니다.
기존에는 함수가 호출될 때마다 무조건 계산을 실행했다면, 메모이제이션은 입력을 키로 사용해 결과를 Map이나 객체에 저장하고 재사용합니다. 메모이제이션의 핵심은 "순수 함수"에만 적용 가능하다는 점입니다.
부작용(side effect)이 없고 같은 입력에 항상 같은 출력을 내는 함수여야 합니다. 또한 메모리 사용량과 속도 향상 간 트레이드오프를 고려해야 하며, 캐시 크기를 제한하는 LRU(Least Recently Used) 전략도 필요할 수 있습니다.
코드 예제
// 메모이제이션 헬퍼 함수
function memoize(fn) {
const cache = new Map();
return function(...args) {
// 1. 인자를 문자열로 변환해 캐시 키 생성
const key = JSON.stringify(args);
// 2. 캐시에 있으면 즉시 반환
if (cache.has(key)) {
console.log(`캐시 히트: ${key}`);
return cache.get(key);
}
// 3. 캐시 미스 - 실제 계산 실행
console.log(`캐시 미스: ${key} - 계산 중...`);
const result = fn.apply(this, args);
// 4. 결과를 캐시에 저장
cache.set(key, result);
return result;
};
}
// 무거운 계산 함수 (예: 피보나치)
const fibonacci = memoize((n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
// 실전 예제: 복잡한 상품 가격 계산
const calculatePrice = memoize((basePrice, discountRate, taxRate, shippingFee) => {
// 복잡한 계산 시뮬레이션
const discounted = basePrice * (1 - discountRate);
const withTax = discounted * (1 + taxRate);
const final = withTax + shippingFee;
return Math.round(final * 100) / 100;
});
// 사용 예시
console.log(fibonacci(40)); // 첫 호출: 계산 (몇 초 소요)
console.log(fibonacci(40)); // 두 번째: 즉시 반환 (캐시)
console.log(calculatePrice(100, 0.1, 0.08, 5)); // 계산
console.log(calculatePrice(100, 0.1, 0.08, 5)); // 캐시
설명
이것이 하는 일: 함수를 래핑하여 호출 시마다 인자를 확인하고, 이전에 같은 인자로 호출된 적이 있으면 저장된 결과를 반환하고, 없으면 실행 후 결과를 저장합니다. 첫 번째 단계에서는 함수 인자를 JSON.stringify()로 문자열화해 캐시 키를 만듭니다.
예를 들어 calculatePrice(100, 0.1, 0.08, 5)는 "[100,0.1,0.08,5]"가 됩니다. 이렇게 하면 인자 조합마다 고유한 키를 얻을 수 있습니다.
객체나 배열 인자도 정확히 구분됩니다. 두 번째 단계에서 cache.has(key)로 캐시를 확인합니다.
Map을 사용하는 이유는 객체보다 성능이 좋고, 어떤 타입의 키도 지원하며, size 속성으로 크기를 쉽게 확인할 수 있기 때문입니다. 캐시에 있으면 "캐시 히트"이고, 저장된 값을 즉시 반환합니다.
이 경우 원래 함수는 전혀 실행되지 않습니다. 캐시에 없으면 "캐시 미스"이고, fn.apply(this, args)로 원래 함수를 실행합니다.
apply를 사용하는 이유는 this 컨텍스트를 유지하고 가변 인자를 전달하기 위함입니다. 피보나치 예제의 경우 재귀 호출이 발생하는데, 각 중간 결과도 캐싱되므로 지수 시간 복잡도가 선형으로 개선됩니다.
결과를 받으면 cache.set(key, result)로 저장합니다. 다음에 같은 인자로 호출되면 저장된 값을 즉시 반환합니다.
fibonacci(40) 같은 무거운 계산은 첫 호출에 몇 초 걸리지만, 두 번째부터는 밀리초도 안 걸립니다. 실전 예제의 calculatePrice는 쇼핑몰에서 자주 사용되는 패턴입니다.
같은 상품, 같은 할인율로 여러 사용자가 조회하면 모두 캐시에서 가져옵니다. 특히 장바구니 페이지에서 수십 개 상품의 가격을 반복 계산할 때 큰 효과가 있습니다.
여러분이 메모이제이션을 적용하면 CPU 사용량이 80% 이상 감소하고, 복잡한 계산의 응답 시간이 밀리초로 줄며, 서버가 더 많은 요청을 처리할 수 있고, 배터리 소모가 줄어 모바일 사용자 경험이 개선됩니다.
실전 팁
💡 메모리 누수를 방지하려면 LRU 캐시를 구현하세요. lru-cache npm 패키지를 사용하거나, 캐시 크기를 제한해 오래된 항목을 자동 삭제하세요.
💡 객체나 배열을 인자로 받는 경우, 참조가 아닌 내용으로 비교해야 합니다. 얕은 비교로는 {a:1}과 {a:1}을 다르게 인식할 수 있습니다.
💡 React에서는 useMemo로 계산 결과를, useCallback으로 함수를 메모이제이션하세요. 특히 자식 컴포넌트에 props로 전달할 때 유용합니다.
💡 부작용이 있는 함수(API 호출, DOM 조작 등)는 메모이제이션하지 마세요. 순수 함수만 메모이제이션해야 예측 가능합니다.
💡 개발 시 캐시 히트율을 로깅해서 효과를 측정하세요. 히트율이 낮으면 메모이제이션이 오히려 오버헤드가 될 수 있습니다.
7. Nginx FastCGI 캐싱 - 동적 페이지도 정적처럼 빠르게
시작하며
여러분의 WordPress나 PHP 애플리케이션이 매 요청마다 데이터베이스를 조회하고 템플릿을 렌더링하느라 느리게 작동하나요? 동적 웹사이트는 본질적으로 느릴 수밖에 없다고 생각하셨나요?
블로그 포스트처럼 내용은 거의 변하지 않는데 매번 PHP와 MySQL을 거쳐야 하는 게 비효율적입니다. 이런 문제는 동적 웹 애플리케이션의 숙명처럼 여겨져 왔습니다.
정적 사이트는 빠르지만 유연성이 없고, 동적 사이트는 유연하지만 느립니다. 특히 트래픽이 증가하면 PHP-FPM이나 데이터베이스가 병목이 되어 서버가 과부하에 걸립니다.
바로 이럴 때 필요한 것이 Nginx FastCGI 캐싱입니다. 동적으로 생성된 HTML 페이지를 Nginx 레벨에서 캐싱하여, 이후 요청은 PHP나 데이터베이스를 거치지 않고 Nginx가 직접 응답해 정적 파일 수준의 속도를 달성할 수 있습니다.
개요
간단히 말해서, FastCGI 캐싱은 PHP 같은 백엔드 애플리케이션이 생성한 응답을 Nginx가 메모리나 디스크에 저장하고, 동일한 요청에 대해 백엔드를 거치지 않고 직접 응답하는 기술입니다. Nginx는 리버스 프록시로 작동하면서 FastCGI(PHP-FPM 등) 응답을 가로챌 수 있습니다.
캐시가 활성화되면 첫 요청만 PHP와 MySQL을 실행하고, 결과 HTML을 저장합니다. 이후 동일한 URL 요청은 Nginx가 캐시에서 즉시 응답합니다.
예를 들어, WordPress 블로그의 홈페이지나 포스트 상세 페이지는 자주 변경되지 않으므로 10분간 캐싱하면 엄청난 성능 향상을 얻습니다. 기존에는 모든 요청이 Nginx → PHP-FPM → MySQL → 템플릿 렌더링을 거쳤다면, 이제는 Nginx → 캐시로 단축됩니다.
FastCGI 캐싱의 강력함은 조건부 캐싱입니다. 로그인한 사용자나 POST 요청은 캐싱하지 않고, GET 요청이고 쿠키가 없는 경우만 캐싱하는 식으로 세밀하게 제어할 수 있습니다.
또한 특정 URL이나 쿼리 파라미터를 캐시 키에 포함해 페이지별로 다른 캐시를 유지할 수 있습니다.
코드 예제
# /etc/nginx/nginx.conf 또는 사이트별 설정
# 1. FastCGI 캐시 경로와 설정 정의
fastcgi_cache_path /var/cache/nginx/fastcgi
levels=1:2
keys_zone=WORDPRESS:100m
inactive=60m
max_size=1g;
server {
listen 80;
server_name example.com;
root /var/www/html;
index index.php;
# 2. 캐시 키 정의 (URL과 쿼리 문자열 기반)
set $skip_cache 0;
# 3. POST 요청이나 쿼리 문자열 있으면 캐시 스킵
if ($request_method = POST) {
set $skip_cache 1;
}
if ($query_string != "") {
set $skip_cache 1;
}
# 4. 로그인 사용자 캐시 스킵 (WordPress 쿠키 확인)
if ($http_cookie ~* "wordpress_logged_in") {
set $skip_cache 1;
}
location ~ \.php$ {
# 5. FastCGI 설정
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 6. 캐시 활성화
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 60m; # 성공 응답은 60분 캐싱
fastcgi_cache_valid 404 10m; # 404는 10분
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# 7. 디버깅용 헤더
add_header X-FastCGI-Cache $upstream_cache_status;
}
}
설명
이것이 하는 일: Nginx가 PHP 애플리케이션 앞에서 프록시로 작동하면서, GET 요청의 응답을 캐싱하고, 캐시 유효 기간 동안은 PHP를 실행하지 않고 저장된 HTML을 직접 제공합니다. 첫 번째 단계에서는 캐시 저장 경로를 정의합니다.
fastcgi_cache_path는 /var/cache/nginx/fastcgi 디렉토리에 캐시를 저장하고, levels=1:2는 하위 디렉토리 구조를 만들어 파일 시스템 성능을 최적화합니다. keys_zone=WORDPRESS:100m은 "WORDPRESS"라는 이름의 캐시 존을 100MB 메모리로 설정하며, 이는 약 80만 개의 캐시 키를 저장할 수 있습니다.
inactive=60m은 60분간 접근 없는 캐시를 삭제하고, max_size=1g는 최대 1GB까지 디스크 사용을 허용합니다. 두 번째와 세 번째 단계에서는 캐시를 스킵할 조건을 설정합니다.
$skip_cache 변수를 사용해 POST 요청이나 쿼리 파라미터가 있는 경우 캐싱하지 않습니다. 왜냐하면 POST는 데이터 변경 작업이고, 쿼리 파라미터는 검색이나 필터링 같은 동적 콘텐츠이기 때문입니다.
네 번째 단계는 로그인 사용자 판별입니다. WordPress는 로그인하면 wordpress_logged_in_* 쿠키를 설정하므로, 이 쿠키가 있으면 개인화된 콘텐츠를 보여줘야 해서 캐싱하지 않습니다.
익명 사용자만 캐싱해 개인정보 노출을 방지합니다. 다섯 번째 단계에서는 PHP-FPM과 통신 설정을 합니다.
fastcgi_pass는 Unix 소켓을 통해 PHP-FPM과 연결하며, TCP보다 빠릅니다. fastcgi_params는 PHP에 필요한 환경 변수를 전달합니다.
여섯 번째 단계가 핵심입니다. fastcgi_cache WORDPRESS는 앞서 정의한 캐시 존을 활성화하고, fastcgi_cache_valid는 상태 코드별 TTL을 설정합니다.
200 응답은 60분, 404는 10분 캐싱합니다. fastcgi_cache_bypass와 fastcgi_no_cache는 $skip_cache=1일 때 캐싱을 우회합니다.
일곱 번째 단계는 디버깅 헤더입니다. $upstream_cache_status는 "HIT"(캐시 사용), "MISS"(캐시 미스), "BYPASS"(캐시 스킵), "EXPIRED"(만료됨) 같은 값을 가지며, 브라우저 개발자 도구에서 확인할 수 있습니다.
설정 후 nginx -t && systemctl reload nginx로 적용하면, 첫 요청은 PHP가 처리하고 응답이 캐싱됩니다. 이후 60분간 동일 URL 요청은 모두 Nginx 캐시에서 즉시 응답하며, PHP와 MySQL은 전혀 사용되지 않습니다.
여러분이 FastCGI 캐싱을 적용하면 WordPress 같은 CMS가 정적 사이트처럼 빨라지고, 서버가 100배 더 많은 동시 접속을 처리할 수 있으며, 데이터베이스 부하가 거의 사라지고, 호스팅 비용이 크게 절감됩니다. WP Super Cache 같은 플러그인보다 훨씬 효과적입니다.
실전 팁
💡 캐시를 수동으로 비우려면 /var/cache/nginx/fastcgi 디렉토리를 삭제하거나, nginx-cache-purge 모듈을 사용하세요. 포스트 발행 시 자동 퍼지 스크립트를 작성하면 편리합니다.
💡 관리자 영역(/wp-admin/)은 절대 캐싱하지 마세요. location /wp-admin/ { set $skip_cache 1; }으로 명시적으로 제외하세요.
💡 캐시 히트율을 모니터링하려면 Nginx 로그에서 $upstream_cache_status를 기록하고, 70% 이상 HIT가 나와야 효과적입니다.
💡 페이지가 업데이트될 때 캐시를 자동으로 비우려면 WordPress의 save_post 훅에서 exec('rm -rf /var/cache/nginx/fastcgi/*')를 실행하거나, Nginx Helper 플러그인을 사용하세요.
💡 모바일과 데스크톱을 다르게 캐싱하려면 fastcgi_cache_key에 $http_user_agent를 포함시켜 디바이스별 캐시를 분리하세요.
8. 데이터베이스 쿼리 결과 캐싱 - ORM 레벨 최적화
시작하며
여러분의 애플리케이션이 매 페이지 로드마다 동일한 카테고리 목록이나 메뉴를 데이터베이스에서 조회하고 있나요? N+1 쿼리 문제로 하나의 페이지를 렌더링하는 데 수백 개의 SQL이 실행되고, 응답 시간이 수 초씩 걸립니다.
이런 문제는 ORM(Sequelize, TypeORM, Django ORM 등)을 사용할 때 특히 심각합니다. 편리함 때문에 쿼리 최적화를 놓치기 쉽고, 관계형 데이터를 로드할 때 연쇄적인 쿼리가 발생합니다.
사용자 목록을 조회하면서 각 사용자의 프로필을 별도로 조회하면 100명 → 101개 쿼리가 됩니다. 바로 이럴 때 필요한 것이 ORM 레벨 쿼리 캐싱입니다.
자주 조회되고 변경이 드문 데이터를 애플리케이션 메모리나 Redis에 캐싱하여, 데이터베이스 왕복을 줄이고 극적인 성능 향상을 달성할 수 있습니다.
개요
간단히 말해서, ORM 쿼리 캐싱은 데이터베이스 조회 결과를 애플리케이션 레벨에서 저장하고 재사용하여, 동일한 쿼리가 실행될 때 데이터베이스에 접근하지 않고 캐시에서 가져오는 기법입니다. 대부분의 웹 애플리케이션은 읽기가 쓰기보다 훨씬 많습니다.
카테고리, 태그, 설정값, 정적 콘텐츠 같은 데이터는 하루에 한 번도 변경되지 않지만 수천 번 조회됩니다. ORM 캐싱은 이런 불균형을 활용합니다.
예를 들어, 이커머스 사이트의 상품 카테고리 트리는 모든 페이지에서 네비게이션에 표시되지만, 하루에 한두 번만 업데이트됩니다. 기존에는 매 요청마다 SELECT * FROM categories가 실행되었다면, 이제는 첫 요청만 데이터베이스를 조회하고 결과를 메모리에 저장해 이후 요청은 밀리초도 안 걸립니다.
ORM 캐싱의 핵심은 invalidation(무효화) 전략입니다. 데이터가 변경되면 캐시를 즉시 비워야 하며, 그렇지 않으면 오래된 데이터를 보여주게 됩니다.
TypeORM의 QueryResultCache, Sequelize의 sequelize-transparent-cache 같은 라이브러리들이 이를 자동화해줍니다.
코드 예제
// TypeORM + Redis를 활용한 쿼리 캐싱 예제
import { DataSource, Repository } from 'typeorm';
import { Category } from './entities/Category';
import Redis from 'ioredis';
const redis = new Redis();
class CategoryService {
private categoryRepo: Repository<Category>;
constructor(private dataSource: DataSource) {
this.categoryRepo = dataSource.getRepository(Category);
}
// 1. 캐시를 활용한 카테고리 조회
async getAllCategories(): Promise<Category[]> {
const cacheKey = 'categories:all';
// 2. Redis 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
console.log('캐시 히트 - Redis에서 반환');
return JSON.parse(cached);
}
// 3. 캐시 미스 - DB 조회
console.log('캐시 미스 - DB 조회');
const categories = await this.categoryRepo.find({
relations: ['parent', 'children'], // N+1 방지: eager loading
order: { order: 'ASC' }
});
// 4. Redis에 1시간 캐싱
await redis.setex(cacheKey, 3600, JSON.stringify(categories));
return categories;
}
// 5. TypeORM 내장 캐싱 사용 (대안)
async getCategoriesWithTypeORMCache(): Promise<Category[]> {
return this.categoryRepo.find({
cache: {
id: 'categories_all',
milliseconds: 3600000 // 1시간
}
});
}
// 6. 카테고리 업데이트 시 캐시 무효화
async updateCategory(id: number, data: Partial<Category>): Promise<void> {
await this.categoryRepo.update(id, data);
// 캐시 삭제
await redis.del('categories:all');
// TypeORM 캐시도 삭제
await this.dataSource.queryResultCache?.remove(['categories_all']);
console.log('캐시 무효화 완료');
}
}
설명
이것이 하는 일: 데이터베이스 조회 전에 캐시를 확인하고, 있으면 즉시 반환하며, 없으면 DB에서 조회 후 캐싱하고, 데이터 변경 시 캐시를 삭제합니다. 첫 번째와 두 번째 단계에서는 Redis를 사용해 쿼리 결과를 캐싱합니다.
cacheKey는 "categories:all" 같은 명확한 이름을 사용하며, 나중에 무효화할 때 찾기 쉽게 합니다. redis.get()으로 캐시를 확인하고, 있으면 JSON을 파싱해 바로 반환합니다.
이 경우 데이터베이스 연결도 발생하지 않습니다. 세 번째 단계는 캐시 미스 처리입니다.
TypeORM의 find()로 카테고리를 조회하는데, relations: ['parent', 'children']로 관련 엔티티를 함께 로드해 N+1 문제를 방지합니다. Eager loading 없이 각 카테고리의 부모/자식을 따로 조회하면 수백 개 쿼리가 발생할 수 있습니다.
네 번째 단계에서는 조회 결과를 Redis에 저장합니다. setex는 TTL과 함께 값을 설정하는 메서드로, 3600초(1시간) 후 자동 만료됩니다.
데이터를 JSON 문자열로 변환해 저장하며, Date 객체는 문자열이 되므로 필요하면 복원 로직이 필요합니다. 다섯 번째 단계는 TypeORM 내장 캐싱입니다.
cache 옵션을 사용하면 TypeORM이 자체적으로 캐싱을 관리합니다. 기본적으로 데이터베이스의 쿼리 캐시를 사용하지만, Redis를 캐시 스토어로 설정할 수도 있습니다.
이 방법은 코드가 간결하지만 세밀한 제어는 어렵습니다. 여섯 번째 단계는 캐시 무효화입니다.
카테고리가 수정되면 redis.del()로 캐시를 삭제하고, TypeORM 캐시도 queryResultCache.remove()로 제거합니다. 이렇게 하면 다음 조회 시 최신 데이터를 DB에서 가져와 새로 캐싱합니다.
캐시 무효화를 잊으면 사용자가 오래된 데이터를 보게 되므로 매우 중요합니다. 실전에서는 특정 카테고리만 업데이트되어도 전체 캐시를 비우는 것이 안전합니다.
세밀한 무효화(예: 특정 카테고리만 삭제)는 복잡도가 높아 버그 위험이 있습니다. 여러분이 ORM 쿼리 캐싱을 적용하면 페이지 로딩 시간이 70-90% 단축되고, 데이터베이스 커넥션 풀이 거의 사용되지 않으며, 동시 사용자를 10배 이상 처리할 수 있고, 데이터베이스 스케일업을 미룰 수 있습니다.
실전 팁
💡 캐시 워밍(Cache Warming)을 구현하세요. 애플리케이션 시작 시 자주 사용되는 쿼리를 미리 실행해 캐시를 채우면, 첫 사용자도 빠른 응답을 받습니다.
💡 변경이 드문 데이터(설정, 카테고리 등)는 긴 TTL(1시간1일)을, 자주 변경되는 데이터(재고, 가격 등)는 짧은 TTL(15분)을 사용하세요.
💡 N+1 쿼리를 먼저 해결하고 캐싱하세요. 비효율적인 쿼리를 캐싱하면 여전히 첫 요청은 느리고, 캐시 미스 시 문제가 됩니다.
💡 캐시 의존성을 관리하세요. 예를 들어 상품 데이터가 변경되면 관련된 카테고리 캐시도 무효화해야 할 수 있습니다. 태그 기반 무효화를 구현하면 편리합니다.
💡 개발 환경에서는 캐싱을 비활성화하거나 매우 짧은 TTL을 사용해, 코드 변경이 즉시 반영되도록 하세요. 환경 변수로 제어하면 좋습니다.