게임 서버 Memcached 적용으로 DB 호출 부하 줄이는 실전 캐싱 전략

멀티플레이 게임 서버에서 반복되는 플레이어·인벤토리·랭킹 조회가 DB 병목으로 이어지는 이유를 분석하고 Memcached 캐시 어사이드 패턴과 TTL·무효화·장애 대응으로 호출 부하를 낮추는 방법을 정리합니다.

✓ 복사 완료!
챕터게임 백엔드 & 데이터베이스
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
게임 서버 Memcached 적용으로 DB 호출 부하 줄이는 실전 캐싱 전략

게임 서버의 DB 호출 부하는 같은 데이터를 짧은 시간에 반복 조회할 때 급격히 커진다. Memcached를 DB 앞단의 인메모리 캐시로 두고 캐시 어사이드(Cache-Aside) 패턴을 적용하면 조회 요청의 상당수를 메모리에서 처리해 DB 연결 수와 쿼리 시간을 줄일 수 있다. 다만 캐시는 원본 데이터가 아니라는 전제 아래 TTL, 키 설계, 무효화, 장애 시 폴백을 함께 설계해야 한다.

게임 서버에서 DB 호출 부하는 왜 발생하는가?

멀티플레이 게임에서는 로그인 직후뿐 아니라 매치메이킹, 로비 갱신, 장비 변경, 상점 표시, 친구 목록, 랭킹 확인처럼 여러 기능이 플레이어 데이터를 반복 조회한다. 서버 노드가 늘어나면 같은 플레이어 정보라도 각 노드에서 DB를 다시 읽게 되며 읽기 비중이 높은 서비스는 DB가 먼저 병목이 되기 쉽다.

대표적인 고빈도 읽기 데이터는 다음과 같다.

데이터조회 특성캐싱 적합성권장 TTL 예시
플레이어 프로필, 닉네임읽기 많음, 변경 적음높음5~30분
친구 목록읽기 많음, 변경 간헐적높음1~10분
인벤토리읽기와 변경 모두 존재조건부30초~5분
상점 상품 메타데이터읽기 매우 많음, 변경 드묾매우 높음10분~1시간
실시간 랭킹갱신 빈도 높음조건부5~30초
결제 잔액, 재화정합성 중요제한적직접 DB 또는 짧은 TTL

DB가 처리해야 하는 읽기 요청 수는 캐시 적중률로 대략 계산할 수 있다.

DBReadQPS=TotalReadQPS×(1−CacheHitRate)DBReadQPS = TotalReadQPS \times (1 - CacheHitRate)

예를 들어 읽기 요청이 초당 10,000건이고 캐시 적중률이 90%라면 DB 읽기는 초당 약 1,000건으로 줄어든다. 캐싱의 목표는 무조건 DB를 없애는 것이 아니라 DB가 반드시 처리해야 하는 쓰기와 캐시 미스만 남기는 데 있다.

Memcached는 게임 서버 캐시에 어떻게 활용할까?

Memcached는 키-값 형태의 데이터를 RAM에 저장하는 분산 인메모리 캐시다. 디스크 영속성이나 복제 기능을 제공하지 않으며 서버 재시작·메모리 축출·노드 장애로 데이터가 사라질 수 있다. 따라서 플레이어 재화나 구매 기록의 원본 저장소로 쓰면 안 되고 MySQL·PostgreSQL 같은 DB의 조회 부하를 줄이는 용도로 사용해야 한다.

게임 서버에서 가장 일반적인 방식은 캐시 어사이드다.

flowchart LR
    A[게임 클라이언트 요청] --> B[게임 서버]
    B --> C{Memcached Hit?}
    C -- Hit --> D[캐시 데이터 응답]
    C -- Miss --> E[관계형 DB 조회]
    E --> F[Memcached 저장: TTL 설정]
    F --> D
    D --> G[클라이언트 응답]

이 구조에서 Memcached는 읽기 경로를 빠르게 만들고 DB는 데이터의 최종 원본(Source of Truth)을 유지한다. 캐시에 데이터가 없거나 만료되면 서버가 DB를 읽고 캐시를 채운 뒤 응답한다.

게임 서버의 Memcached 캐시 어사이드 요청 흐름과 DB 폴백 구조

캐시 어사이드 패턴은 어떻게 구현할까?

다음 예시는 Node.js 20과 TypeScript에서 플레이어 공개 프로필을 조회하는 형태다. 사용하는 Memcached 클라이언트 라이브러리에 따라 API는 달라질 수 있지만 핵심 순서는 동일하다.

