캐싱 패턴의 종류와 동작 원리 #Node.js #Redis #MySQL

캐싱은 데이터베이스의 조회 부하를 줄이고 응답 속도를 개선하기 위해 자주 사용하는 기술이다. 하지만 캐시를 도입하는 것만으로 성능과 데이터 일관성이 모두 해결되는 것은 아니다.

캐시를 누가 조회하는지, 데이터를 수정할 때 어느 저장소를 먼저 갱신하는지, DB 저장이 지연되거나 실패하면 어떻게 복구하는지에 따라 시스템의 동작은 달라진다.

이러한 설계 방식은 캐싱 패턴(Cache Pattern)으로 구분할 수 있다.

대표적인 캐싱 패턴에는 Cache-Aside, Read-Through, Write-Through, Write-Behind, Write-Around, Refresh-Ahead가 있다.

이번 포스트에서는 Node.js와 TypeScript 환경에서 Redis와 MySQL을 사용해 각 패턴의 동작 원리와 구현 방법을 살펴본다.

캐싱 패턴을 이해하기 위한 기본 구조

먼저 각 저장소의 역할을 정의한다.

  • MySQL: 영구적으로 보존해야 하는 기준 데이터를 관리한다.
  • Redis: 자주 조회하는 데이터를 메모리에 캐싱한다.
  • Application Service: 비즈니스 로직과 데이터 처리 흐름을 조율한다.
  • Repository: 데이터베이스 조회 및 수정 작업을 추상화한다.
  • Worker: 비동기 작업이나 백그라운드 캐시 갱신을 수행한다.

이번 예제에서는 플레이어 정보를 기준으로 설명한다.

type Player = {
  id: string;
  nickname: string;
  points: number;
};

MySQL에는 플레이어의 실제 데이터가 저장되고 Redis에는 해당 데이터를 직렬화한 캐시가 저장된다.

MySQL
player-001
nickname = "Wayhope"
points = 100

Redis
player:player-001
{
  "id": "player-001",
  "nickname": "Wayhope",
  "points": 100
}

여기서 중요한 점은 Redis에 저장된 데이터가 MySQL의 복사본이라는 것이다. 이번 포스트에서는 MySQL을 기준 데이터 저장소(Source of Truth)로 사용한다.

단, 모든 시스템에서 반드시 MySQL이 기준 저장소여야 하는 것은 아니다. 데이터의 특성과 시스템 요구사항에 따라 Redis를 기준 저장소로 설계할 수도 있다.

Cache-Aside

Cache-Aside는 애플리케이션이 직접 캐시를 조회하고 관리하는 패턴이다. 일반적인 웹 서비스와 게임 서버에서 적용하기 쉬운 방식이다.

동작 원리

클라이언트 요청
      |
      v
Application Service
      |
      v
Redis 캐시 조회
      |
      +---- Cache Hit ----> 데이터 반환
      |
      +---- Cache Miss
                |
                v
           MySQL 조회
                |
                v
           Redis 저장
                |
                v
            데이터 반환

Redis에 데이터가 존재하면 해당 값을 즉시 반환한다. 데이터가 없다면 MySQL에서 조회한 뒤 Redis에 저장한다.

조회 구현

async function getPlayer(
  id: string
): Promise<Player | null> {
  const key = `player:${id}`;

  // Redis 캐시 조회
  const cached = await redis.get(key);

  if (cached !== null) {
    return JSON.parse(cached) as Player;
  }

  // Cache Miss: MySQL 조회
  const player = await repository.findById(id);

  if (player === null) {
    return null;
  }

  // Redis 캐시 생성
  await redis.set(
    key,
    JSON.stringify(player),
    "EX",
    300
  );

  return player;
}

EX 300은 캐시의 유효 시간을 300초로 설정한다.

최초 조회에서는 MySQL을 사용하지만 이후 요청에서는 Redis를 이용할 수 있다.

수정 구현

async function updatePlayer(
  player: Player
): Promise<void> {
  // DB 저장
  await repository.save(player);

  // 기존 캐시 무효화
  await redis.del(`player:${player.id}`);
}

