C++ 캐싱 전략 | Redis·Memcached 활용 완벽 가이드 [#50-8]

C++ 캐싱 전략 | Redis·Memcached 활용 완벽 가이드 [#50-8]

이 글의 핵심

C++ 캐싱 전략에 대한 실전 가이드입니다. Redis·Memcached 활용 완벽 가이드 [#50-8] 등을 예제와 함께 설명합니다.

들어가며: “DB 쿼리가 병목이라 API가 느려요”

왜 캐싱 전략인가

API 서버에서 같은 쿼리를 수천 번 반복하면 DB 부하가 급증하고 응답 지연이 발생합니다. “인기 상품 목록”, “실시간 순위표”, “세션 데이터”처럼 읽기 비율이 높고 변경이 적은 데이터캐시에 두면 DB 부하를 줄이고 응답 속도를 크게 개선할 수 있습니다. Redis 클론(#48-1)에서 인메모리 KV를 직접 구현했다면, 이 글은 실제 Redis·Memcached를 C++에서 활용하는 캐싱 전략을 다룹니다.

이 글에서 다루는 것:

  • 문제 시나리오: DB 병목, 캐시 스탬피드, 무효화 타이밍 등 실제 겪는 상황
  • 완전한 캐싱 예제: hiredis 기반 Redis 클라이언트, Cache-Aside·Write-Through 패턴
  • 자주 발생하는 에러: 연결 타임아웃, 직렬화 오류, 캐시 일관성 문제
  • 성능 벤치마크: 캐시 유무에 따른 QPS·지연시간 비교
  • 프로덕션 패턴: TTL 설계, 분산 락, 모니터링, 장애 대응

요구 환경: C++17 이상, Redis 6.x 이상 또는 Memcached

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.


목차

  1. 문제 시나리오: 왜 캐싱이 필요한가
  2. 시스템 아키텍처
  3. Redis 클라이언트 구현 (hiredis)
  4. 캐싱 패턴: Cache-Aside·Write-Through
  5. 완전한 캐싱 예제
  6. 자주 발생하는 에러와 해결법
  7. 성능 벤치마크
  8. 프로덕션 패턴
  9. 정리

1. 문제 시나리오: 왜 캐싱이 필요한가

시나리오 1: “인기 상품 API가 DB를 1초에 수천 번 쿼리해요”

"트래픽이 몰리면 DB CPU가 90%를 넘고 API 응답이 2초 이상 걸려요."
"같은 상품 목록을 매 요청마다 SELECT하는데, 99%가 동일한 결과예요."

원인: 읽기 비율이 높은 데이터를 매번 DB에서 조회하면, 동일 쿼리가 반복 실행되어 DB 부하가 급증합니다.

해결 포인트: Cache-Aside 패턴으로 첫 요청 시 DB에서 조회 후 Redis에 캐시하고, 이후 요청은 캐시에서 반환합니다. DB 쿼리 수가 크게 줄어듭니다.

시나리오 2: “캐시를 넣었는데 오래된 데이터가 보여요”

"상품 가격을 업데이트했는데 5분 동안 이전 가격이 표시돼요."
"캐시 TTL을 300초로 했는데, 업데이트 시점에 무효화를 안 해서요."

원인: TTL만 의존하고 쓰기 시 캐시 무효화를 하지 않으면, 데이터 변경 후에도 오래된 캐시가 서빙됩니다.

해결 포인트: Write-Through 또는 쓰기 시 명시적 삭제로, DB 업데이트와 동시에 캐시를 무효화하거나 갱신합니다.

시나리오 3: “캐시 스탬피드(Cache Stampede)로 DB가 터져요”

"캐시가 만료된 순간 수천 요청이 동시에 DB로 몰려요."
"한 번에 같은 쿼리가 5000번 실행돼서 DB가 멈췄어요."

원인: TTL 만료 시점에 모든 요청이 동시에 캐시 미스를 경험하고, 모두 DB에 쿼리를 보냅니다.

해결 포인트: Probabilistic Early Expiration(확률적 조기 만료), 분산 락(한 요청만 DB 조회), Stale-While-Revalidate 패턴으로 스탬피드를 방지합니다.

시나리오 4: “여러 서버가 같은 캐시를 써야 해요”

"로드 밸런서 뒤에 서버 4대가 있는데, 각 서버 메모리 캐시는 공유가 안 돼요."
"A 서버에서 캐시한 데이터를 B 서버에서 못 써요."

원인: 프로세스 내 메모리 캐시(std::unordered_map 등)는 서버 간 공유가 불가능합니다.

해결 포인트: Redis·Memcached 같은 분산 캐시를 사용해 모든 서버가 동일한 캐시 레이어에 접근합니다.

시나리오 5: “동시에 같은 리소스를 수정하려 해요”

"재고 차감을 여러 서버에서 동시에 하니 음수 재고가 나와요."
"분산 환경에서 락을 걸 방법이 없어요."

원인: 분산 환경에서는 단일 프로세스의 std::mutex로는 다른 서버의 동시 접근을 막을 수 없습니다.

해결 포인트: Redis 분산 락(SET NX EX) 또는 Redlock 알고리즘으로 분산 락을 구현합니다.

시나리오별 해결 방향 요약

시나리오특징권장 접근
DB 병목동일 쿼리 반복Cache-Aside, Redis 캐시
오래된 데이터쓰기 후 TTL만 의존Write-Through, 무효화
캐시 스탬피드TTL 만료 시 동시 요청분산 락, Early Expiration
다중 서버메모리 캐시 비공유Redis·Memcached 분산 캐시
동시 수정분산 락 필요Redis SET NX, Redlock

2. 시스템 아키텍처

전체 구조

flowchart TB
    subgraph Client["클라이언트"]
        C1[API 요청]
    end

    subgraph App["C++ 애플리케이션"]
        A1[캐시 레이어]
        A2[비즈니스 로직]
        A1 --> A2
    end

    subgraph Cache["캐시 레이어"]
        R[Redis / Memcached]
    end

    subgraph DB["영구 저장소"]
        D[(PostgreSQL / MySQL)]
    end

    C1 --> A1
    A1 -->|캐시 히트| R
    A1 -->|캐시 미스| A2
    A2 -->|조회| D
    A2 -->|캐시 저장| R
    A2 -->|쓰기 시 무효화| R

Cache-Aside 흐름

sequenceDiagram
    participant C as 클라이언트
    participant A as C++ 앱
    participant R as Redis
    participant D as DB

    C->>A: GET /product/123
    A->>R: GET product:123
    alt 캐시 히트
        R-->>A: 값 반환
        A-->>C: 200 OK (캐시)
    else 캐시 미스
        R-->>A: nil
        A->>D: SELECT ...
        D-->>A: 결과
        A->>R: SET product:123 (TTL)
        A-->>C: 200 OK (DB)
    end

Redis vs Memcached 선택 가이드

항목RedisMemcached
데이터 구조String, Hash, List, Set, Sorted SetString만
영속성RDB, AOF 지원메모리만 (휘발성)
트랜잭션MULTI/EXEC, Lua없음
Pub/Sub지원미지원
분산 락SET NX EX, RedlockCAS (제한적)
메모리 효율상대적으로 높음매우 높음 (단순)
권장 용도세션, 캐시, 순위표, 락단순 KV 캐시

3. Redis 클라이언트 구현 (hiredis)

hiredis 설치

# Ubuntu/Debian
sudo apt-get install libhiredis-dev

# macOS (Homebrew)
brew install hiredis

# vcpkg
vcpkg install hiredis

기본 연결 및 GET/SET

// redis_basic.cpp
// 컴파일: g++ -std=c++17 -o redis_basic redis_basic.cpp -lhiredis

#include <hiredis/hiredis.h>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>

// RAII로 redisContext 관리: 연결 해제 자동화
struct RedisConnection {
    redisContext* ctx = nullptr;

    RedisConnection(const char* host, int port) {
        ctx = redisConnect(host, port);
        if (ctx == nullptr) {
            throw std::runtime_error("Redis 연결 할당 실패");
        }
        if (ctx->err) {
            std::string err = ctx->errstr;
            redisFree(ctx);
            throw std::runtime_error("Redis 연결 실패: " + err);
        }
    }

    ~RedisConnection() {
        if (ctx) redisFree(ctx);
    }

    RedisConnection(const RedisConnection&) = delete;
    RedisConnection& operator=(const RedisConnection&) = delete;
};

int main() {
    try {
        RedisConnection conn("127.0.0.1", 6379);

        // SET key value
        redisReply* reply = (redisReply*)redisCommand(conn.ctx, "SET user:1 %s", "홍길동");
        if (reply->type == REDIS_REPLY_ERROR) {
            std::cerr << "SET 에러: " << reply->str << "\n";
            freeReplyObject(reply);
            return 1;
        }
        freeReplyObject(reply);

        // GET key
        reply = (redisReply*)redisCommand(conn.ctx, "GET user:1");
        if (reply->type == REDIS_REPLY_STRING) {
            std::cout << "user:1 = " << reply->str << "\n";
        } else if (reply->type == REDIS_REPLY_NIL) {
            std::cout << "user:1 = (없음)\n";
        }
        freeReplyObject(reply);

        // SET key value EX seconds (TTL 설정)
        reply = (redisReply*)redisCommand(conn.ctx, "SET cache:product:123 %s EX 300", "{\"name\":\"상품A\"}");
        freeReplyObject(reply);

    } catch (const std::exception& e) {
        std::cerr << "에러: " << e.what() << "\n";
        return 1;
    }
    return 0;
}

RAII 래퍼 클래스

// redis_wrapper.hpp
#pragma once

#include <hiredis/hiredis.h>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>

class RedisClient {
public:
    RedisClient(const std::string& host, int port = 6379) {
        ctx_ = redisConnect(host.c_str(), port);
        if (!ctx_) throw std::runtime_error("Redis 연결 할당 실패");
        if (ctx_->err) {
            std::string err = ctx_->errstr;
            redisFree(ctx_);
            throw std::runtime_error("Redis 연결 실패: " + err);
        }
    }

    ~RedisClient() {
        if (ctx_) redisFree(ctx_);
    }

    RedisClient(const RedisClient&) = delete;
    RedisClient& operator=(const RedisClient&) = delete;

    // GET: 존재하면 값, 없으면 nullopt
    std::optional<std::string> get(const std::string& key) {
        redisReply* reply = (redisReply*)redisCommand(ctx_, "GET %s", key.c_str());
        if (!reply) return std::nullopt;
        std::optional<std::string> result;
        if (reply->type == REDIS_REPLY_STRING) {
            result = std::string(reply->str, reply->len);
        }
        freeReplyObject(reply);
        return result;
    }

    // SET key value EX seconds (%b: 바이너리 안전, value.data(), value.size())
    bool set(const std::string& key, const std::string& value, int ttl_seconds = 0) {
        redisReply* reply;
        if (ttl_seconds > 0) {
            reply = (redisReply*)redisCommand(ctx_, "SET %s %b EX %d",
                                              key.c_str(), value.data(), value.size(), ttl_seconds);
        } else {
            reply = (redisReply*)redisCommand(ctx_, "SET %s %b",
                                              key.c_str(), value.data(), value.size());
        }
        if (!reply) return false;
        bool ok = (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK");
        freeReplyObject(reply);
        return ok;
    }

    // DEL key
    bool del(const std::string& key) {
        redisReply* reply = (redisReply*)redisCommand(ctx_, "DEL %s", key.c_str());
        if (!reply) return false;
        bool ok = (reply->type == REDIS_REPLY_INTEGER && reply->integer > 0);
        freeReplyObject(reply);
        return ok;
    }

    // SET key value NX EX seconds (분산 락용)
    bool setNX(const std::string& key, const std::string& value, int ttl_seconds) {
        redisReply* reply = (redisReply*)redisCommand(ctx_, "SET %s %b NX EX %d",
                                                      key.c_str(), value.data(), value.size(), ttl_seconds);
        if (!reply) return false;
        bool ok = (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK");
        freeReplyObject(reply);
        return ok;
    }

private:
    redisContext* ctx_ = nullptr;
};

4. 캐싱 패턴: Cache-Aside·Write-Through

Cache-Aside (Lazy Loading)

애플리케이션이 캐시를 직접 관리합니다. 읽기 시 캐시 먼저 확인, 미스 시 DB 조회 후 캐시에 저장합니다.

// cache_aside.cpp
std::optional<std::string> getProduct(RedisClient& redis, DbClient& db, int productId) {
    std::string key = "product:" + std::to_string(productId);

    // 1. 캐시 조회
    auto cached = redis.get(key);
    if (cached) return cached;

    // 2. 캐시 미스 → DB 조회
    auto product = db.queryProduct(productId);
    if (!product) return std::nullopt;

    // 3. 캐시에 저장 (TTL 300초)
    std::string json = product->toJson();
    redis.set(key, json, 300);

    return json;
}

Write-Through

쓰기 시 DB와 캐시를 동시에 갱신합니다. 읽기 시 항상 캐시를 먼저 보므로, 쓰기 후에도 최신 데이터가 캐시에 있습니다.

// write_through.cpp
bool updateProduct(RedisClient& redis, DbClient& db, int productId, const Product& product) {
    // 1. DB 업데이트
    if (!db.updateProduct(productId, product)) return false;

    // 2. 캐시 갱신 (또는 삭제 후 다음 읽기 시 로드)
    std::string key = "product:" + std::to_string(productId);
    redis.set(key, product.toJson(), 300);

    return true;
}

Write-Behind (Write-Back)

쓰기를 캐시에만 먼저 기록하고, 비동기로 DB에 반영합니다. 쓰기 성능은 높지만 일관성·장애 복구가 복잡합니다. 이 글에서는 다루지 않습니다.


5. 완전한 캐싱 예제

예제 1: API 응답 캐싱 (Cache-Aside + 스탬피드 방지)

// api_cache.cpp
// 캐시 스탬피드 방지: 분산 락으로 한 요청만 DB 조회

#include "redis_wrapper.hpp"
#include <chrono>
#include <random>
#include <sstream>
#include <thread>

std::string getCachedOrFetch(RedisClient& redis, DbClient& db,
                             const std::string& cacheKey,
                             std::function<std::string()> fetcher,
                             int ttl = 300) {
    // 1. 캐시 조회
    auto cached = redis.get(cacheKey);
    if (cached) return *cached;

    // 2. 락 획득 시도 (lock:cacheKey, 10초 TTL)
    std::string lockKey = "lock:" + cacheKey;
    std::string lockValue = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
    bool locked = redis.setNX(lockKey, lockValue, 10);

    if (locked) {
        // 락 획득 성공 → DB 조회
        std::string data = fetcher();
        redis.set(cacheKey, data, ttl);
        redis.del(lockKey);  // 락 해제
        return data;
    }

    // 3. 락 획득 실패 → 짧은 대기 후 재조회 (다른 요청이 캐시 완료 대기)
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    for (int i = 0; i < 20; ++i) {
        cached = redis.get(cacheKey);
        if (cached) return *cached;
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }

    // 타임아웃: 직접 조회 (최후 수단)
    return fetcher();
}

예제 2: 세션 저장 (Redis Hash)

// session_cache.cpp
// 세션 데이터를 Redis Hash로 저장

#include "redis_wrapper.hpp"
#include <hiredis/hiredis.h>

class SessionStore {
public:
    SessionStore(redisContext* ctx) : ctx_(ctx) {}

    void setSession(const std::string& sessionId,
                    const std::string& userId,
                    const std::string& data,
                    int ttlSeconds = 3600) {
        std::string key = "session:" + sessionId;
        redisReply* r;

        r = (redisReply*)redisCommand(ctx_, "HSET %s user_id %s data %s",
                                      key.c_str(), userId.c_str(), data.c_str());
        freeReplyObject(r);

        r = (redisReply*)redisCommand(ctx_, "EXPIRE %s %d", key.c_str(), ttlSeconds);
        freeReplyObject(r);
    }

    std::optional<std::string> getSession(const std::string& sessionId) {
        std::string key = "session:" + sessionId;
        redisReply* r = (redisReply*)redisCommand(ctx_, "HGET %s data", key.c_str());
        if (!r || r->type != REDIS_REPLY_STRING) {
            if (r) freeReplyObject(r);
            return std::nullopt;
        }
        std::string result(r->str, r->len);
        freeReplyObject(r);
        return result;
    }

    void extendSession(const std::string& sessionId, int ttlSeconds = 3600) {
        std::string key = "session:" + sessionId;
        redisReply* r = (redisReply*)redisCommand(ctx_, "EXPIRE %s %d", key.c_str(), ttlSeconds);
        freeReplyObject(r);
    }

private:
    redisContext* ctx_;
};

예제 3: 분산 락 (재고 차감)

// distributed_lock.cpp
// Redis SET NX EX로 분산 락 구현

#include "redis_wrapper.hpp"
#include <chrono>
#include <string>
#include <thread>

class DistributedLock {
public:
    DistributedLock(RedisClient& redis, const std::string& resource, int ttlSeconds = 10)
        : redis_(redis), resource_(resource), key_("lock:" + resource), ttl_(ttlSeconds) {}

    bool tryLock() {
        token_ = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
        return redis_.setNX(key_, token_, ttl_);
    }

    void unlock() {
        // Lua로 "같은 token일 때만 DEL" 해야 안전 (다른 프로세스의 락을 해제하지 않도록)
        // 여기서는 단순화: DEL만 수행 (실제로는 Lua 스크립트 권장)
        redis_.del(key_);
    }

    template<typename Func>
    bool withLock(Func&& f) {
        if (!tryLock()) return false;
        bool ok = false;
        try {
            f();
            ok = true;
        } catch (...) {
            unlock();
            throw;
        }
        unlock();
        return ok;
    }

private:
    RedisClient& redis_;
    std::string resource_;
    std::string key_;
    std::string token_;
    int ttl_;
};

// 사용 예: 재고 차감
bool decrementStock(RedisClient& redis, DbClient& db, int productId, int count) {
    std::string key = "stock:" + std::to_string(productId);
    DistributedLock lock(redis, key, 5);

    return lock.withLock([&]() {
        int current = db.getStock(productId);
        if (current < count) throw std::runtime_error("재고 부족");
        db.updateStock(productId, current - count);
    });
}

예제 4: Memcached 클라이언트 (libmemcached)

// memcached_example.cpp
// 컴파일: g++ -std=c++17 -o memcached_example memcached_example.cpp -lmemcached
// Ubuntu: sudo apt-get install libmemcached-dev

#include <libmemcached/memcached.h>
#include <iostream>
#include <string>

int main() {
    memcached_st* memc = memcached_create(nullptr);
    memcached_return_t rc;
    memcached_server_st* servers = memcached_server_list_append(nullptr, "127.0.0.1", 11211, &rc);
    rc = memcached_server_push(memc, servers);
    memcached_server_list_free(servers);

    if (rc != MEMCACHED_SUCCESS) {
        std::cerr << "Memcached 서버 연결 실패: " << memcached_strerror(memc, rc) << "\n";
        memcached_free(memc);
        return 1;
    }

    // SET key, value, TTL 300초
    rc = memcached_set(memc, "user:1", 6, "홍길동", 9, 300, 0);
    if (rc != MEMCACHED_SUCCESS) {
        std::cerr << "SET 실패: " << memcached_strerror(memc, rc) << "\n";
    }

    // GET
    size_t value_len;
    uint32_t flags;
    char* value = memcached_get(memc, "user:1", 6, &value_len, &flags, &rc);
    if (rc == MEMCACHED_SUCCESS && value) {
        std::cout << "user:1 = " << std::string(value, value_len) << "\n";
        free(value);
    }

    memcached_free(memc);
    return 0;
}

6. 자주 발생하는 에러와 해결법

문제 1: “Connection refused” / “Connection timeout”

원인: Redis 서버가 실행 중이 아니거나, 호스트/포트 설정이 잘못되었거나, 방화벽이 차단합니다.

해결법:

# Redis 실행 확인
redis-cli ping
# PONG 이면 정상

# 포트 확인 (Linux/macOS)
lsof -i :6379
// ❌ 잘못된 설정
RedisClient redis("localhost", 6379);  // localhost가 127.0.0.1로 해석 안 될 수 있음

// ✅ 올바른 설정: 환경 변수 또는 설정 파일 사용
const char* host = std::getenv("REDIS_HOST");
if (!host) host = "127.0.0.1";
int port = 6379;
if (const char* p = std::getenv("REDIS_PORT")) port = std::atoi(p);
RedisClient redis(host, port);

문제 2: “OOM command not allowed when used memory > ‘maxmemory’”

원인: Redis maxmemory 한도를 초과했습니다.

해결법:

# redis.conf 또는 redis-cli로 maxmemory 확인
redis-cli CONFIG GET maxmemory

# maxmemory-policy 설정 (예: allkeys-lru)
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# redis.conf
maxmemory 256mb
maxmemory-policy allkeys-lru   # 메모리 부족 시 LRU로 키 삭제

문제 3: “WRONGTYPE Operation against a key holding the wrong kind of value”

원인: String으로 저장된 키에 HGET, LPUSH 등 다른 타입의 명령을 사용했습니다.

해결법:

// ❌ 잘못된 사용: product:123이 String인데 HGET 시도
redisCommand(ctx, "HGET product:123 name");  // WRONGTYPE 에러

// ✅ 키 타입 확인 후 사용
redisReply* typeReply = (redisReply*)redisCommand(ctx, "TYPE product:123");
if (typeReply->str && std::string(typeReply->str) == "hash") {
    // HGET 사용
} else {
    // GET 사용
}
freeReplyObject(typeReply);

문제 4: “캐시에 저장한 데이터가 깨져요” (직렬화/인코딩)

원인: 바이너리 데이터를 문자열로 저장할 때 널 문자(\0) 포함, 또는 UTF-8이 아닌 인코딩 문제.

해결법:

// ❌ 잘못된 사용: std::string에 \0 포함 시 redisCommand %s가 중간에 끊김
std::string data = "hello\0world";  // 5바이트만 전송됨
redisCommand(ctx, "SET key %s", data.c_str());

// ✅ 바이너리 안전: %b 사용
redisCommand(ctx, "SET key %b", data.data(), data.size());

문제 5: “캐시와 DB 데이터가 불일치해요”

원인: 쓰기 시 캐시 무효화를 하지 않거나, 여러 서버가 다른 순서로 쓰기할 때 발생합니다.

해결법:

// ✅ 쓰기 시 반드시 캐시 무효화 또는 갱신
void updateProduct(int id, const Product& p) {
    db.update(id, p);
    redis.del("product:" + std::to_string(id));  // 무효화
    // 또는 redis.set("product:" + id, p.toJson(), 300);  // 갱신
}

문제 6: “redisCommand 호출 후 reply가 NULL이에요”

원인: Redis 연결이 끊어졌거나, 메모리 부족, 또는 잘못된 명령 형식입니다.

해결법:

// ✅ NULL 체크 및 에러 처리
redisReply* reply = (redisReply*)redisCommand(ctx, "GET %s", key.c_str());
if (!reply) {
    // 연결 끊김 등
    if (ctx->err) {
        std::cerr << "Redis 에러: " << ctx->errstr << "\n";
        // 재연결 로직
    }
    return std::nullopt;
}
if (reply->type == REDIS_REPLY_ERROR) {
    std::cerr << "Redis 명령 에러: " << reply->str << "\n";
    freeReplyObject(reply);
    return std::nullopt;
}
// ... 정상 처리
freeReplyObject(reply);

7. 성능 벤치마크

벤치마크 환경

- CPU: Apple M1 / Intel Xeon 8코어
- Redis: 6.2, localhost
- 데이터: 상품 JSON 약 500바이트
- TTL: 300초

결과 요약

시나리오QPS (초당 요청)P99 지연 (ms)비고
DB 직접 조회~800180DB 병목
Redis 캐시 (캐시 히트 100%)~45,0000.5네트워크 + Redis
Redis 캐시 (히트율 95%)~38,0002.15% DB 조회 혼합
Memcached (히트율 100%)~52,0000.4Redis보다 약간 빠름

벤치마크 코드 예시

// benchmark.cpp
#include "redis_wrapper.hpp"
#include <chrono>
#include <iostream>
#include <thread>

void benchmarkGet(RedisClient& redis, const std::string& key, int iterations) {
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) {
        redis.get(key);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    double qps = iterations * 1000.0 / ms;
    std::cout << "GET " << iterations << "회: " << ms << "ms, QPS=" << qps << "\n";
}

int main() {
    RedisClient redis("127.0.0.1", 6379);
    redis.set("bench:key", "value", 60);

    benchmarkGet(redis, "bench:key", 100000);
    return 0;
}

TTL에 따른 메모리 사용량 (예시)

키 개수TTL 60초TTL 300초TTL 3600초
10만~50MB~50MB~50MB
100만~500MB~500MB~520MB
1000만--~5.2GB

실제 값은 키/값 크기에 따라 다름


8. 프로덕션 패턴

패턴 1: 키 설계 규칙

# 권장 키 형식
{서비스}:{도메인}:{id}:{필드}

예시:
- api:product:123
- session:abc-def-ghi
- rank:leaderboard:daily
- lock:stock:456

패턴 2: TTL 설계

// 도메인별 TTL 가이드
const int TTL_PRODUCT = 300;      // 상품: 5분 (가격 변경 빈도 낮음)
const int TTL_RANKING = 60;       // 순위표: 1분 (실시간성)
const int TTL_SESSION = 3600;     // 세션: 1시간
const int TTL_API_RESPONSE = 60;  // API 응답: 1분

패턴 3: 연결 풀 (멀티스레드)

// 단일 연결은 스레드 안전하지 않음. 스레드당 연결 또는 연결 풀 사용
class RedisPool {
public:
    RedisClient& acquire() {
        std::lock_guard lock(mutex_);
        if (available_.empty()) {
            available_.push_back(std::make_unique<RedisClient>("127.0.0.1", 6379));
        }
        auto& client = available_.back();
        available_.pop_back();
        inUse_.push_back(std::move(client));
        return *inUse_.back();
    }
    void release(RedisClient& client) { /* 반환 */ }
private:
    std::mutex mutex_;
    std::vector<std::unique_ptr<RedisClient>> available_, inUse_;
};

패턴 4: 모니터링

# Redis 모니터링 명령
redis-cli INFO stats        # hits, misses
redis-cli INFO memory      # used_memory
redis-cli SLOWLOG get 10   # 느린 명령
# 주요 지표
- hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
- used_memory
- connected_clients
- instantaneous_ops_per_sec

패턴 5: 장애 대응 (캐시 장애 시)

// 캐시 실패 시 DB로 폴백 (Circuit Breaker 패턴)
std::string getWithFallback(const std::string& key) {
    try {
        auto cached = redis.get(key);
        if (cached) return *cached;
    } catch (const std::exception& e) {
        logError("Redis 실패, DB 폴백: ", e.what());
        // Redis 장애 시 DB만 사용
    }
    return db.fetch(key);
}

구현 체크리스트

  • Redis/Memcached 호스트·포트 환경 변수화
  • 연결 실패 시 재시도 및 폴백
  • 모든 redisReply freeReplyObject 호출
  • 쓰기 시 캐시 무효화 또는 갱신
  • TTL 도메인별 설계
  • 분산 락 사용 시 Lua로 안전한 해제
  • 모니터링 (hit rate, memory, slow log)
  • 바이너리 데이터는 %b 사용

9. 정리

항목설명
Cache-Aside읽기 시 캐시 → 미스 시 DB → 캐시 저장
Write-Through쓰기 시 DB + 캐시 동시 갱신
분산 락SET NX EX로 락, Lua로 안전한 해제
스탬피드 방지락 또는 Probabilistic Early Expiration
키 설계서비스:도메인:id 형식
에러 처리연결 실패, NULL reply, WRONGTYPE 대응

핵심 원칙:

  1. 읽기 비율 높은 데이터는 캐시로 DB 부하 감소
  2. 쓰기 시 반드시 캐시 무효화 또는 갱신
  3. TTL 만료 시 스탬피드 방지 (락, Early Expiration)
  4. 분산 환경에서는 Redis·Memcached로 캐시 공유
  5. 모니터링과 폴백으로 장애 대응

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. API 응답 캐싱, 세션 저장, 실시간 순위표, 분산 락 구현 등 고성능 시스템에 필수적입니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. Redis와 Memcached 중 뭘 써야 하나요?

A. 단순 KV 캐시만 필요하면 Memcached가 메모리 효율과 속도 면에서 유리합니다. 세션, 순위표, Pub/Sub, 분산 락 등이 필요하면 Redis를 선택하세요.

Q. 캐시 hit rate는 얼마나 나와야 하나요?

A. 읽기 위주 API에서는 90% 이상을 목표로 합니다. 80% 이하면 키 설계, TTL, 무효화 전략을 재검토하세요.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. Redis 공식 문서, hiredis GitHub를 참고하세요. Redis 고급 활용(#52-3)에서 Pub/Sub·파이프라인·Lua를 다룹니다.

한 줄 요약: Redis·Memcached로 Cache-Aside·Write-Through를 구현하고, 분산 락·스탬피드 방지까지 적용하면 프로덕션 수준의 캐싱 시스템을 구축할 수 있습니다.

다음 글: [C++ 실전 가이드 #51-1] 프로파일링 도구 마스터

이전 글: [C++ 실전 가이드 #50-7] 메시지 큐


관련 글

  • C++ 채팅 서버 아키텍처 완벽 가이드 | Acceptor-Worker·방 관리·메시지 라우팅·커넥션 풀
  • C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]
  • C++ 이미지 처리 완벽 가이드 | OpenCV 필터·변환·파이프라인 [#50-10]
  • C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드
  • C++ 대용량 파일 업로드 완벽 가이드 | S3 멀티파트·MinIO·CDN 연동 [#50-11]