C++ 애플리케이션 캐싱: hiredis로 Redis 연동, Cache-Aside·Write-Through, 무효화와 만료
들어가며: “DB 쿼리가 병목이라 API가 느려요”
API 서버에서 같은 쿼리를 수천 번 반복하면 DB 부하가 급증하고 응답 지연이 발생합니다. “인기 상품 목록”, “실시간 순위표”, “세션 데이터”처럼 읽기 비율이 높고 변경이 적은 데이터는 캐시에 두면 DB 부하를 줄이고 응답 속도를 크게 개선할 수 있습니다. Redis 클론(#48-1)에서 인메모리 KV를 직접 구현했다면, 이 글은 실제 Redis·Memcached를 C++에서 활용하는 캐싱 전략을 다룹니다.
캐시는 성능 문제를 풀어 주는 대신 일관성 문제를 새로 만듭니다. 데이터가 두 곳(DB와 캐시)에 존재하는 순간 “어느 쪽이 최신인가”라는 질문이 생기고, 캐시 서버가 죽었을 때의 동작도 설계해야 합니다. 그래서 이 글은 hiredis로 Redis에 붙는 코드뿐 아니라, 각 패턴이 어떤 순서로 실패하는지와 그때 무엇을 감수하는지를 함께 설명합니다.
요구 환경: C++17 이상, Redis 6.x 이상 또는 Memcached
DB 병목·오래된 데이터·캐시 스탬피드: 캐싱이 필요한 상황
"트래픽이 몰리면 DB CPU가 90%를 넘고 API 응답이 2초 이상 걸려요."
"같은 상품 목록을 매 요청마다 SELECT하는데, 99%가 동일한 결과예요."
원인: 읽기 비율이 높은 데이터를 매번 DB에서 조회하면, 동일 쿼리가 반복 실행되어 DB 부하가 급증합니다. 해결 포인트: Cache-Aside 패턴으로 첫 요청 시 DB에서 조회 후 Redis에 캐시하며, 이후 요청은 캐시에서 반환합니다. DB 쿼리 수가 크게 줄어듭니다.
"상품 가격을 업데이트했는데 5분 동안 이전 가격이 표시돼요."
"캐시 TTL을 300초로 했는데, 업데이트 시점에 무효화를 안 해서요."
원인: TTL만 의존하고 쓰기 시 캐시 무효화를 하지 않으면, 데이터 변경 후에도 오래된 캐시가 서빙됩니다. 해결 포인트: Write-Through 또는 쓰기 시 명시적 삭제로, DB 업데이트와 동시에 캐시를 무효화하거나 갱신합니다.
"캐시가 만료된 순간 수천 요청이 동시에 DB로 몰려요."
"한 번에 같은 쿼리가 5000번 실행돼서 DB가 멈췄습니다."
원인: TTL 만료 시점에 모든 요청이 동시에 캐시 미스를 경험하며, 모두 DB에 쿼리를 보냅니다. 캐시 재생성 쿼리가 무거울수록 문제가 커집니다. 쿼리 하나가 1초 걸리는 집계라면 그 1초 동안 들어온 모든 요청이 같은 쿼리를 실행하고, DB가 느려지면 쿼리 시간이 더 길어져 더 많은 요청이 몰리는 악순환이 됩니다. 해결 포인트: Probabilistic Early Expiration(확률적 조기 만료), 분산 락(한 요청만 DB 조회), Stale-While-Revalidate 패턴으로 스탬피드를 방지합니다.
"로드 밸런서 뒤에 서버 4대가 있는데, 각 서버 메모리 캐시는 공유가 안 돼요."
"A 서버에서 캐시한 데이터를 B 서버에서 못 써요."
원인: 프로세스 내 메모리 캐시(std::unordered_map 등)는 서버 간 공유가 불가능합니다.
해결 포인트: Redis·Memcached 같은 분산 캐시를 사용해 모든 서버가 동일한 캐시 레이어에 접근합니다.
"재고 차감을 여러 서버에서 동시에 하니 음수 재고가 나와요."
"분산 환경에서 락을 걸 방법이 없습니다."
원인: 분산 환경에서는 단일 프로세스의 std::mutex로는 다른 서버의 동시 접근을 막을 수 없습니다.
해결 포인트: Redis 분산 락(SET NX EX) 또는 Redlock 알고리즘으로 분산 락을 구현합니다.
시나리오별 해결 방향 요약
| 시나리오 | 특징 | 권장 접근 |
|---|---|---|
| DB 병목 | 동일 쿼리 반복 | Cache-Aside, Redis 캐시 |
| 오래된 데이터 | 쓰기 후 TTL만 의존 | Write-Through, 무효화 |
| 캐시 스탬피드 | TTL 만료 시 동시 요청 | 분산 락, Early Expiration |
| 다중 서버 | 메모리 캐시 비공유 | Redis·Memcached 분산 캐시 |
| 동시 수정 | 분산 락 필요 | Redis SET NX, Redlock |
Cache-Aside 구조와 Redis·Memcached 선택
전체 구조
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 캐시 |
Redis 클라이언트 구현 (hiredis)
hiredis는 Redis 프로젝트가 관리하는 공식 C 클라이언트로, 동기 API(redisCommand)와 비동기 API(redisAsyncContext)를 모두 제공합니다. C++ 전용 래퍼(redis-plus-plus 등)도 있지만 내부적으로 hiredis를 쓰는 경우가 많으므로, hiredis의 동작 방식을 알아 두면 어떤 래퍼를 쓰든 오류를 이해하기 쉽습니다. 특히 redisReply는 호출자가 freeReplyObject로 해제해야 하는 C 구조체이고, redisContext 하나는 스레드 안전하지 않다는 두 가지가 이후 모든 코드의 전제입니다.
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;
}
이 최소 예제는 동작 확인용이라 몇 가지를 생략했습니다. redisCommand는 연결이 끊기거나 I/O 오류가 나면 NULL을 반환하는데, 위 코드는 reply->type을 바로 읽으므로 Redis가 재시작되는 순간 세그폴트가 납니다. 또 redisConnect는 타임아웃 없이 블로킹하므로, 방화벽이 패킷을 조용히 버리는 환경에서는 OS의 TCP 연결 타임아웃(수십 초~몇 분)까지 멈춰 있습니다. 운영 코드라면 redisConnectWithTimeout(host, port, {1, 0})으로 연결 타임아웃을, redisSetTimeout(ctx, {0, 500000})으로 명령 타임아웃을 거는 것이 기본입니다. 아래 래퍼가 NULL 검사를 추가한 것도 이 때문입니다.
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;
};
Cache-Aside·Write-Through·Write-Behind 비교
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;
}
Cache-Aside의 장점은 단순함과 장애 격리입니다. 캐시에는 실제로 읽힌 데이터만 올라가고, Redis가 죽어도 모든 요청이 DB로 가서 (느리지만) 서비스는 동작합니다. 대신 첫 요청은 항상 느리고(콜드 스타트), 아래와 같은 경쟁 조건이 남습니다.
- 요청 A가 캐시 미스로 DB에서 옛 값을 읽습니다.
- 그사이 요청 B가 DB를 갱신하고 캐시를 삭제합니다.
- 요청 A가 방금 읽은 옛 값을 캐시에 씁니다.
이제 캐시는 TTL이 끝날 때까지 옛 값을 서빙합니다. 확률은 낮지만 트래픽이 많으면 실제로 일어나며, “가격을 바꿨는데 일부 사용자에게만 한동안 옛 가격이 보인다”는 형태로 나타납니다. TTL을 무한대로 두지 않는 것이 이 문제의 안전망이고, 더 엄격해야 한다면 쓰기 직후 짧은 지연을 두고 한 번 더 삭제하는 “지연 이중 삭제”나, DB 변경 로그(CDC)를 구독해 캐시를 무효화하는 방식을 씁니다.
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;
}
여기서 “캐시 갱신”과 “캐시 삭제”는 비슷해 보이지만 동시성 측면에서 차이가 큽니다. 두 요청이 같은 상품을 거의 동시에 수정하면, DB에는 B의 값이 마지막으로 쓰였는데 캐시에는 A의 set이 나중에 도착해 A의 값이 남을 수 있습니다. 네트워크 지연만으로 순서가 뒤집히므로 막기 어렵습니다. 반면 삭제는 순서가 뒤집혀도 결과가 같습니다(둘 다 캐시를 비울 뿐). 그래서 여러 서버가 같은 키를 쓰는 환경에서는 쓰기 시 set보다 del을 기본으로 하고, 다음 읽기가 DB에서 최신 값을 다시 채우게 하는 편이 안전합니다. 또 DB 갱신은 성공했는데 캐시 갱신이 실패하는 경우(Redis 타임아웃)도 처리해야 합니다. 이 함수는 그때도 true를 반환하므로, 최소한 실패를 로그로 남기고 TTL이 짧게 유지되도록 해야 합니다.
Write-Behind (Write-Back)
쓰기를 캐시에만 먼저 기록하며, 비동기로 DB에 반영합니다. 쓰기 성능은 높지만, 캐시 노드가 DB에 반영하기 전에 죽으면 그 사이의 쓰기가 사라집니다. 조회수·좋아요 수처럼 일부 유실을 감수할 수 있는 카운터를 모아서 주기적으로 DB에 반영하는 용도가 아니라면 권하기 어렵고, 이 글에서는 코드로 다루지 않습니다.
스탬피드 방지·세션 저장·분산 락 예제
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();
}
이 스탬피드 방지 코드는 몇 가지를 의도적으로 단순화했습니다. 락 값으로 쓴 steady_clock 카운트는 서버마다 기준점이 달라 서로 다른 서버에서 같은 값이 나올 수 있으므로, 실제로는 UUID나 std::random_device 기반 난수를 씁니다. fetcher()가 예외를 던지면 del이 호출되지 않아 락이 10초 동안 남고, 그동안 다른 요청은 모두 1초(50ms × 20)를 기다린 뒤 결국 직접 DB를 조회합니다. 또 대기하는 요청이 스레드를 잡고 sleep하므로, 동기 서버에서 인기 키 하나가 만료되면 워커 스레드가 대량으로 묶일 수 있습니다.
그래서 실무에서는 락과 함께 stale-while-revalidate를 자주 씁니다. 캐시 값에 “논리적 만료 시각”을 함께 저장하고 Redis TTL은 그보다 넉넉하게 둔 뒤, 논리적으로 만료된 값을 읽은 요청은 옛 값을 그대로 반환하면서 락을 잡은 한 요청만 백그라운드에서 갱신합니다. 사용자는 기다리지 않고, DB에는 갱신 쿼리가 하나만 갑니다. 약간 오래된 데이터를 잠깐 보여 줘도 되는 상품 목록·순위표 같은 데이터에 잘 맞습니다.
세션 저장 (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_;
};
HSET과 EXPIRE를 따로 보내는 것에도 작은 틈이 있습니다. 두 명령 사이에 프로세스가 죽거나 연결이 끊기면 TTL 없는 세션이 남아 영원히 메모리를 차지합니다. MULTI/EXEC로 묶거나, 파이프라인(redisAppendCommand 두 번 후 redisGetReply 두 번)으로 보내면 원자성과 왕복 횟수 문제를 함께 줄일 수 있습니다. 또 EXPIRE는 해시 전체에 걸리며, 필드별 TTL은 Redis 7.4의 HEXPIRE 이전에는 지원되지 않았습니다.
분산 락 (재고 차감)
// 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);
});
}
이 분산 락 예제에는 운영에서 실제로 문제가 되는 함정이 두 가지 있습니다.
첫째, 코드 주석에도 적었듯이 토큰을 확인하지 않고 DEL하면 남의 락을 풀 수 있습니다. A가 락을 잡고 작업하는 사이 TTL(5초)이 지나 락이 만료되고, B가 새로 락을 잡은 뒤, 뒤늦게 끝난 A가 DEL을 호출하면 B의 락이 지워집니다. 이제 C도 락을 잡을 수 있어 B와 C가 동시에 재고를 차감합니다. 해제는 “내 토큰일 때만 삭제”를 원자적으로 수행하는 Lua 스크립트로 해야 합니다.
// 토큰이 일치할 때만 락 해제 (GET과 DEL을 원자적으로)
const char* kUnlockScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then "
" return redis.call('del', KEYS[1]) "
"else return 0 end";
redisReply* r = (redisReply*)redisCommand(ctx, "EVAL %s 1 %s %s",
kUnlockScript, key.c_str(), token.c_str());
if (r) freeReplyObject(r);
둘째, Lua로 해제를 고쳐도 락이 작업 도중 만료되는 문제는 남습니다. GC 멈춤, 느린 DB 쿼리, 네트워크 지연으로 작업이 TTL보다 오래 걸리면 두 프로세스가 동시에 임계 구역에 들어갑니다. Redis 락은 “대부분의 경우 한 명만”을 보장하는 효율성용으로는 좋지만, 재고처럼 틀리면 돈이 오가는 정확성용으로는 부족하다는 것이 잘 알려진 결론입니다. 제가 재고 차감을 설계한다면 락보다 DB의 원자적 조건부 갱신을 먼저 씁니다.
UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?;
-- 영향받은 행이 0이면 재고 부족
이 쿼리는 DB가 행 단위로 원자성을 보장하므로 분산 락 없이도 음수 재고가 생기지 않습니다. 분산 락은 “같은 무거운 작업을 여러 서버가 중복 실행하지 않게” 하는 용도(캐시 재생성, 배치 작업 중복 방지)에 쓰는 것이 적합합니다.
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;
}
OOM maxmemory, WRONGTYPE, NULL reply: Redis 에러 해결
”Connection refused” / “Connection timeout”
원인: Redis 서버가 실행 중이 아니거나, 호스트/포트 설정이 잘못되었거나, 방화벽이 차단합니다. 해결법:
# Redis 실행 확인
redis-cli ping
# PONG 이면 정상
# 포트 확인 (Linux/macOS)
lsof -i :6379
// ❌ 잘못된 설정
RedisClient redis("localhost", 6379); // localhost가 IPv6 ::1로 먼저 해석되면, 127.0.0.1에만 바인딩된 Redis에 연결 실패
// ✅ 올바른 설정: 환경 변수 또는 설정 파일 사용
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);
“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로 키 삭제
“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);
“캐시에 저장한 데이터가 깨져요” (직렬화/인코딩)
원인: 바이너리 데이터를 문자열로 저장할 때 널 문자(\0) 포함, 또는 UTF-8이 아닌 인코딩 문제.
해결법:
// ❌ 잘못된 사용: std::string에 \0 포함 시 redisCommand %s가 중간에 끊김
std::string data("hello\0world", 11); // \0 포함 11바이트
redisCommand(ctx, "SET key %s", data.c_str()); // %s는 C 문자열이라 "hello" 5바이트만 전송
// ✅ 바이너리 안전: %b 사용
redisCommand(ctx, "SET key %b", data.data(), data.size());
std::string data = "hello\0world";처럼 쓰면 std::string 생성자 자체가 문자열 리터럴을 첫 \0에서 자르므로, Redis로 보내기 전에 이미 5바이트짜리 문자열이 됩니다. 바이너리를 다룰 때는 길이를 명시하는 생성자나 std::string_literals의 "..."s를 써야 합니다. 직렬화 포맷으로 Protobuf나 MessagePack을 쓰면 값에 \0이 흔히 들어가므로 %b는 선택이 아니라 필수입니다. 또 %s로 넘긴 키나 값에 공백이 있어도 hiredis가 하나의 인자로 전달하므로 문제는 없지만, 명령 문자열 자체에 값을 붙여("SET key " + value) 넘기면 공백에서 인자가 쪼개지므로 항상 포맷 지정자를 써야 합니다.
”캐시와 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); // 갱신
}
“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);
캐시 효과를 측정하는 방법
캐시의 효과는 데이터 크기, 네트워크 거리, DB 쿼리 비용, 히트율에 따라 몇 배에서 수백 배까지 달라지므로, 다른 환경의 수치를 그대로 믿기보다 자기 환경에서 재는 것이 정확합니다. 측정할 때 기억할 점은 다음과 같습니다.
- 히트율이 전체를 좌우합니다: 캐시 히트가 0.5ms, DB 조회가 50ms라면 히트율 99%의 평균은 약 1ms지만 90%면 약 5.5ms입니다. 평균 지연은 대부분 미스 쪽 비용이 결정하므로, 히트율을 1%p 올리는 것이 Redis 자체를 빠르게 하는 것보다 효과가 큰 경우가 많습니다.
- p99를 함께 봅니다: 평균이 좋아도 미스가 몰리는 순간(스탬피드)의 꼬리 지연이 사용자 경험을 망칩니다.
- 같은 조건에서 비교합니다: Redis를 localhost에 두고 재면 네트워크 왕복이 거의 0이라 실제 운영(다른 호스트, 다른 가용 영역)보다 훨씬 빠르게 나옵니다.
벤치마크 코드 예시
// 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;
}
이 코드는 한 연결에서 GET을 순차적으로 보내므로, 결과 QPS는 Redis의 처리 능력이 아니라 왕복 지연(RTT)의 역수에 가깝습니다. RTT가 0.1ms면 대략 초당 1만 회에서 멈추고, Redis 서버는 대부분의 시간을 놀고 있습니다. Redis의 실제 처리량을 보려면 여러 연결을 동시에 쓰거나 파이프라인(redisAppendCommand로 여러 명령을 쌓은 뒤 redisGetReply로 한꺼번에 받기)을 써야 하고, 공식 도구인 redis-benchmark -t get -n 100000 -P 16으로 비교 기준을 잡아 두는 것이 좋습니다. 또 시간 측정에는 시스템 시계 조정의 영향을 받지 않는 steady_clock을 쓰는 편이 안전합니다.
메모리 사용량 추정
TTL은 키가 동시에 얼마나 많이 존재하는지를 통해 메모리에 영향을 줍니다. 초당 새로 캐시되는 키가 N개이고 TTL이 T초라면 정상 상태에서 대략 N × T개의 키가 존재합니다. 여기에 키 하나당 값 크기와 Redis 내부 오버헤드(키 객체, 해시 테이블 엔트리, 만료 정보)를 곱하면 필요한 메모리를 추정할 수 있습니다. 작은 값이 많으면 오버헤드 비율이 커지므로, 실제 값은 샘플 데이터를 넣은 뒤 MEMORY USAGE <key>와 INFO memory의 used_memory로 확인하는 것이 정확합니다.
키 설계·TTL 설계·캐시 장애 대응
키 설계 규칙
# 권장 키 형식
{서비스}:{도메인}:{id}:{필드}
예시:
- api:product:123
- session:abc-def-ghi
- rank:leaderboard:daily
- lock:stock:456
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분
연결 풀 (멀티스레드)
// 단일 연결은 스레드 안전하지 않음. 스레드당 연결 또는 연결 풀 사용
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 = std::move(available_.back()); // 참조로 받으면 pop_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_;
};
원래 코드는 auto& client = available_.back();으로 참조를 받은 뒤 pop_back()을 호출해, 이미 파괴된 unique_ptr에서 이동하는 미정의 동작이었습니다. 위처럼 값으로 먼저 옮겨야 합니다. 또 release()가 비어 있어 연결이 풀로 돌아오지 않으므로 이 클래스는 요청마다 새 연결을 만들고 영원히 쌓아 두는 셈입니다. 실무에서는 acquire()가 소멸 시 자동으로 반환하는 RAII 핸들(커스텀 삭제자를 가진 unique_ptr 등)을 돌려주고, 풀의 최대 크기와 대기 타임아웃을 두어 Redis의 maxclients를 넘지 않게 합니다. 단순한 서버라면 스레드 수가 고정이므로 thread_local RedisClient가 풀보다 간단한 대안입니다.
모니터링
# 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
장애 대응 (캐시 장애 시)
// 캐시 실패 시 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);
}
이 폴백 코드에는 두 가지 함정이 있습니다. 먼저 위 RedisClient::get()은 오류 시 예외를 던지지 않고 nullopt를 반환하므로, 이 catch는 실제로는 거의 실행되지 않고 Redis 장애가 “캐시 미스”와 구별되지 않습니다. 장애를 감지하려면 래퍼가 연결 오류를 예외나 별도 상태로 알려야 합니다. 더 큰 문제는 Redis 장애 시 모든 요청이 DB로 몰린다는 점입니다. 캐시 덕분에 평소 DB가 받는 부하가 전체의 몇 %에 불과했다면, 캐시가 사라지는 순간 DB는 평소의 수십 배 부하를 받고 함께 무너질 수 있습니다. 이름처럼 제대로 된 서킷 브레이커라면 Redis 실패가 일정 비율을 넘을 때 Redis 호출을 잠시 건너뛰어 타임아웃 대기를 줄이고, DB 쪽에도 동시 쿼리 수 제한이나 부하 차단(load shedding)을 함께 걸어야 합니다.
캐시 도입 전 점검 항목
- Redis/Memcached 호스트·포트 환경 변수화
- 연결 실패 시 재시도 및 폴백
- 모든 redisReply freeReplyObject 호출
- 쓰기 시 캐시 무효화 또는 갱신
- TTL 도메인별 설계
- 분산 락 사용 시 Lua로 안전한 해제
- 모니터링 (hit rate, memory, slow log)
- 바이너리 데이터는 %b 사용
캐싱 패턴별 요약
| 항목 | 설명 |
|---|---|
| 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. 인기 키의 캐시가 만료되는 순간 DB 부하가 급증하는 캐시 스탬피드는 어떻게 막나요?
A. 많이 조회되는 키가 만료되면 그 순간 들어온 요청이 모두 캐시 미스를 내고 동시에 DB로 몰리면서 DB가 버티지 못하게 됩니다. 캐시를 다시 채우는 작업을 한 요청만 하도록 Redis SET NX 같은 락으로 막고, 나머지 요청은 잠시 기다렸다가 새로 채워진 캐시를 읽게 하는 방법이 대표적입니다. 여러 키가 동시에 만료되지 않도록 TTL에 무작위 지터를 더하거나, 만료 전에 백그라운드에서 미리 갱신하는 방법도 함께 쓸 수 있습니다.
Q. Redis와 Memcached 중 뭘 써야 하나요?
A. 단순 KV 캐시만 필요하면 Memcached가 메모리 효율과 속도 면에서 유리합니다. 세션, 순위표, Pub/Sub, 분산 락 등이 필요하면 Redis를 선택하세요.
Q. 캐시 hit rate는 얼마나 나와야 하나요?
A. 정해진 기준보다 “미스 비용 × 미스 비율”로 판단하는 편이 정확합니다. 미스 한 번이 수백 ms짜리 집계라면 히트율 1%p 차이도 크고, 미스가 가벼운 조회라면 낮은 히트율도 괜찮을 수 있습니다. 히트율이 예상보다 낮다면 키에 매번 달라지는 값(타임스탬프, 페이지 크기 등)이 섞였는지, TTL이 조회 간격보다 짧은지, 쓰기 무효화가 너무 넓은 범위를 지우는지부터 확인합니다.
Q. Redis Cluster나 Sentinel 환경에서도 이 코드를 쓸 수 있나요?
A. hiredis의 기본 redisContext는 단일 노드 연결이라 Cluster의 MOVED/ASK 리다이렉트를 처리하지 않습니다. Cluster를 쓴다면 hiredis-cluster나 redis-plus-plus처럼 클러스터를 지원하는 라이브러리가 필요하고, 분산 락이나 다중 키 명령은 키가 같은 해시 슬롯에 있어야 하므로 {stock:456}처럼 해시 태그를 설계해야 합니다.
Redis·Memcached로 Cache-Aside·Write-Through를 구현하며, 분산 락·스탬피드 방지까지 적용하면 프로덕션 수준의 캐싱 시스템을 구축할 수 있습니다.
다음 글: C++ 프로파일러 비교: perf 화염 그래프, gprof, Valgrind Callgrind, VTune, Tracy
이전 글: [C++ 실전 가이드 #50-7] 메시지 큐
같이 보면 좋은 글
- C++ 채팅 서버 아키텍처
- C++ 채팅 서버 완성하기 | 인증·방 관리·메시지 히스토리 구현 [#50-1]
- C++ 대용량 이미지 처리
- C++ 로드 밸런서 구현 | Round-Robin·Least Connections·헬스 체크 가이드
- C++로 10GB 파일 올리기
- C++에서 Redis 쓰기
- C++ volatile의 정확한 의미
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]