데이터를 수정할 때는 MySQL에 먼저 반영하고 Redis의 기존 캐시를 삭제한다. 다음 조회에서 최신 데이터를 다시 캐싱한다.

장점과 단점

장점

  • 캐시 대상과 정책을 애플리케이션에서 자유롭게 제어할 수 있다.
  • Redis가 비어 있어도 MySQL을 통해 데이터를 조회할 수 있다.
  • 구현이 단순하고 적용 범위가 넓다.

단점

  • 캐시 조회, 생성, 무효화를 애플리케이션에서 직접 관리해야 한다.
  • 캐시 삭제 실패 시 오래된 데이터가 남을 수 있다.
  • 동시 요청으로 동일한 데이터가 여러 번 조회될 수 있다.

적합한 상황: 플레이어 프로필, 게임 설정, 상품 정보처럼 조회 빈도가 높고 캐시 대상 선택이 중요한 데이터.

Read-Through

Read-Through는 애플리케이션이 캐시 계층에 조회를 요청하고, 캐시 계층이 Cache Miss 발생 시 DB 조회와 캐시 생성을 담당하는 패턴이다.

Cache-Aside와의 핵심적인 차이는 DB 조회 책임이 어디에 있는가이다.

동작 원리

Application
     |
     v
Cache Layer
     |
     +---- Cache Hit ----> 데이터 반환
     |
     +---- Cache Miss
               |
               v
          MySQL 조회
               |
               v
          Redis 저장
               |
               v
           데이터 반환

애플리케이션은 캐시 계층의 get() 메서드만 호출하면 된다. 캐시 계층이 내부적으로 DB 조회와 캐시 적재를 처리한다.

구현

class ReadThrough {
  constructor(
    private readonly redis: Redis,
    private readonly repository: PlayerRepository
  ) {}

  async get(id: string): Promise<Player | null> {
    const key = `player:${id}`;

    const cached = await this.redis.get(key);

    if (cached !== null) {
      return JSON.parse(cached) as Player;
    }

    const player = await this.repository.findById(id);

    if (player === null) {
      return null;
    }

    await this.redis.set(
      key,
      JSON.stringify(player),
      "EX",
      300
    );

    return player;
  }
}

애플리케이션에서는 다음과 같이 호출한다.

const player = await readThrough.get("player-001");

장점과 단점

장점

  • 애플리케이션의 조회 코드가 단순해진다.
  • 캐시 미스 처리 정책을 하나의 계층에 모을 수 있다.
  • 여러 서비스에서 캐시 정책을 재사용하기 쉽다.

단점

  • 캐시 계층에서 DB 접근을 수행할 수 있도록 구현해야 한다.
  • 캐시 계층의 장애 처리와 재시도 정책이 필요하다.
  • 데이터 수정 시 캐시 갱신 전략은 별도로 설계해야 한다.

적합한 상황: 캐시 추상화 계층을 두고 여러 서비스의 조회 정책을 통일하려는 경우.

Redis 자체가 MySQL을 자동으로 조회하는 것은 아니다. 일반적인 Node.js와 Redis 조합에서는 애플리케이션 내부에 Read-Through 계층을 구현해야 한다.

Write-Through

Write-Through는 캐시 계층을 통해 데이터를 수정하고, 캐시 계층이 DB와 캐시를 함께 갱신하는 패턴이다.

동작 원리

Application
     |
     v
Cache Layer
     |
     +----> MySQL 갱신
     |
     +----> Redis 갱신

정상적으로 두 작업이 완료되면 다음 조회에서 최신 캐시를 반환할 수 있다.

구현

class WriteThrough {
  constructor(
    private readonly redis: Redis,
    private readonly repository: PlayerRepository
  ) {}

  async update(player: Player): Promise<void> {
    // MySQL 먼저 수정
    await this.repository.save(player);

    // Redis 캐시 갱신
    await this.redis.set(
      `player:${player.id}`,
      JSON.stringify(player),
      "EX",
      300
    );
  }
}

이 예제에서는 DB 저장 후 캐시를 갱신한다. 두 작업의 순서는 시스템의 일관성 요구사항에 따라 설계해야 한다.