1. 캐시 키와 TTL을 먼저 정의한다

키에는 데이터 종류, 버전, 식별자를 포함한다. v1 같은 버전을 넣어 두면 직렬화 구조 변경 시 이전 캐시를 안전하게 분리할 수 있다.

const PLAYER_PROFILE_TTL_SECONDS = 600;

function playerProfileKey(playerId: string): string {
  return `game:v1:player-profile:${playerId}`;
}

피해야 할 키 설계도 있다.

  • player:123처럼 데이터 종류와 버전이 불명확한 키
  • 닉네임처럼 변경 가능한 값을 식별자로 쓰는 키
  • 요청 파라미터 전체를 무분별하게 포함해 키 종류가 폭증하는 구조
  • 민감한 인증 토큰이나 개인정보 원문을 키에 넣는 방식

2. 캐시를 먼저 조회하고 미스일 때만 DB를 읽는다

캐시 값은 JSON 문자열로 저장하되 역직렬화 실패를 캐시 미스로 취급한다. 오래된 형식이나 손상된 값 때문에 정상 요청이 실패하지 않도록 하기 위함이다.

type PlayerProfile = {
  id: string;
  nickname: string;
  level: number;
  avatarId: number;
};

async function getPlayerProfile(playerId: string): Promise<PlayerProfile> {
  const key = playerProfileKey(playerId);
  const cached = await memcached.get(key);

  if (cached) {
    try {
      return JSON.parse(cached) as PlayerProfile;
    } catch {
      await memcached.delete(key);
    }
  }

  const profile = await playerRepository.findPublicProfile(playerId);
  if (!profile) {
    throw new Error("PLAYER_NOT_FOUND");
  }

  await memcached.set(key, JSON.stringify(profile), {
    expires: PLAYER_PROFILE_TTL_SECONDS,
  });

  return profile;
}

캐시 저장 실패는 보통 조회 요청 전체를 실패시킬 이유가 아니다. DB에서 읽은 정상 데이터가 있다면 응답하고 캐시 오류는 메트릭과 로그로 관찰하는 편이 안전하다.

3. 데이터 변경 후 캐시를 무효화한다

프로필 수정, 장비 변경, 친구 추가처럼 캐시 대상 데이터가 바뀌면 관련 키를 삭제한다. 다음 조회에서 DB를 읽어 최신 값을 캐시에 다시 채운다.

async function updatePlayerNickname(playerId: string, nickname: string) {
  await database.transaction(async (tx) => {
    await playerRepository.updateNickname(tx, playerId, nickname);
  });

  await memcached.delete(playerProfileKey(playerId));
}

DB 트랜잭션이 성공하기 전에 캐시를 삭제하면 트랜잭션 롤백 시 불필요한 캐시 미스만 발생한다. 반대로 DB 변경 뒤 캐시 삭제가 잠시 실패할 수 있으므로 TTL을 안전망으로 유지하고 정합성이 매우 중요한 데이터는 캐시 사용 범위를 좁혀야 한다.

TTL과 캐시 무효화는 어떻게 정할까?

TTL은 데이터가 오래되어도 허용되는 시간과 DB 보호 효과 사이의 절충값이다. 하나의 TTL을 모든 데이터에 적용하면 상점 메타데이터에는 너무 짧고 재화에는 너무 길 수 있다.

상황권장 전략이유
변경이 드문 읽기 중심 데이터긴 TTL + 변경 시 삭제높은 적중률을 얻기 좋음
변경 직후 최신 값이 필요DB 저장 후 관련 키 삭제다음 조회부터 최신 데이터 사용
약간의 지연이 허용되는 랭킹짧은 TTL갱신 비용과 최신성 균형
재화·결제·거래 상태DB 기준 처리 우선오래된 캐시가 게임 경제에 영향
존재하지 않는 ID 반복 조회짧은 네거티브 캐시DB를 향한 반복 미스 방지

캐시 만료가 한 시점에 몰리면 여러 서버가 동시에 DB를 조회하는 캐시 스탬피드(cache stampede)가 발생할 수 있다. TTL에 작은 무작위 범위를 더해 만료를 분산할 수 있다.

TTLactual=TTLbase+Random(0,Jitter)TTL_{actual} = TTL_{base} + Random(0, Jitter)

예를 들어 기본 TTL이 600초라면 0~60초 지터를 추가한다. 인기 플레이어 프로필처럼 단일 키의 요청량이 특히 높다면 서버 내부의 짧은 락, 요청 병합(single-flight), 백그라운드 갱신 같은 추가 대책도 검토한다.

