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에서 사용할 수 있는 주요 캐싱 전략:

  1. 인메모리 캐싱

    • 장점: 가장 빠른 접근 속도
    • 단점: 메모리 제한, 프로세스 재시작 시 손실
  2. Redis 캐싱

    • 장점: 분산 환경 지원, 영속성
    • 단점: 추가 인프라 필요, 네트워크 지연
  3. 파일 시스템 캐싱

    • 장점: 대용량 데이터 처리 가능, 영속성
    • 단점: 느린 I/O, 동시성 제어 필요
  4. HTTP 캐싱

    • 장점: 클라이언트 측 캐싱, 대역폭 절약
    • 단점: 세밀한 제어 어려움
  5. 다층 캐싱

    • 장점: 각 전략의 장점 조합
    • 단점: 구현 복잡도 증가, 일관성 관리 필요

효과적인 캐싱 전략 선택을 위해 고려할 사항:

  • 데이터 크기와 접근 패턴
  • 일관성 요구사항
  • 인프라 제약 조건
  • 성능 요구사항

results matching ""

    No results matching ""