장점과 단점

장점

  • 정상 처리 시 쓰기 이후에도 최신 캐시를 유지할 수 있다.
  • 다음 조회에서 Cache Miss가 발생할 가능성을 줄일 수 있다.
  • 읽기와 쓰기가 모두 빈번한 데이터에서 유용하다.

단점

  • 캐시와 DB를 갱신하는 비용이 쓰기 요청에 추가된다.
  • DB 저장 성공 후 Redis 갱신이 실패할 수 있다.
  • 두 저장소의 변경을 하나의 원자적 트랜잭션으로 처리할 수 없다.

적합한 상황: 데이터 수정 직후 해당 데이터를 자주 조회하고, 캐시를 최신 상태로 유지할 필요가 있는 경우.

Write-Through라는 이름만으로 강한 일관성이 보장되는 것은 아니다. 캐시 갱신 실패에 대비한 재시도나 무효화 전략이 필요하다.

Write-Behind (Write-Back)

Write-Behind는 캐시에 변경 사항을 먼저 반영하고, DB 저장을 비동기로 수행하는 패턴이다.

동작 원리

Application
     |
     v
Redis 갱신
     |
     +----> 빠른 응답
     |
     v
비동기 작업 큐
     |
     v
Worker
     |
     v
MySQL 저장

DB 저장을 요청의 동기 처리 경로에서 분리할 수 있으므로 쓰기 응답 시간을 줄이는 데 유리하다.

구현

아래 코드는 Redis에 캐시를 갱신하고 별도의 큐에 DB 저장 작업을 등록하는 개념적인 예제다.

class WriteBehind {
  constructor(
    private readonly redis: Redis,
    private readonly repository: PlayerRepository
  ) {}

  async update(player: Player): Promise<void> {
    const serialized = JSON.stringify(player);

    // 캐시 갱신
    await this.redis.set(
      `player:${player.id}`,
      serialized
    );

    // 비동기 저장 작업 등록
    await this.redis.rpush(
      "player:write-queue",
      serialized
    );
  }

  async processNext(): Promise<boolean> {
    const serialized = await this.redis.lpop(
      "player:write-queue"
    );

    if (serialized === null) {
      return false;
    }

    const player = JSON.parse(serialized) as Player;

    await this.repository.save(player);

    return true;
  }
}

processNext()는 별도 Worker에서 호출하도록 구성할 수 있다.

다만 이 구현은 학습용이다. LPOP으로 작업을 제거한 직후 Worker가 종료되면 DB에 저장되지 않은 작업이 유실될 수 있다.

운영 환경에서는 처리 완료 전까지 작업을 보존하는 큐, 확인 응답, 재시도, 중복 처리 방지, 데이터 변경 순서 보장 등이 필요하다.

장점과 단점

장점

  • DB 저장을 지연시켜 요청 응답 시간을 줄일 수 있다.
  • 여러 변경 사항을 모아 일괄 저장할 수 있다.
  • 쓰기 빈도가 높은 시스템에서 DB 부하를 줄일 수 있다.

단점

  • DB 반영 전에 장애가 발생하면 데이터가 유실될 수 있다.
  • Redis와 DB의 데이터가 일시적으로 불일치할 수 있다.
  • 재시도, 순서 보장, 복구 설계가 복잡하다.

적합한 상황: 일시적인 게임 상태처럼 변경 빈도가 높고, 저장 지연을 허용할 수 있는 데이터.

포인트, 구매 기록, 보상 지급처럼 유실되면 안 되는 데이터에는 단순한 Write-Behind를 적용하지 않는 것이 좋다.

Write-Around

Write-Around는 쓰기 요청을 캐시 계층에서 처리하지 않고 DB에 직접 기록하는 패턴이다. 기존 캐시가 있다면 무효화하고 다음 조회에서 캐시를 생성한다.

동작 원리

Application
     |
     v
MySQL 저장
     |
     v
Redis 캐시 삭제
     |
     v
다음 조회에서 캐시 생성

구현