Memcached 캐싱에서 자주 발생하는 문제는 무엇인가?

캐시 스탬피드는 왜 발생하는가?

인기 키가 만료된 순간 요청이 동시에 몰리면 모든 요청이 캐시 미스를 보고 DB로 향한다. TTL 지터만으로 부족한 경우에는 동일 키의 DB 조회를 하나로 합치고 나머지 요청은 그 결과를 기다리게 해야 한다. 이 제어는 각 게임 서버 프로세스 안에서만 적용할지, 분산 락까지 도입할지 트래픽 규모와 장애 복잡도를 고려해 결정한다.

캐시 오염과 오래된 데이터는 어떻게 막을까?

캐시에는 클라이언트가 보낸 원본 객체를 그대로 넣지 않는 편이 좋다. 공개 프로필처럼 응답에 필요한 필드만 가진 DTO를 만들고 서버 권한이나 내부 플래그, 결제 정보, 인증 정보는 저장 대상에서 제외한다. 또한 스키마가 바뀌면 캐시 키 버전을 올려 이전 직렬화 데이터를 분리한다.

Memcached 장애 시 게임 서버는 어떻게 동작해야 할까?

Memcached가 일시적으로 응답하지 않을 때 게임 서비스 전체가 중단되면 안 된다. 제한된 시간의 타임아웃을 두고 캐시 읽기·쓰기에 실패하면 DB로 폴백한다. 다만 캐시 장애 상태에서 모든 읽기가 DB로 몰릴 수 있으므로 DB 커넥션 풀, 쿼리 타임아웃, 서킷 브레이커, 읽기 제한 정책을 함께 준비해야 한다.

Memcached 장애 시 캐시 타임아웃 후 DB로 폴백하는 게임 서버 처리 정책

운영 지표는 무엇을 관찰해야 할까?

적중률만 높아도 캐시가 성공했다고 단정할 수 없다. 캐시가 오래된 데이터를 제공하거나 메모리 축출로 인해 DB 부하가 되살아날 수 있기 때문이다.

지표확인할 내용이상 징후 예시
Cache hit rate조회 중 캐시 적중 비율배포 후 급락, 키 설계 오류
Get latency캐시 조회 지연 시간네트워크 지연, 노드 과부하
Evictions메모리 부족으로 축출된 항목 수캐시 용량 부족 또는 값 과대
DB read QPSDB 읽기 요청량캐시 미스 증가와 동반 상승
Cache error rate타임아웃·연결 실패 비율Memcached 장애 또는 연결 풀 문제
Stale data reports오래된 데이터 관련 오류무효화 누락, TTL 과다

대시보드에서는 배포 시점, Memcached 노드 증설·교체 시점, DB 읽기 QPS와 캐시 적중률을 함께 비교하는 것이 좋다. 변화 원인을 한 지표만으로 판단하지 않게 된다.

자주 묻는 질문 (FAQ)

Memcached와 Redis 중 무엇을 선택해야 하나?

단순한 휘발성 키-값 캐시와 높은 처리량이 목적이라면 Memcached가 적합하다. 자료구조, 영속성, Pub/Sub, 분산 락 등 추가 기능이 필요하면 Redis를 검토한다. 다만 게임 경제의 원본 데이터를 캐시에만 저장해서는 안 된다는 원칙은 두 제품 모두 같다.

모든 DB 조회를 Memcached로 캐싱해야 하나?

아니다. 변경 직후 정확한 값이 필요한 재화, 결제, 거래, 운영 권한 데이터는 DB 기준으로 처리하거나 매우 제한적으로만 캐싱해야 한다. 먼저 DB 읽기 비중이 높고 데이터 변경 빈도가 낮은 조회부터 적용하는 것이 안전하다.

캐시 삭제와 TTL 중 하나만 사용해도 되나?

둘을 함께 쓰는 것이 일반적이다. 데이터 변경 뒤 삭제는 최신성을 높이고 TTL은 삭제 누락이나 캐시 장애 복구 뒤 오래된 데이터가 남는 시간을 제한한다.

정리

게임 서버의 Memcached 적용은 DB를 대체하는 작업이 아니라 반복 조회를 메모리로 옮겨 DB가 중요한 쓰기와 캐시 미스에 집중하도록 만드는 작업이다. 캐시 어사이드 패턴으로 시작해 데이터별 TTL을 분리하고 DB 변경 후 관련 키를 무효화하며 캐시 장애 시 DB 폴백과 관측 지표를 갖추면 멀티플레이 서비스의 읽기 확장성을 안정적으로 높일 수 있다.