C++에서 Redis 쓰기: hiredis·redis-plus-plus, 파이프라인, 분산 락, Lua 스크립트
들어가며
Redis는 인메모리 키-값 저장소로, C++ 서버에서도 캐싱, 세션 공유, 분산 락, 요청 수 제한 같은 용도로 널리 쓰입니다. 이 글은 C 클라이언트인 hiredis와 그 위에 만든 C++ 래퍼인 redis-plus-plus로 Redis에 연결하는 방법부터, 캐시 스탬피드와 재고 갱신 손실 같은 동시성 문제를 Lua와 WATCH로 푸는 방법, 연결 시간 초과나 응답 누수 같은 흔한 오류, Pub/Sub과 Streams, 트랜잭션과 EVALSHA, Redis Cluster의 해시 태그까지 다룹니다. 예제는 C++17과 Redis 6 이상을 기준으로 합니다.
캐시·세션·재고·순위표: Redis를 꺼내게 되는 상황
DB 쿼리 병목으로 API 지연
웹 API에서 상품 정보를 DB에서 매번 조회합니다. 동일 상품에 대한 반복 요청이 많아 DB 부하와 응답 지연이 발생합니다.
Redis에 Cache-Aside 패턴으로 상품 JSON을 캐싱하고 TTL을 300초로 두면, 같은 상품에 대한 반복 조회는 5분 동안 DB 대신 Redis가 응답합니다. 줄어드는 DB 부하는 조회가 일부 인기 상품에 얼마나 몰리는지(캐시 적중률)에 달려 있습니다.
로드밸런서 뒤 다중 서버 세션
세션을 프로세스 메모리에 저장하면, 로그인한 서버가 아닌 다른 서버로 요청이 갔을 때 세션을 찾을 수 없습니다.
세션 데이터를 Redis에 Hash나 JSON으로 저장하고 모든 서버가 같은 Redis를 바라보게 하면, 어느 서버로 요청이 가도 같은 세션을 찾을 수 있습니다.
재고 차감 경쟁 조건
재고를 Redis에 두고 GET → 애플리케이션에서 감소 → SET 순서로 차감하면, 두 요청이 동시에 GET으로 100을 읽고 각자 99를 SET합니다. 두 건이 팔렸는데 재고는 한 건만 줄어드는 갱신 손실(lost update)이고, 반대로 “재고가 남았는지” 확인과 차감 사이에 다른 요청이 끼어들면 재고가 음수로 떨어집니다. 명령 하나하나는 원자적이어도 읽고-계산하고-쓰는 세 단계 전체가 원자적이지 않아서 생기는 문제입니다.
세 가지 방법이 있습니다. 확인과 차감을 Lua 스크립트 하나로 서버에서 실행하거나, WATCH로 값이 바뀌었으면 재시도하거나, SET key value NX EX ttl로 얻은 분산 락 안에서 처리합니다. 재고 키 하나만 다룬다면 Lua가 가장 단순하고 빠르며, 비교는 운영 패턴의 재고 차감 항목에 정리했습니다. 락을 쓴다면 해제할 때 Lua로 같은 토큰일 때만 지워야 안전합니다.
실시간 순위표
DB ORDER BY score DESC LIMIT 100은 부하가 크고, 실시간 반영이 어렵습니다.
Redis Sorted Set에 멤버와 점수를 저장하면(ZADD) 점수 갱신은 O(log N), 상위 M명 조회(ZREVRANGE)는 O(log N + M)으로 처리됩니다.
인기 키 만료 순간 DB 폭주 (Cache Stampede)
Cache-Aside는 캐시 미스가 나면 요청이 직접 DB를 조회합니다. 초당 수천 번 읽히는 키의 TTL이 끝나는 순간, 그 사이 들어온 요청 전부가 동시에 미스를 보고 같은 쿼리를 DB에 보냅니다. TTL을 늘려도 만료 시점이 뒤로 밀릴 뿐 사라지지 않고, 여러 키에 같은 TTL을 주면 한꺼번에 만료되어 더 커집니다.
미스가 났을 때 SET NX EX로 락을 잡은 요청 하나만 DB를 조회하고, 나머지는 잠깐 기다렸다 캐시를 다시 읽습니다(운영 패턴의 캐시 스탬피드 방지). TTL에 무작위 오프셋을 더해 만료 시점을 흩어 두는 것도 함께 적용합니다.
서버 기동 시 캐시 워밍업이 오래 걸림
SET을 하나 보내고 응답을 받은 뒤 다음 SET을 보내면, 명령 처리 시간보다 왕복 지연(RTT)이 전체 시간을 결정합니다. 계산해 보면 RTT가 1ms인 네트워크에서 10,000건은 RTT만 10초입니다. Redis 서버는 한가한데 클라이언트가 응답을 기다리느라 시간을 보내는 셈입니다.
파이프라인으로 200건씩 묶어 보내면 왕복은 50번으로 줄어, 같은 계산으로 RTT 비용은 50ms 수준이 됩니다. 실제 시간은 명령 처리와 전송량이 더해지므로 이보다 길지만, 병목이 RTT에서 처리량으로 옮겨 갑니다(파이프라인으로 RTT 줄이기).
시나리오별 기술 선택
| 시나리오 | Redis 기능 | C++ 구현 | 이 글 / 관련 글 |
|---|---|---|---|
| API 캐싱 | String (SET/GET) + TTL | RedisClient::get/set, redis.set(k, v, ttl) | Cache-Aside 예제 |
| 세션 저장 | Hash + EXPIRE | HSET/EXPIRE, redis.hset | 세션 저장 예제 |
| 분산 락 | SET NX EX + Lua 해제 | setNX, EVAL | 분산 락 예제, Lua 락 해제 패턴 |
| 순위표 | Sorted Set | redis.zadd, redis.zrevrange | redis-plus-plus 절 |
| Cache Stampede | SET NX EX, DEL | setNX + 재조회 루프 | 캐시 스탬피드 방지 패턴 |
| 재고 차감 | Lua EVAL 또는 WATCH | EVAL, WATCH/MULTI/EXEC | 재고 차감 패턴 |
| 대량 SET/GET | Pipeline, MGET | redisAppendCommand, redis.pipeline() | 파이프라인·MGET 팁 |
| 실시간 알림 | Pub/Sub | 구독 전용 연결, subscriber() | Pub/Sub 절 |
| 여러 키 원자적 변경 | MULTI/EXEC 또는 Lua | transaction(), EVAL | 트랜잭션 절 |
선택할 때 기준은 “원자성이 필요한 범위”입니다. 키 하나에 대한 단순 연산은 INCR·SET NX처럼 이미 원자적인 명령으로 끝내고, 여러 단계를 묶어야 하면 Lua, 여러 키를 조건 없이 한꺼번에 바꾸면 MULTI/EXEC, 긴 작업 전체를 다른 프로세스와 직렬화해야 할 때만 분산 락을 씁니다. 락은 TTL 만료·클라이언트 정지 같은 실패 모드가 가장 많아서 마지막 수단에 가깝습니다.
Redis 서버와 hiredis·redis-plus-plus 설치
Redis 서버 실행
# Docker로 Redis 실행 (권장)
docker run -d -p 6379:6379 redis:7-alpine
# 또는 로컬 설치 후
redis-server
hiredis 설치
# Ubuntu/Debian
sudo apt-get install libhiredis-dev
# macOS (Homebrew)
brew install hiredis
# vcpkg
vcpkg install hiredis
redis-plus-plus 설치
redis-plus-plus는 hiredis 위에 구축된 C++ 래퍼입니다.
# vcpkg (권장)
vcpkg install redis-plus-plus
# 또는 소스 빌드
git clone https://github.com/sewenew/redis-plus-plus.git
cd redis-plus-plus
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make && sudo make install
CMake 연동 예시
# CMakeLists.txt
find_package(PkgConfig REQUIRED)
pkg_check_modules(HIREDIS REQUIRED hiredis)
add_executable(redis_demo main.cpp)
target_link_libraries(redis_demo PRIVATE ${HIREDIS_LIBRARIES})
target_include_directories(redis_demo PRIVATE ${HIREDIS_INCLUDE_DIRS})
hiredis 연결과 GET/SET
아키텍처 다이어그램
flowchart TB
subgraph App[C++ 애플리케이션]
Main[main]
Client[RedisClient]
end
subgraph Hiredis[hiredis]
Ctx[redisContext]
Cmd[redisCommand]
Reply[redisReply]
end
subgraph Redis[Redis 서버]
Store["(Key-Value Store)"]
end
Main --> Client
Client --> Ctx
Client --> Cmd
Cmd --> Reply
Ctx -->|TCP 6379| Store
기본 연결 (RAII)
// 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>
struct RedisConnection {
redisContext* ctx = nullptr;
RedisConnection(const char* host, int port, int timeout_sec = 5) {
struct timeval tv = {timeout_sec, 0};
ctx = redisConnectWithTimeout(host, port, tv);
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 == nullptr) { // 연결 오류 시 NULL, 원인은 conn.ctx->errstr
std::cerr << "SET 실패: " << conn.ctx->errstr << "\n";
return 1;
}
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\",\"price\":9900}");
freeReplyObject(reply);
} catch (const std::exception& e) {
std::cerr << "에러: " << e.what() << "\n";
return 1;
}
return 0;
}
RAII 래퍼 클래스 (GET/SET/DEL/SETNX)
// 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, int timeout_sec = 5) {
struct timeval tv = {timeout_sec, 0};
ctx_ = redisConnectWithTimeout(host.c_str(), port, tv);
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;
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;
}
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;
}
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;
}
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;
}
std::optional<long long> incr(const std::string& key) {
redisReply* reply = (redisReply*)redisCommand(ctx_, "INCR %s", key.c_str());
if (!reply || reply->type != REDIS_REPLY_INTEGER) {
if (reply) freeReplyObject(reply);
return std::nullopt;
}
long long val = reply->integer;
freeReplyObject(reply);
return val;
}
bool expire(const std::string& key, int seconds) {
redisReply* reply = (redisReply*)redisCommand(ctx_, "EXPIRE %s %d", key.c_str(), seconds);
if (!reply) return false;
bool ok = (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1);
freeReplyObject(reply);
return ok;
}
private:
redisContext* ctx_ = nullptr;
};
주의: %b는 바이너리 안전(binary-safe) 포맷으로, value.data()와 value.size()를 사용합니다. %s는 null 종료 문자열에만 사용하세요.
redis-plus-plus로 쓰는 Modern C++ 클라이언트
연결 풀 및 STL 스타일 API
// redis_plus_plus_demo.cpp
// vcpkg install redis-plus-plus 후 컴파일
#include <sw/redis++/redis++.h>
#include <chrono>
#include <iostream>
#include <iterator>
#include <string>
#include <utility>
#include <vector>
using namespace sw::redis;
int main() {
try {
// URI만 주면 풀 크기 1인 연결 풀이 만들어짐 (크기 조정은 아래 성능 절 참고)
auto redis = Redis("tcp://127.0.0.1:6379");
// SET / GET
redis.set("key", "value");
auto val = redis.get("key");
if (val) {
std::cout << "key = " << *val << "\n";
}
// TTL과 함께 SET
redis.set("session:abc", "user_data", std::chrono::seconds(3600));
// Hash
redis.hset("user:1001", "name", "김철수");
redis.hset("user:1001", "email", "kim@example.com");
auto name = redis.hget("user:1001", "name");
// Sorted Set (순위표)
redis.zadd("leaderboard", "player1", 1500.0);
redis.zadd("leaderboard", "player2", 2300.0);
redis.zadd("leaderboard", "player3", 1800.0);
// 출력 타입이 pair면 WITHSCORES로 점수까지 받아 옴
std::vector<std::pair<std::string, double>> top3;
redis.zrevrange("leaderboard", 0, 2, std::back_inserter(top3));
for (const auto& [member, score] : top3) {
std::cout << member << ": " << score << "\n";
}
} catch (const Error& e) {
std::cerr << "Redis 에러: " << e.what() << "\n";
return 1;
}
return 0;
}
redis-plus-plus vs hiredis 비교
| 항목 | hiredis | redis-plus-plus |
|---|---|---|
| 언어 | C | C++11/14/17 |
| 의존성 | 없음 | hiredis |
| 연결 풀 | 직접 구현 | 내장 |
| STL 호환 | 없음 | optional, vector 등 |
| 설치 | 간단 | vcpkg 또는 빌드 |
| 용량 | 작음 | 상대적으로 큼 |
Cache-Aside·세션·분산 락·Rate Limiter 예제
Cache-Aside API 캐싱
// cache_aside.cpp
#include "redis_wrapper.hpp"
#include <functional>
#include <string>
std::string getCachedOrFetch(RedisClient& redis,
const std::string& cacheKey,
std::function<std::string()> fetcher,
int ttl = 300) {
auto cached = redis.get(cacheKey);
if (cached) return *cached;
std::string data = fetcher();
redis.set(cacheKey, data, ttl);
return data;
}
// 사용 예
// std::string productJson = getCachedOrFetch(redis, "product:123",
// [&] { return db.queryProduct(123).toJson(); }, 300);
쓰기 경로: 캐시를 갱신할까, 지울까
Cache-Aside는 실제로 읽힌 데이터만 캐시에 올라가고 Redis가 죽어도 (느리지만) DB로 서비스가 돌아간다는 장점이 있습니다. 대신 쓰기 경로에서 캐시를 어떻게 다루느냐에 따라 일관성이 갈립니다.
- 쓰기 후
SET(Write-Through): 두 서버가 같은 키를 거의 동시에 수정하면, DB에는 B의 값이 마지막으로 쓰였는데 캐시에는 A의SET이 네트워크 지연으로 나중에 도착해 A의 값이 남을 수 있습니다. - 쓰기 후
DEL: 순서가 뒤집혀도 결과가 같으므로(둘 다 비울 뿐) 여러 서버가 같은 키를 쓰는 환경에서는 이쪽을 기본으로 두고, 다음 읽기가 DB에서 최신 값을 채우게 합니다. - Write-Behind: 캐시에만 먼저 쓰고 DB에는 비동기로 반영합니다. 쓰기는 빠르지만 반영 전에 캐시 노드가 죽으면 그 사이의 쓰기가 사라지므로, 조회수처럼 일부 유실을 감수할 수 있는 카운터를 모아 반영하는 용도가 아니면 권하기 어렵습니다.
DEL을 써도 경쟁 조건이 하나 남습니다. 요청 A가 캐시 미스로 DB에서 옛 값을 읽는 사이 요청 B가 DB를 갱신하고 캐시를 지우면, A가 방금 읽은 옛 값을 캐시에 써서 TTL이 끝날 때까지 옛 값이 서빙됩니다. “가격을 바꿨는데 일부 사용자에게만 한동안 옛 가격이 보인다”는 형태로 나타나며, TTL을 무한대로 두지 않는 것이 기본 안전망입니다. 더 엄격해야 하면 쓰기 직후 짧은 지연을 두고 한 번 더 지우는 지연 이중 삭제나, DB 변경 로그(CDC)를 구독해 무효화하는 방식을 씁니다. DB 갱신은 성공했는데 캐시 DEL이 타임아웃으로 실패하는 경우도 로그로 남기고 TTL로 수습되게 해야 합니다.
Redis 장애 시 DB로 폴백하는 코드도 설계가 필요합니다. 캐시 덕분에 DB가 평소 전체 요청의 일부만 받고 있었다면, 캐시가 사라지는 순간 모든 요청이 DB로 몰려 DB까지 함께 무너질 수 있습니다. Redis 실패가 일정 비율을 넘으면 Redis 호출을 잠시 건너뛰어 타임아웃 대기를 줄이고, DB 쪽에는 동시 쿼리 수 제한을 함께 걸어 둡니다. 또 래퍼의 get()이 연결 오류를 nullopt로 돌려주면 장애가 캐시 미스와 구별되지 않으므로, 오류는 예외나 별도 상태로 알려야 합니다. 단순 키-값 캐시만 필요하고 영속성, 자료구조, 분산 락이 필요 없다면 Memcached도 선택지지만, 세션·락·순위표까지 한 곳에서 다루려면 Redis가 맞습니다.
세션 저장 (Hash)
// session_store.cpp
#include <hiredis/hiredis.h>
#include <optional>
#include <string>
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_;
};
분산 락 (SET NX EX)
// distributed_lock.cpp
#include "redis_wrapper.hpp"
#include <random>
#include <string>
class DistributedLock {
public:
DistributedLock(RedisClient& redis, const std::string& resource, int ttlSeconds = 10)
: redis_(redis), resource_(resource), key_("lock:" + resource), ttl_(ttlSeconds) {}
bool tryLock() {
// 다른 프로세스·서버와 겹치지 않도록 무작위 토큰 사용
std::random_device rd;
token_ = std::to_string(rd()) + "-" + std::to_string(rd());
return redis_.setNX(key_, token_, ttl_);
}
void unlock() {
redis_.del(key_); // 단순화한 버전: 토큰 확인 없이 지움 (아래 Lua 해제 절 참고)
}
template <typename Func>
bool withLock(Func&& f) {
if (!tryLock()) return false;
bool ok = false;
try {
f();
ok = true;
} catch (...) {}
unlock();
return ok;
}
private:
RedisClient& redis_;
std::string resource_;
std::string key_;
int ttl_;
std::string token_;
};
// 사용 예
// DistributedLock lock(redis, "inventory:product:123", 5);
// if (lock.tryLock()) {
// // 재고 차감 로직
// lock.unlock();
// }
Rate Limiter (고정 윈도우 — INCR+EXPIRE)
// rate_limiter.cpp
// 고정 윈도우: INCR로 카운트 증가, 첫 요청 시 EXPIRE로 TTL 설정
#include "redis_wrapper.hpp"
#include <string>
class RateLimiter {
public:
RateLimiter(RedisClient& redis, int maxRequests, int windowSeconds)
: redis_(redis), max_(maxRequests), window_(windowSeconds) {}
bool allow(const std::string& clientKey) {
std::string redisKey = "ratelimit:" + clientKey;
auto countOpt = redis_.incr(redisKey);
if (!countOpt) return false;
if (*countOpt == 1) {
redis_.expire(redisKey, window_);
}
return *countOpt <= static_cast<long long>(max_);
}
private:
RedisClient& redis_;
int max_;
int window_;
};
INCR과 EXPIRE가 별도 명령이라, 첫 INCR 직후 프로세스가 죽거나 EXPIRE가 실패하면 TTL 없는 카운터가 남아 그 클라이언트가 영구히 차단됩니다. 두 명령을 Lua 스크립트 하나로 묶거나, EXPIRE에 NX 옵션(Redis 7.0 이상)을 붙여 매 요청마다 호출하는 방식으로 이 틈을 없앨 수 있습니다. 슬라이딩 윈도우가 필요하면 ZADD+ZREMRANGEBYSCORE+ZCARD 조합을 사용하세요. Lua로 원자적으로 처리하는 예시는 아래 운영 패턴의 재고 차감 스크립트를 참고하세요.
타임아웃, freeReplyObject 누락, MOVED/ASK: Redis 에러 해결
Connection timeout / Connection refused
증상: redisConnect 실패, ctx->errstr에 “Connection refused” 또는 “Connection timed out”
원인:
- Redis 서버가 실행 중이 아님
- 잘못된 호스트/포트
- 방화벽 차단
- Redis가
bind 127.0.0.1만 허용하는데 외부 IP로 접속 시도 해결법:
// ❌ 잘못된 설정
RedisClient redis("redis.example.com", 6379); // Redis 미실행 또는 네트워크 불통
// ✅ 타임아웃 설정 + 재시도
struct timeval tv = {5, 0};
redisContext* ctx = redisConnectWithTimeout("127.0.0.1", 6379, tv);
if (ctx == nullptr || ctx->err) {
// 로그 남기고 재시도 또는 폴백
fprintf(stderr, "Redis 연결 실패: %s\n", ctx ? ctx->errstr : "컨텍스트 할당 실패");
}
# Redis 서버 확인
redis-cli ping
# PONG 응답이면 정상
freeReplyObject 누락으로 메모리 누수
증상: 장시간 실행 시 메모리 사용량이 계속 증가
원인: redisCommand가 반환하는 redisReply*를 freeReplyObject로 해제하지 않음
// ❌ 메모리 누수
redisReply* reply = (redisReply*)redisCommand(ctx, "GET key");
std::string result = reply->str; // 사용 후
// freeReplyObject(reply) 누락!
해결법:
// ✅ RAII 래퍼 사용
struct ReplyGuard {
redisReply* r;
~ReplyGuard() { if (r) freeReplyObject(r); }
};
redisReply* reply = (redisReply*)redisCommand(ctx, "GET key");
ReplyGuard guard{reply};
if (reply->type == REDIS_REPLY_STRING) {
std::string result(reply->str, reply->len);
}
%s vs %b 혼동 (바이너리 안전)
증상: 값에 null 문자(\0)가 포함되면 잘림
원인: %s는 null 종료 문자열만 처리. 바이너리 데이터에는 %b 사용 필요
// ❌ 바이너리 데이터 잘림
std::string data("hello\0world", 11); // 길이를 지정해야 11바이트가 됨
redisCommand(ctx, "SET key %s", data.c_str()); // "hello"만 저장됨 (5바이트)
// ✅ 바이너리 안전
redisCommand(ctx, "SET key %b", data.data(), data.size());
MOVED/ASK (Redis Cluster)
증상: (error) MOVED 12345 192.168.1.10:6379
원인: Redis Cluster 모드에서 키의 해시 슬롯을 다른 노드가 담당할 때. hiredis 단일 연결은 리다이렉트를 자동 처리하지 않음
해결법:
- Redis Cluster용으로는
redis-plus-plus의RedisCluster사용 - 또는 단일 노드 Redis 사용
// redis-plus-plus Cluster
#include <sw/redis++/redis++.h>
sw::redis::RedisCluster redis("tcp://127.0.0.1:7000");
NOAUTH Authentication required
증상: (error) NOAUTH Authentication required
원인: Redis에 비밀번호가 설정되어 있는데 AUTH 없이 명령 실행
해결법:
// hiredis
redisReply* r = (redisReply*)redisCommand(ctx, "AUTH %s", password);
freeReplyObject(r);
// redis-plus-plus
ConnectionOptions opts;
opts.host = "127.0.0.1";
opts.port = 6379;
opts.password = "mypassword"; // 실제로는 환경 변수에서 읽음
auto redis = Redis(opts);
같은 연결을 멀티스레드에서 공유
증상: 간헐적 크래시, 잘못된 응답
원인: hiredis redisContext는 스레드 안전하지 않음
// ❌ 위험
RedisClient redis("127.0.0.1", 6379);
std::thread t1([&]() { redis.get("key1"); });
std::thread t2([&]() { redis.get("key2"); });
해결법:
// ✅ 스레드당 연결 또는 연결 풀
void worker() {
thread_local RedisClient redis("127.0.0.1", 6379);
redis.get("key");
}
// 또는 redis-plus-plus 연결 풀 (내부적으로 스레드 안전)
auto redis = Redis("tcp://127.0.0.1:6379"); // 연결 풀
SUBSCRIBE한 연결에서 GET/SET 시도
증상: (error) ERR only (P)SUBSCRIBE / (P)UNSUBSCRIBE / PING / QUIT allowed in this context
원인: 연결에서 SUBSCRIBE를 실행하는 순간 그 연결은 구독 모드로 바뀌고, 구독 관련 명령 외에는 받지 않습니다(RESP2 기준). “같은 연결을 멀티스레드에서 공유” 에러에서 본 thread_local 연결이나 직접 만든 커넥션 풀에서 연결 하나를 꺼내 구독에 썼다가 풀에 돌려놓으면, 다음에 그 연결을 받은 코드의 평범한 GET이 이 에러로 실패합니다. 어떤 요청에서 터질지 매번 달라서 재현이 어렵습니다.
해결법: 구독용 연결은 풀과 분리된 전용 연결로 하나 만들어 수명 내내 구독에만 쓰고, 일반 명령은 기존 풀로 보냅니다. redis-plus-plus의 redis.subscriber()는 풀과 별개의 연결을 새로 만들어 주므로 이 문제를 피할 수 있습니다. 구독자 코드와 재연결 루프는 아래 Pub/Sub 절에서 다룹니다.
파이프라인 중간에 redisCommand 호출
증상: 응답이 한 칸씩 밀려, GET b의 결과로 SET a의 OK가 나옵니다.
원인: hiredis의 redisCommand는 “명령을 버퍼에 넣고, 버퍼를 전송하고, 응답을 하나 읽어 반환”합니다. 그런데 redisAppendCommand로 쌓아 둔 명령이 있으면 버퍼에는 그 명령들이 먼저 들어 있으므로, redisCommand가 읽어 오는 첫 응답은 앞서 쌓아 둔 명령의 응답입니다. 이후 redisGetReply도 전부 하나씩 어긋납니다.
// ❌ 응답 순서가 어긋남
redisAppendCommand(ctx, "SET a 1");
redisReply* r = (redisReply*)redisCommand(ctx, "GET b"); // "SET a 1"의 OK를 받음
// ✅ 파이프라인에 넣은 명령 수만큼 redisGetReply를 모두 호출한 뒤 다른 명령 사용
redisAppendCommand(ctx, "SET a 1");
redisAppendCommand(ctx, "GET b");
redisReply* reply = nullptr;
for (int i = 0; i < 2; ++i) {
if (redisGetReply(ctx, (void**)&reply) != REDIS_OK) break;
freeReplyObject(reply);
}
파이프라인 로직을 한 함수 안에 가두고, 그 함수가 끝날 때 쌓은 명령 수와 읽은 응답 수가 같은지 확인하면 이 실수를 막을 수 있습니다.
Lua 스크립트에서 nil과 숫자 비교
증상: ERR Error running script ... attempt to compare nil with number
원인: 키가 없으면 redis.call('GET', key)는 Lua의 nil이 아니라 false를 돌려주고, tonumber(false)는 nil이 됩니다. 그 값을 숫자와 비교하면 스크립트가 중간에 실패합니다. 재고 키가 아직 만들어지지 않은 신상품이나, TTL로 만료된 키에서 주로 터집니다.
해결법: 스크립트 앞부분에서 존재 여부를 먼저 확인하고, “키 없음”을 음수 같은 구분 가능한 반환값으로 돌려줍니다. 호출하는 C++ 쪽은 그 값을 에러와 따로 처리합니다. 예시는 아래 운영 패턴의 재고 차감 스크립트에 있습니다.
파이프라인·연결 풀·MGET으로 성능 올리기
파이프라인으로 RTT 감소
단일 명령마다 왕복(RTT)이 발생합니다. 여러 명령을 파이프라인으로 묶으면 RTT를 줄일 수 있습니다.
// hiredis 파이프라인
redisReply* reply;
redisAppendCommand(ctx, "SET key1 %s", "v1");
redisAppendCommand(ctx, "SET key2 %s", "v2");
redisAppendCommand(ctx, "GET key1");
redisGetReply(ctx, (void**)&reply);
freeReplyObject(reply);
redisGetReply(ctx, (void**)&reply);
freeReplyObject(reply);
redisGetReply(ctx, (void**)&reply);
freeReplyObject(reply);
파이프라인은 원자성을 보장하지 않으므로, 쌓인 명령 사이에 다른 클라이언트의 명령이 끼어들 수 있습니다. redis-plus-plus에서는 redis.pipeline()으로 만든 객체에 명령을 쌓고 exec()로 보냅니다. 한 번에 너무 많이 쌓으면 양쪽 응답 버퍼가 커지고 응답을 다 받을 때까지 결과를 쓸 수 없으므로, 수백 건 단위로 나눠 보내면 왕복 절감 효과는 대부분 얻으면서 메모리를 제한할 수 있습니다. 적정 배치 크기는 실제 값 크기로 측정해 정합니다.
const int BATCH_SIZE = 200;
for (int offset = 0; offset < total; offset += BATCH_SIZE) {
auto pipe = redis.pipeline();
for (int i = 0; i < BATCH_SIZE && offset + i < total; ++i) {
pipe.set(keys[offset + i], values[offset + i]);
}
auto replies = pipe.exec();
}
Pipeline 객체는 스레드 안전하지 않으므로 여러 스레드가 하나를 공유하면 안 되고, 위처럼 스레드(또는 작업)마다 새로 만들어 씁니다. redis.pipeline()은 풀에서 연결 하나를 꺼내 붙잡으므로, 파이프라인을 오래 들고 있으면 그만큼 풀이 줄어든다는 점도 기억해 둡니다.
연결 풀 사용
매 요청마다 새 연결을 만들면 TCP 핸드셰이크 비용이 큽니다. 연결 풀로 재사용하세요.
// redis-plus-plus는 기본이 연결 풀 (URI만 주면 크기 1)
auto redis = Redis("tcp://127.0.0.1:6379");
// 풀 크기 조정
ConnectionOptions opts;
opts.host = "127.0.0.1";
opts.port = 6379;
ConnectionPoolOptions pool_opts;
pool_opts.size = 10;
auto pooled = Redis(opts, pool_opts);
키 설계 — 짧고 일관되게
// ❌ 긴 키
"user:session:cache:data:12345:profile:settings"
// ✅ 짧고 일관된 키
"u:12345:prof"
키 이름도 메모리를 차지하므로 키가 수천만 개 단위라면 짧게 줄이는 효과가 있습니다. 다만 키가 많지 않다면 절약되는 양이 미미하고 읽기 어려워지기만 하므로, user:12345:profile처럼 읽을 수 있는 이름에 일관된 구분자 규칙을 지키는 쪽이 더 중요합니다.
대량 조회 시 MGET
// ❌ N번 왕복
for (int i = 0; i < 100; ++i) {
redis.get("key:" + std::to_string(i));
}
// ✅ MGET 1번: 인자 개수가 가변적이면 redisCommandArgv 사용
std::vector<std::string> keys;
for (int i = 0; i < 100; ++i) keys.push_back("key:" + std::to_string(i));
std::vector<const char*> argv{"MGET"};
std::vector<size_t> argvlen{4};
for (const auto& k : keys) {
argv.push_back(k.c_str());
argvlen.push_back(k.size());
}
redisReply* r = (redisReply*)redisCommandArgv(ctx, (int)argv.size(), argv.data(), argvlen.data());
// r->element[i]는 keys[i]의 값 (없으면 REDIS_REPLY_NIL)
freeReplyObject(r);
TTL 적절히 설정
캐시는 반드시 TTL을 두어 메모리 폭증을 방지하세요. 무기한 캐시는 Redis OOM으로 이어질 수 있습니다. maxmemory에 도달하면 기본 정책(noeviction)에서는 쓰기 명령이 OOM command not allowed when used memory > 'maxmemory' 에러로 거부되므로, 캐시 전용 인스턴스라면 maxmemory-policy allkeys-lru처럼 오래 안 쓴 키를 내보내는 정책을 함께 설정합니다. 같은 인스턴스에 세션이나 락처럼 사라지면 안 되는 키가 섞여 있다면 TTL이 있는 키만 내보내는 volatile-lru가 맞습니다.
redis.set("cache:product:123", json, 300); // 5분 TTL
재연결·스탬피드 방지·Lua 재고 차감
Health Check 및 재연결
// RedisClient에 멤버 함수로 추가 (클래스 선언에 bool ping(); 도 필요)
bool RedisClient::ping() {
redisReply* r = (redisReply*)redisCommand(ctx_, "PING");
if (!r) return false;
bool ok = (r->type == REDIS_REPLY_STATUS && std::string(r->str) == "PONG");
freeReplyObject(r);
return ok;
}
void ensureConnected(RedisClient& redis) {
if (!redis.ping()) {
// 재연결 또는 알림
throw std::runtime_error("Redis 연결 끊김");
}
}
캐시 스탬피드 방지 (분산 락)
여러 요청이 동시에 캐시 미스 시 DB를 중복 조회하지 않도록, 락으로 한 요청만 DB 조회하고 나머지는 대기합니다.
std::string getWithStampedePrevention(RedisClient& redis,
const std::string& key,
std::function<std::string()> fetcher,
int ttl = 300) {
auto cached = redis.get(key);
if (cached) return *cached;
std::string lockKey = "lock:" + key;
std::string lockVal = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
if (redis.setNX(lockKey, lockVal, 10)) {
std::string data = fetcher();
redis.set(key, data, ttl);
redis.del(lockKey);
return data;
}
for (int i = 0; i < 20; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
cached = redis.get(key);
if (cached) return *cached;
}
return fetcher();
}
Lua로 원자적 락 해제
분산 락 해제 시 같은 토큰을 가진 클라이언트만 해제해야 합니다. Lua로 원자적으로 처리합니다.
-- unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
// C++에서 Lua 실행: 스크립트 전체를 %s 인자 하나로 넘긴다
const char* UNLOCK =
"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",
UNLOCK, lockKey.c_str(), token.c_str());
freeReplyObject(r);
스크립트를 포맷 문자열 안에 따옴표로 감싸 직접 넣으면 안 됩니다. hiredis의 redisCommand는 포맷 문자열을 공백 기준으로만 인자로 나누고 따옴표를 해석하지 않으므로, "if, redis.call(...)==ARGV[1], then 같은 조각이 각각 별도 인자로 전송되어 Redis가 스크립트를 받지 못합니다. %s·%b로 넘긴 값은 공백이 있어도 인자 하나로 전송됩니다. 분산 락 예제의 DistributedLock::unlock()과 스탬피드 방지 패턴의 redis.del(lockKey)도 실제로는 이 스크립트로 바꿔야, TTL이 지나 다른 요청이 새로 잡은 락을 지워 버리는 사고를 막을 수 있습니다.
설정 외부화
struct RedisConfig {
std::string host = "127.0.0.1";
int port = 6379;
int timeout_sec = 5;
std::string password;
};
RedisConfig loadFromEnv() {
RedisConfig c;
if (const char* h = std::getenv("REDIS_HOST")) c.host = h;
if (const char* p = std::getenv("REDIS_PORT")) c.port = std::stoi(p);
if (const char* pw = std::getenv("REDIS_PASSWORD")) c.password = pw;
return c;
}
재고 차감 — Lua 스크립트와 WATCH 낙관적 락
앞의 재고 차감 경쟁 조건 시나리오에서 본 갱신 손실은 “읽고-확인하고-쓰는” 과정을 원자적으로 만들면 사라집니다. 방법은 두 가지입니다.
방법 1: Lua 스크립트. 스크립트는 Redis 서버에서 다른 명령이 끼어들 틈 없이 실행되므로, 확인과 차감이 한 단위가 됩니다. “Lua 스크립트에서 nil과 숫자 비교” 에러의 nil 처리도 여기서 합니다.
// 반환값: 차감 후 재고, -1 = 키 없음, -2 = 재고 부족
const char* DECR_STOCK =
"local cur = redis.call('GET', KEYS[1]) "
"if not cur then return -1 end "
"local stock = tonumber(cur) "
"local amount = tonumber(ARGV[1]) "
"if stock < amount then return -2 end "
"return redis.call('DECRBY', KEYS[1], amount)";
long long decrementStock(redisContext* ctx, const std::string& key, int amount) {
std::string amt = std::to_string(amount);
redisReply* r = (redisReply*)redisCommand(ctx, "EVAL %s 1 %s %s",
DECR_STOCK, key.c_str(), amt.c_str());
if (!r) throw std::runtime_error("Redis 연결 오류");
ReplyGuard guard{r};
if (r->type == REDIS_REPLY_ERROR) throw std::runtime_error(r->str);
return r->integer;
}
방법 2: WATCH + MULTI/EXEC. 키를 WATCH한 뒤 값을 읽고, 차감 명령을 MULTI로 예약해 EXEC합니다. 그 사이 다른 클라이언트가 키를 바꿨다면 EXEC는 아무것도 실행하지 않고 nil을 돌려주므로, 처음부터 다시 시도합니다. 락을 잡지 않고 “충돌하면 다시 한다”는 낙관적 방식입니다.
// 반환값: 차감 후 재고, -1 = 키 없음, -2 = 재고 부족, -3 = 재시도 한도 초과
long long decrementStockWatch(redisContext* ctx, const std::string& key,
int amount, int maxRetries = 5) {
for (int attempt = 0; attempt < maxRetries; ++attempt) {
ReplyGuard w{(redisReply*)redisCommand(ctx, "WATCH %s", key.c_str())};
ReplyGuard g{(redisReply*)redisCommand(ctx, "GET %s", key.c_str())};
if (!g.r || g.r->type != REDIS_REPLY_STRING) {
ReplyGuard u{(redisReply*)redisCommand(ctx, "UNWATCH")};
return -1;
}
long long stock = std::stoll(std::string(g.r->str, g.r->len));
if (stock < amount) {
ReplyGuard u{(redisReply*)redisCommand(ctx, "UNWATCH")};
return -2;
}
ReplyGuard m{(redisReply*)redisCommand(ctx, "MULTI")};
ReplyGuard q{(redisReply*)redisCommand(ctx, "DECRBY %s %d", key.c_str(), amount)};
ReplyGuard e{(redisReply*)redisCommand(ctx, "EXEC")};
if (e.r && e.r->type == REDIS_REPLY_ARRAY && e.r->elements == 1) {
return e.r->element[0]->integer; // 성공
}
// e.r->type == REDIS_REPLY_NIL: 다른 클라이언트가 먼저 바꿈 → 재시도
}
return -3;
}
두 방법의 차이는 경합이 심할 때 드러납니다. WATCH는 충돌할 때마다 왕복 4~5번을 처음부터 다시 하므로, 한정 판매처럼 수백 요청이 같은 키에 몰리면 대부분의 시도가 실패하고 재시도 한도에 걸립니다. Lua는 충돌 자체가 없어 이런 상황에서 유리합니다. 반대로 WATCH는 읽은 값을 C++ 쪽에서 복잡하게 계산해야 할 때(예: 외부 가격 정책을 반영한 차감) 로직을 Lua로 옮기지 않아도 된다는 장점이 있습니다. WATCH는 연결 단위 상태라 반드시 같은 연결에서 EXEC까지 이어져야 하므로, 명령마다 다른 연결을 꺼내는 커넥션 풀에서는 쓸 수 없고 redis-plus-plus라면 redis.transaction()이 만든 전용 연결을 써야 합니다. MULTI/EXEC 자체의 동작과 롤백이 없다는 특성은 아래 트랜잭션 절에서 설명합니다.
Pub/Sub으로 실시간 알림
로드밸런서 뒤에 서버가 여러 대 있으면 WebSocket 연결도 서버마다 나뉩니다. 주문 완료나 채팅 메시지를 모든 서버의 클라이언트에게 보내려면 서버끼리 메시지를 공유해야 하는데, 각 서버가 같은 채널을 구독하고 이벤트가 생긴 서버가 그 채널에 발행하면 됩니다. redis-plus-plus는 subscriber()로 풀과 분리된 구독 전용 연결을 만들고, on_message 콜백으로 메시지를 받습니다. consume()은 응답 하나를 읽어 콜백을 호출하고 반환하므로 루프에서 반복 호출해야 합니다.
#include <sw/redis++/redis++.h>
#include <chrono>
#include <iostream>
#include <thread>
using namespace sw::redis;
void run_subscriber(Redis& redis, const std::string& channel) {
while (true) {
try {
auto sub = redis.subscriber();
sub.on_message([](std::string ch, std::string msg) {
std::cout << ch << ": " << msg << "\n";
});
sub.subscribe(channel);
while (true) {
sub.consume(); // 연결이 끊기면 예외가 나서 바깥 루프에서 재연결
}
} catch (const Error& e) {
std::cerr << "구독 재연결: " << e.what() << "\n";
std::this_thread::sleep_for(std::chrono::seconds(5));
}
}
}
// 발행은 일반 연결(풀)로
// long long receivers = redis.publish("channel:orders", "order:123 completed");
publish가 돌려주는 값은 그 순간 메시지를 받은 구독자 수입니다. channel:orders:*처럼 여러 채널을 한 번에 받으려면 sub.psubscribe("channel:orders:*")로 패턴 구독을 하고 on_pmessage 콜백을 등록합니다. hiredis로 직접 구현할 때도 구독한 연결은 구독 모드로 바뀌므로, 앞의 에러 절에서 본 것처럼 일반 명령용 연결과 반드시 분리합니다.
Pub/Sub은 메시지를 저장하지 않습니다. 구독자가 없거나 재연결 중이던 동안 발행된 메시지는 그대로 사라지므로, 위 재연결 루프는 “놓쳐도 다음 메시지로 회복되는 알림”에만 충분합니다. 주문 이벤트처럼 유실되면 안 되는 데이터는 메시지를 저장하고 소비자 그룹별로 처리 위치(ACK)를 관리하는 Streams(XADD, XREADGROUP, XACK)를 씁니다.
std::vector<std::pair<std::string, std::string>> fields = {
{"event", "order_complete"}, {"id", "123"}};
auto id = redis.xadd("stream:orders", "*", fields.begin(), fields.end());
MULTI/EXEC 트랜잭션과 Lua 운영
MULTI 이후 보낸 명령은 바로 실행되지 않고 QUEUED로 쌓였다가 EXEC 때 한 번에 실행되며, 그동안 다른 클라이언트의 명령이 끼어들지 않습니다. redis-plus-plus에서는 redis.transaction()이 이 과정을 감싸고, 기본적으로 파이프라인으로 보내 왕복도 한 번으로 줄입니다.
auto tx = redis.transaction();
auto replies = tx.decrby("stock:42", 1)
.hset("order:123", "item", "42")
.exec();
long long left = replies.get<long long>(0);
흔한 오해는 Redis 트랜잭션에 롤백이 있다고 생각하는 것입니다. EXEC 후 실행 단계에서 어떤 명령이 에러를 내도(예: 문자열 키에 HSET) 나머지 명령은 그대로 실행되고, 이미 실행된 명령은 되돌려지지 않습니다. Lua 스크립트도 중간에 에러가 나면 그 전까지 실행한 쓰기가 남습니다. 둘 다 “중간에 다른 클라이언트가 끼어들지 않는다”는 의미의 원자성만 보장하므로, 조건 검사를 쓰기보다 먼저 끝내고 검사를 통과한 경우에만 쓰도록 짜야 합니다. 앞의 재고 차감 스크립트가 GET과 비교를 먼저 하고 마지막에 DECRBY를 하는 것도 이 때문입니다.
같은 스크립트를 반복 실행한다면 SCRIPT LOAD로 SHA1을 받아 두고 EVALSHA로 호출해 스크립트 본문 전송을 줄일 수 있습니다(redis-plus-plus는 script_load와 evalsha). Redis가 재시작되거나 다른 노드로 장애 조치되면 서버의 스크립트 캐시가 비어 EVALSHA가 NOSCRIPT 에러를 내므로, 이때 EVAL로 다시 보내거나 다시 로드하는 폴백이 필요합니다.
std::string sha = redis.script_load(script);
long long left;
try {
left = redis.evalsha<long long>(sha, {"stock:42"}, {"1"});
} catch (const sw::redis::ReplyError& e) {
// NOSCRIPT: 캐시가 비었으면 본문으로 다시 실행
left = redis.eval<long long>(script, {"stock:42"}, {"1"});
}
Lua 스크립트를 쓸 때 지킬 것이 두 가지 더 있습니다. 첫째, 사용자 입력은 항상 KEYS/ARGV로 넘기고 스크립트 문자열에 이어 붙이지 않습니다. "return redis.call('GET', '" + user_input + "')"처럼 만들면 따옴표가 든 입력에서 문법 에러가 나거나 의도하지 않은 Lua 코드가 실행됩니다. 둘째, 스크립트가 실행되는 동안 Redis는 다른 명령을 처리하지 않으므로, 원자성이 필요한 최소한의 로직만 넣고 반복문이 큰 계산은 클라이언트에서 합니다. 스크립트가 길어지면 .lua 파일로 분리해 버전 관리하는 편이 리뷰하기 쉽습니다.
Redis Cluster: 해시 태그와 CROSSSLOT
Redis Cluster는 키를 16384개 해시 슬롯으로 나눠 여러 노드에 분산합니다. 앞의 MOVED/ASK 절에서 본 것처럼 리다이렉트를 처리하는 RedisCluster를 쓰면 단일 키 명령은 그대로 동작하지만, 여러 키를 다루는 MGET·MSET·트랜잭션·Lua 스크립트는 모든 키가 같은 슬롯에 있어야 합니다. 그렇지 않으면 한 노드에서 원자적으로 처리할 수 없어 CROSSSLOT Keys in request don't hash to the same slot 에러가 납니다.
sw::redis::RedisCluster cluster("tcp://127.0.0.1:7000"); // 노드 하나만 지정해도 나머지를 찾음
// 중괄호 안의 부분(해시 태그)만으로 슬롯을 계산하므로 두 키가 같은 슬롯에 놓인다
cluster.set("order:{42}:items", "...");
cluster.set("order:{42}:status", "paid");
std::vector<sw::redis::OptionalString> vals;
cluster.mget({"order:{42}:items", "order:{42}:status"}, std::back_inserter(vals));
해시 태그는 함께 다뤄야 하는 키 묶음에만 씁니다. {user}처럼 넓은 태그를 모든 키에 붙이면 키가 한 슬롯, 한 노드에 몰려 클러스터를 쓰는 의미가 사라집니다. 단일 노드에서 개발하다 클러스터로 옮길 때 이 에러가 처음 드러나는 경우가 많으므로, 다중 키 연산이 있는 키 설계는 처음부터 태그를 염두에 두는 편이 낫습니다. hiredis 단일 연결로 클러스터를 쓰려면 hiredis-cluster 같은 별도 라이브러리가 필요합니다.
다음 글
C++에서 MongoDB 쓰기(#52-3)에서는 문서형 DB를 C++ 드라이버로 다룹니다.