class WriteAround {
  constructor(
    private readonly redis: Redis,
    private readonly repository: PlayerRepository
  ) {}

  async update(player: Player): Promise<void> {
    // DB에 직접 저장
    await this.repository.save(player);

    // 기존 캐시 무효화
    await this.redis.del(`player:${player.id}`);
  }
}

이 구조에서는 수정 시 Redis에 새 데이터를 기록하지 않는다. 따라서 수정한 데이터가 곧바로 다시 조회되지 않는 상황에서 캐시 갱신 비용을 줄일 수 있다.

장점과 단점

장점

  • 불필요한 캐시 갱신을 줄일 수 있다.
  • DB를 기준 데이터 저장소로 유지하기 쉽다.
  • 쓰기 이후 조회 빈도가 낮은 데이터에 유리하다.

단점

  • 쓰기 직후 조회에서 Cache Miss가 발생할 수 있다.
  • 캐시 무효화 실패 시 오래된 데이터가 남을 수 있다.
  • 반복적인 쓰기와 읽기가 동시에 발생하면 캐시 재생성 비용이 증가할 수 있다.

적합한 상황: 게시글, 상품 정보처럼 쓰기 작업이 발생하더라도 해당 데이터가 즉시 다시 조회되지 않을 수 있는 경우.

Write-Around는 일반적으로 쓰기만 캐시를 우회하는 전략이다. 읽기에서는 Cache-Aside처럼 캐시를 활용할 수 있다.

Refresh-Ahead

Refresh-Ahead는 캐시가 만료되기 전에 데이터를 미리 갱신하는 패턴이다.

캐시 만료 이후 첫 요청이 DB 조회를 기다리는 상황을 줄이기 위해 사용한다.

동작 원리

Redis 캐시 조회
      |
      v
캐시 데이터 존재
      |
      v
TTL 확인
      |
      +---- 만료까지 여유 있음
      |          |
      |          v
      |       캐시 반환
      |
      +---- 만료 임박
                 |
                 +---- 기존 캐시 반환
                 |
                 +---- 백그라운드 갱신
                              |
                              v
                         MySQL 조회
                              |
                              v
                         Redis 갱신

구현

class RefreshAhead {
  private readonly ttlSeconds = 300;
  private readonly refreshThresholdSeconds = 60;

  constructor(
    private readonly redis: Redis,
    private readonly repository: PlayerRepository
  ) {}

  async get(id: string): Promise<Player | null> {
    const key = `player:${id}`;

    const cached = await this.redis.get(key);

    if (cached === null) {
      return this.loadAndCache(id);
    }

    const ttl = await this.redis.ttl(key);

    if (
      ttl >= 0 &&
      ttl <= this.refreshThresholdSeconds
    ) {
      void this.refreshInBackground(id);
    }

    // 갱신 완료를 기다리지 않고 기존 캐시 반환
    return JSON.parse(cached) as Player;
  }

  private async loadAndCache(
    id: string
  ): Promise<Player | null> {
    const player = await this.repository.findById(id);

    if (player === null) {
      return null;
    }

    await this.redis.set(
      `player:${id}`,
      JSON.stringify(player),
      "EX",
      this.ttlSeconds
    );

    return player;
  }

  private async refreshInBackground(
    id: string
  ): Promise<void> {
    try {
      await this.loadAndCache(id);
    } catch (error) {
      console.error("Cache refresh failed", error);
    }
  }
}

이 예제에서는 캐시의 TTL이 60초 이하로 남으면 백그라운드 갱신을 시작한다. 기존 캐시는 즉시 반환하고, 갱신이 완료되면 Redis에 새 데이터와 TTL을 기록한다.

장점과 단점

장점

  • 인기 데이터의 Cache Miss를 줄일 수 있다.
  • 캐시 갱신에 따른 사용자 요청의 지연을 줄일 수 있다.
  • 읽기 요청이 많은 서비스에서 유용하다.

단점

  • 조회가 적은 데이터까지 갱신하면 DB 부하가 증가한다.
  • 여러 요청이 동시에 들어오면 중복 갱신이 발생할 수 있다.
  • 갱신 도중 데이터가 수정되면 오래된 값이 다시 저장될 수 있다.

