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
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오: 왜 캐싱이 필요한가
- 시스템 아키텍처
- Redis 클라이언트 구현 (hiredis)
- 캐싱 패턴: Cache-Aside·Write-Through
- 완전한 캐싱 예제
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 정리
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 선택 가이드
| 항목 | Redis | Memcached |
|---|---|---|
| 데이터 구조 | String, Hash, List, Set, Sorted Set | String만 |
| 영속성 | RDB, AOF 지원 | 메모리만 (휘발성) |
| 트랜잭션 | MULTI/EXEC, Lua | 없음 |
| Pub/Sub | 지원 | 미지원 |
| 분산 락 | SET NX EX, Redlock | CAS (제한적) |
| 메모리 효율 | 상대적으로 높음 | 매우 높음 (단순) |
| 권장 용도 | 세션, 캐시, 순위표, 락 | 단순 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 직접 조회 | ~800 | 180 | DB 병목 |
| Redis 캐시 (캐시 히트 100%) | ~45,000 | 0.5 | 네트워크 + Redis |
| Redis 캐시 (히트율 95%) | ~38,000 | 2.1 | 5% DB 조회 혼합 |
| Memcached (히트율 100%) | ~52,000 | 0.4 | Redis보다 약간 빠름 |
벤치마크 코드 예시
// 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 대응 |
핵심 원칙:
- 읽기 비율 높은 데이터는 캐시로 DB 부하 감소
- 쓰기 시 반드시 캐시 무효화 또는 갱신
- TTL 만료 시 스탬피드 방지 (락, Early Expiration)
- 분산 환경에서는 Redis·Memcached로 캐시 공유
- 모니터링과 폴백으로 장애 대응
자주 묻는 질문 (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]