[2026] C++ 캐싱 전략 | Redis·Memcached 활용 완벽 가이드 [#50-8]
이 글의 핵심
API 서버에서 같은 쿼리를 수천 번 반복하면 DB 부하가 급증하고 응답 지연이 발생합니다. 인기 상품 목록, 실시간 순위표, 세션 데이터처럼 읽기 비율이 높고 변경이 적은 데이터는 캐시에 두면 DB 부하를… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다.
들어가며: “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
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오: 왜 캐싱이 필요한가
- 시스템 아키텍처
- 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. 시스템 아키텍처
전체 구조
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 흐름
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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 설치
아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# Ubuntu/Debian
sudo apt-get install libhiredis-dev
# macOS (Homebrew)
brew install hiredis
# vcpkg
vcpkg install hiredis
기본 연결 및 GET/SET
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 래퍼 클래스
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 조회 후 캐시에 저장합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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와 캐시를 동시에 갱신합니다. 읽기 시 항상 캐시를 먼저 보므로, 쓰기 후에도 최신 데이터가 캐시에 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 + 스탬피드 방지)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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: 분산 락 (재고 차감)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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)
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 서버가 실행 중이 아니거나, 호스트/포트 설정이 잘못되었거나, 방화벽이 차단합니다. 해결법: 아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# Redis 실행 확인
redis-cli ping
# PONG 이면 정상
# 포트 확인 (Linux/macOS)
lsof -i :6379
아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 설정
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 한도를 초과했습니다.
해결법:
아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 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 등 다른 타입의 명령을 사용했습니다. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 사용: 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이 아닌 인코딩 문제.
해결법:
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 사용: 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 데이터가 불일치해요”
원인: 쓰기 시 캐시 무효화를 하지 않거나, 여러 서버가 다른 순서로 쓰기할 때 발생합니다. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 쓰기 시 반드시 캐시 무효화 또는 갱신
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 연결이 끊어졌거나, 메모리 부족, 또는 잘못된 명령 형식입니다. 해결법: 다음은 cpp를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 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. 성능 벤치마크
벤치마크 환경
다음은 간단한 text 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
- 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보다 약간 빠름 |
벤치마크 코드 예시
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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: 키 설계 규칙
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 권장 키 형식
{서비스}:{도메인}:{id}:{필드}
예시:
- api:product:123
- session:abc-def-ghi
- rank:leaderboard:daily
- lock:stock:456
패턴 2: TTL 설계
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 도메인별 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: 연결 풀 (멀티스레드)
다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 단일 연결은 스레드 안전하지 않음. 스레드당 연결 또는 연결 풀 사용
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: 모니터링
다음은 간단한 bash 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# Redis 모니터링 명령
redis-cli INFO stats # hits, misses
redis-cli INFO memory # used_memory
redis-cli SLOWLOG get 10 # 느린 명령
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
# 주요 지표
- hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses)
- used_memory
- connected_clients
- instantaneous_ops_per_sec
패턴 5: 장애 대응 (캐시 장애 시)
아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 캐시 실패 시 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++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.