적합한 상황: 랭킹, 인기 콘텐츠, 대시보드처럼 읽기 지연에 민감한 데이터.

실제 서비스에서는 Singleflight 또는 분산 조율, 데이터 버전 검증, 갱신 실패 모니터링 등을 추가로 고려해야 한다.

캐싱 패턴 비교

패턴캐시 미스 처리쓰기 전략핵심 목적
Cache-Aside애플리케이션이 DB 조회DB 저장 후 캐시 무효화범용적인 캐싱
Read-Through캐시 계층이 DB 조회별도 쓰기 정책조회 로직 추상화
Write-Through캐시 계층이 관리캐시와 DB 갱신쓰기 이후 캐시 유지
Write-Behind캐시 계층에 따라 다름캐시 반영 후 비동기 DB 저장쓰기 응답 지연 감소
Write-Around일반적으로 애플리케이션이 DB 조회DB 직접 저장 및 캐시 무효화불필요한 캐시 갱신 방지
Refresh-Ahead사전 갱신으로 미스 감소백그라운드 캐시 갱신읽기 지연 감소

이 패턴들은 서로 배타적인 선택지가 아니다. 조회 전략과 쓰기 전략을 조합해 사용할 수 있다.

예를 들어 Cache-Aside와 Write-Around를 조합하면 읽기는 캐시를 사용하고 쓰기는 DB에 직접 반영한 뒤 캐시를 무효화한다. 여기에 Refresh-Ahead를 추가하면 인기 데이터의 캐시를 만료 전에 갱신할 수도 있다.

게임 서버에서의 적용 기준

게임 서버에서는 데이터의 성격에 따라 캐싱 패턴을 다르게 선택해야 한다.

데이터고려할 패턴설계상 핵심
플레이어 프로필Cache-Aside캐시 TTL과 무효화
게임 설정Cache-Aside 또는 Read-Through공통 캐시 정책
인기 랭킹Refresh-Ahead만료 시점의 지연 감소
상품 정보Cache-Aside + Write-Around조회 빈도와 캐시 갱신 비용
고빈도 게임 상태Write-Behind 고려내구성, 순서 보장, 복구
포인트 및 재화DB 트랜잭션 중심정확성, 중복 요청 방지
구매 기록DB 트랜잭션 중심영속성, 거래 무결성

처음부터 복잡한 패턴을 도입할 필요는 없다.

일반적인 조회 데이터는 Cache-Aside로 시작하고, 실제 모니터링 결과에서 병목이 확인되면 Read-Through, Refresh-Ahead 또는 비동기 저장을 검토하는 것이 합리적이다.

특히 캐시를 적용하기 전에 읽기와 쓰기의 비율, 데이터의 중요도, 허용 가능한 지연, 장애 발생 시 복구 방법을 먼저 정의해야 한다.

마무리

캐싱 패턴의 차이는 Redis에 데이터를 저장하는 방법이 아니라, 데이터의 조회와 수정에 대한 책임을 어떻게 분배하는가에 있다.

  • Cache-Aside는 애플리케이션이 캐시를 직접 관리한다.
  • Read-Through는 캐시 계층이 조회와 캐시 적재를 담당한다.
  • Write-Through는 캐시 계층이 DB와 캐시를 함께 갱신한다.
  • Write-Behind는 캐시 반영 이후 DB 저장을 비동기로 수행한다.
  • Write-Around는 쓰기 작업에서 캐시 갱신을 생략하고 DB에 직접 저장한다.
  • Refresh-Ahead는 캐시가 만료되기 전에 미리 갱신한다.

어떤 패턴도 모든 상황에서 최선은 아니다. 읽기 성능을 높이면 갱신 비용이 증가할 수 있고, 쓰기 지연을 줄이면 데이터 유실 위험과 복구 복잡도가 커질 수 있다.

캐싱 설계의 핵심은 성능, 데이터 일관성, 장애 복구 사이에서 시스템의 요구사항에 맞는 균형을 찾는 것이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다