Node.js 인터뷰 질문 87
질문: Node.js에서 캐싱을 구현하는 다양한 방법과 각각의 장단점에 대해 설명해주세요.
답변:
Node.js에서 캐싱은 애플리케이션의 성능을 향상시키는 중요한 전략입니다. 메모리 캐시, Redis, 파일 시스템 캐시 등 다양한 방법으로 구현할 수 있습니다.
1. 인메모리 캐싱
가장 간단하고 빠른 캐싱 방법으로, Node.js 프로세스의 메모리를 사용합니다.
// 간단한 인메모리 캐시 클래스 구현
class MemoryCache {
constructor() {
this.cache = new Map();
this.ttl = new Map();
}
// 데이터 저장
set(key, value, ttlMs = 0) {
this.cache.set(key, value);
if (ttlMs > 0) {
const expiryTime = Date.now() + ttlMs;
this.ttl.set(key, expiryTime);
// TTL 이후 자동 삭제
setTimeout(() => {
this.delete(key);
}, ttlMs);
}
}
// 데이터 조회
get(key) {
if (this.ttl.has(key)) {
const expiryTime = this.ttl.get(key);
if (Date.now() > expiryTime) {
this.delete(key);
return undefined;
}
}
return this.cache.get(key);
}
// 데이터 삭제
delete(key) {
this.cache.delete(key);
this.ttl.delete(key);
}
// 캐시 초기화
clear() {
this.cache.clear();
this.ttl.clear();
}
}
// 사용 예시
const cache = new MemoryCache();
// 데이터베이스 조회 결과 캐싱
async function getUserById(id) {
// 캐시 확인
const cachedUser = cache.get(`user:${id}`);
if (cachedUser) {
return cachedUser;
}
// DB 조회
const user = await db.findUser(id);
// 캐시에 저장 (1시간 유효)
cache.set(`user:${id}`, user, 3600000);
return user;
}
2. Redis를 사용한 캐싱
분산 환경에서 사용하기 적합한 캐싱 솔루션입니다.
const Redis = require("ioredis");
const redis = new Redis({
host: "localhost",
port: 6379,
});
class RedisCache {
constructor(redisClient) {
this.client = redisClient;
}
// 데이터 저장
async set(key, value, ttlSeconds = 0) {
try {
const serializedValue = JSON.stringify(value);
if (ttlSeconds > 0) {
await this.client.setex(key, ttlSeconds, serializedValue);
} else {
await this.client.set(key, serializedValue);
}
} catch (error) {
console.error("Redis 저장 오류:", error);
throw error;
}
}
// 데이터 조회
async get(key) {
try {
const value = await this.client.get(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error("Redis 조회 오류:", error);
throw error;
}
}
// 데이터 삭제
async delete(key) {
try {
await this.client.del(key);
} catch (error) {
console.error("Redis 삭제 오류:", error);
throw error;
}
}
// 패턴으로 키 삭제
async deletePattern(pattern) {
try {
const keys = await this.client.keys(pattern);
if (keys.length > 0) {
await this.client.del(keys);
}
} catch (error) {
console.error("Redis 패턴 삭제 오류:", error);
throw error;
}
}
}
// 사용 예시
const redisCache = new RedisCache(redis);
// API 응답 캐싱
async function getArticles(category) {
const cacheKey = `articles:${category}`;
try {
// 캐시 확인
const cachedArticles = await redisCache.get(cacheKey);
if (cachedArticles) {
return cachedArticles;
}
// DB 조회
const articles = await db.findArticles({ category });
// 캐시에 저장 (15분 유효)
await redisCache.set(cacheKey, articles, 900);
return articles;
} catch (error) {
console.error("기사 조회 오류:", error);
throw error;
}
}
3. 파일 시스템 캐싱
정적 데이터나 큰 데이터셋을 캐싱할 때 유용합니다.
const fs = require("fs").promises;
const path = require("path");
class FileCache {
constructor(cacheDir) {
this.cacheDir = cacheDir;
}
// 캐시 파일 경로 생성
getCachePath(key) {
return path.join(this.cacheDir, `${key}.json`);
}
// 데이터 저장
async set(key, value, ttlMs = 0) {
const cachePath = this.getCachePath(key);
const cacheData = {
value,
expiryTime: ttlMs > 0 ? Date.now() + ttlMs : null,
};
try {
await fs.mkdir(this.cacheDir, { recursive: true });
await fs.writeFile(cachePath, JSON.stringify(cacheData));
} catch (error) {
console.error("파일 캐시 저장 오류:", error);
throw error;
}
}
// 데이터 조회
async get(key) {
const cachePath = this.getCachePath(key);
try {
const data = await fs.readFile(cachePath, "utf8");
const cacheData = JSON.parse(data);
// TTL 확인
if (cacheData.expiryTime && Date.now() > cacheData.expiryTime) {
await this.delete(key);
return null;
}
return cacheData.value;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
// 데이터 삭제
async delete(key) {
const cachePath = this.getCachePath(key);
try {
await fs.unlink(cachePath);
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
// 캐시 초기화
async clear() {
try {
const files = await fs.readdir(this.cacheDir);
await Promise.all(
files.map((file) => fs.unlink(path.join(this.cacheDir, file)))
);
} catch (error) {
console.error("캐시 초기화 오류:", error);
throw error;
}
}
}
// 사용 예시
const fileCache = new FileCache("./cache");
// 대용량 데이터 캐싱
async function getAnalyticsData(dateRange) {
const cacheKey = `analytics_${dateRange}`;
try {
// 캐시 확인
const cachedData = await fileCache.get(cacheKey);
if (cachedData) {
return cachedData;
}
// 데이터 계산
const analyticsData = await computeAnalytics(dateRange);
// 캐시에 저장 (24시간 유효)
await fileCache.set(cacheKey, analyticsData, 24 * 60 * 60 * 1000);
return analyticsData;
} catch (error) {
console.error("분석 데이터 조회 오류:", error);
throw error;
}
}
4. HTTP 응답 캐싱
Express.js에서 HTTP 응답을 캐싱하는 방법입니다.
const express = require("express");
const app = express();
// HTTP 캐시 미들웨어
function cacheMiddleware(duration) {
return (req, res, next) => {
// 캐시 헤더 설정
res.set("Cache-Control", `public, max-age=${duration}`);
next();
};
}
// 조건부 요청 처리 미들웨어
function conditionalGet() {
return (req, res, next) => {
const etag = generateETag(req.url);
res.set("ETag", etag);
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
next();
};
}
// 정적 파일에 캐시 적용
app.use(
"/static",
cacheMiddleware(86400), // 24시간
express.static("public")
);
// API 응답에 조건부 요청 처리 적용
app.get("/api/data", conditionalGet(), async (req, res) => {
const data = await getData();
res.json(data);
});
5. 다층 캐싱 전략
여러 캐싱 방식을 조합하여 사용하는 전략입니다.
class MultiLevelCache {
constructor(options = {}) {
this.memoryCache = new MemoryCache();
this.redisCache = new RedisCache(options.redisClient);
this.fileCache = new FileCache(options.cacheDir);
}
async get(key) {
// 1. 메모리 캐시 확인
let value = this.memoryCache.get(key);
if (value) {
return { value, source: "memory" };
}
// 2. Redis 캐시 확인
value = await this.redisCache.get(key);
if (value) {
// 메모리 캐시에도 저장
this.memoryCache.set(key, value, 300000); // 5분
return { value, source: "redis" };
}
// 3. 파일 캐시 확인
value = await this.fileCache.get(key);
if (value) {
// Redis와 메모리 캐시에도 저장
await this.redisCache.set(key, value, 3600); // 1시간
this.memoryCache.set(key, value, 300000); // 5분
return { value, source: "file" };
}
return { value: null, source: null };
}
async set(key, value, options = {}) {
const {
memoryTTL = 300000, // 5분
redisTTL = 3600, // 1시간
fileTTL = 86400000, // 24시간
} = options;
// 모든 레벨에 동시에 저장
await Promise.all([
this.memoryCache.set(key, value, memoryTTL),
this.redisCache.set(key, value, redisTTL),
this.fileCache.set(key, value, fileTTL),
]);
}
async invalidate(key) {
// 모든 레벨에서 동시에 삭제
await Promise.all([
this.memoryCache.delete(key),
this.redisCache.delete(key),
this.fileCache.delete(key),
]);
}
}
// 사용 예시
const multiLevelCache = new MultiLevelCache({
redisClient: redis,
cacheDir: "./cache",
});
async function getData(id) {
const cacheKey = `data:${id}`;
// 캐시 확인
const { value, source } = await multiLevelCache.get(cacheKey);
if (value) {
console.log(`캐시 히트 (${source})`);
return value;
}
// 데이터 조회
const data = await fetchData(id);
// 캐시에 저장
await multiLevelCache.set(cacheKey, data);
return data;
}
요약
Node.js에서 사용할 수 있는 주요 캐싱 전략:
인메모리 캐싱
- 장점: 가장 빠른 접근 속도
- 단점: 메모리 제한, 프로세스 재시작 시 손실
Redis 캐싱
- 장점: 분산 환경 지원, 영속성
- 단점: 추가 인프라 필요, 네트워크 지연
파일 시스템 캐싱
- 장점: 대용량 데이터 처리 가능, 영속성
- 단점: 느린 I/O, 동시성 제어 필요
HTTP 캐싱
- 장점: 클라이언트 측 캐싱, 대역폭 절약
- 단점: 세밀한 제어 어려움
다층 캐싱
- 장점: 각 전략의 장점 조합
- 단점: 구현 복잡도 증가, 일관성 관리 필요
효과적인 캐싱 전략 선택을 위해 고려할 사항:
- 데이터 크기와 접근 패턴
- 일관성 요구사항
- 인프라 제약 조건
- 성능 요구사항