C++에서 Redis 쓰기: hiredis·redis-plus-plus, 파이프라인, 분산 락, Lua 스크립트
들어가며: C++에서 Redis를 왜 쓰나요?
이 글이 답하는 질문
"DB 쿼리가 느려서 API 응답이 500ms 넘어가요."
"세션을 여러 서버에서 공유해야 하는데, 어떻게 하죠?"
"재고 차감을 분산 환경에서 안전하게 하려면?"
Redis는 인메모리 Key-Value 스토어로, C++ 서버에서 캐싱, 세션 저장, 분산 락, Rate Limiting 등에 널리 사용됩니다. 이 글은 hiredis(C 기반, 경량)와 redis-plus-plus(Modern C++, 풍부한 API)를 사용해 Redis를 C++에서 연동하는 완전한 가이드입니다.
이 글을 읽으면:
- hiredis·redis-plus-plus 설치 및 기본 연결을 할 수 있습니다.
- GET/SET, Hash, TTL, 분산 락 등 실전 패턴을 구현할 수 있습니다.
- 캐시 스탬피드, 재고 갱신 손실 같은 동시성 문제를 Lua와 WATCH로 해결할 수 있습니다.
- Connection timeout, 메모리 누수 등 흔한 에러를 해결할 수 있습니다.
- 성능 최적화와 프로덕션 배포 패턴을 적용할 수 있습니다. 요구 환경: C++17 이상, Redis 6.x 이상 권장
캐시·세션·재고·순위표: Redis를 꺼내게 되는 상황
DB 쿼리 병목으로 API 지연
"상품 상세 API가 DB 조회 때문에 300~500ms 걸려요."
"같은 상품을 매번 조회하는데, 캐시가 없습니다."
상황: 웹 API에서 상품 정보를 DB에서 매번 조회합니다. 동일 상품에 대한 반복 요청이 많아 DB 부하와 응답 지연이 발생합니다. 해결 포인트: Redis에 Cache-Aside 패턴으로 상품 JSON을 캐싱. TTL 300초를 두면, 같은 상품에 대한 반복 조회는 5분 동안 DB 대신 Redis가 응답합니다. 줄어드는 DB 부하는 조회가 일부 인기 상품에 얼마나 몰리는지(캐시 적중률)에 달려 있습니다.
로드밸런서 뒤 다중 서버 세션
"서버를 3대로 늘렸는데, 로그인 후 다른 서버이면 세션이 사라져요."
상황: 세션을 프로세스 메모리에 저장하면, 요청이 다른 서버이면 세션을 찾을 수 없습니다. 해결 포인트: 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, ZREVRANGE)로 점수·멤버를 저장. O(log N)으로 순위 조회가 가능합니다.
인기 키 만료 순간 DB 폭주 (Cache Stampede)
"평소엔 멀쩡한데, 인기 상품 캐시가 만료되는 순간 DB CPU가 튀어요."
상황: Cache-Aside는 캐시 미스가 나면 요청이 직접 DB를 조회합니다. 초당 수천 번 읽히는 키의 TTL이 끝나는 순간, 그 사이 들어온 요청 전부가 동시에 미스를 보고 같은 쿼리를 DB에 보냅니다. TTL을 늘려도 만료 시점이 뒤로 밀릴 뿐 사라지지 않고, 여러 키에 같은 TTL을 주면 한꺼번에 만료되어 더 커집니다.
해결 포인트: 미스가 났을 때 SET NX EX로 락을 잡은 요청 하나만 DB를 조회하고, 나머지는 잠깐 기다렸다 캐시를 다시 읽습니다(운영 패턴의 캐시 스탬피드 방지). TTL에 무작위 오프셋을 더해 만료 시점을 흩어 두는 것도 함께 적용합니다.
서버 기동 시 캐시 워밍업이 오래 걸림
"배포 후 상품 10,000건을 Redis에 채우는 데 10초가 넘게 걸려요."
상황: 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, zrevrangebyscore | 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() | #52-3 |
| 여러 키 원자적 변경 | MULTI/EXEC 또는 Lua | transaction(), EVAL | #52-3 |
선택할 때 기준은 “원자성이 필요한 범위”입니다. 키 하나에 대한 단순 연산은 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->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 <iostream>
#include <string>
using namespace sw::redis;
int main() {
try {
// 연결 풀 생성 (기본 1~10 연결)
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", "[email protected]");
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);
std::vector<std::pair<std::string, double>> top3;
redis.zrevrangebyscore("leaderboard",
UnboundedInterval<double>{},
std::back_inserter(top3),
{.offset = 0, .count = 3});
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만) | 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 <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() {
redis_.del(key_);
}
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_;
};
참고: 슬라이딩 윈도우가 필요하면 ZADD+ZREMRANGEBYSCORE+ZCARD 조합을 사용하세요. Redis 고급 활용(#52-3)에서 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->err) {
// 로그 남기고 재시도 또는 폴백
fprintf(stderr, "Redis 연결 실패: %s\n", 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바이트
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
auto redis = Redis("tcp://127.0.0.1:6379", Options{}.password("mypassword"));
같은 연결을 멀티스레드에서 공유
증상: 간헐적 크래시, 잘못된 응답
원인: 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()는 풀과 별개의 연결을 새로 만들어 주므로 이 문제를 피할 수 있습니다. 구독자 코드와 재연결 루프는 Redis 고급(#52-3)의 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 고급 활용(#52-3)에서 파이프라인을 더 자세히 다룹니다.
연결 풀 사용
매 요청마다 새 연결을 만들면 TCP 핸드셰이크 비용이 큽니다. 연결 풀로 재사용하세요.
// redis-plus-plus는 기본이 연결 풀
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 redis = Redis(opts, pool_opts);
키 설계 — 짧고 일관되게
// ❌ 긴 키
"user:session:cache:data:12345:profile:settings"
// ✅ 짧고 일관된 키
"u:12345:prof"
대량 조회 시 MGET
// ❌ N번 왕복
for (int i = 0; i < 100; ++i) {
redis.get("key:" + std::to_string(i));
}
// ✅ MGET 1번
redisReply* r = (redisReply*)redisCommand(ctx, "MGET k1 k2 k3 ... k100");
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 및 재연결
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 자체의 동작과 롤백이 없다는 특성은 Redis 고급(#52-3)의 트랜잭션 절에서 설명합니다.
Redis 연동 점검 항목
환경 설정
- Redis 서버 실행 확인 (
redis-cli ping) - hiredis 또는 redis-plus-plus 설치
- CMake/vcpkg 연동
연결 및 기본 사용
-
redisConnectWithTimeout으로 타임아웃 설정 - RAII로
redisContext/redisReply관리 -
freeReplyObject누락 없이 호출
에러 처리
-
ctx->err체크 -
reply->type == REDIS_REPLY_ERROR처리 - Connection timeout 시 재시도 또는 폴백
성능
- 연결 풀 또는 스레드당 연결
- 대량 조회 시 MGET/파이프라인 고려
- 캐시 키에 TTL 설정
프로덕션
- Health Check (PING) 주기적 수행
- 비밀번호(AUTH) 설정 시 환경 변수 사용
- 캐시 스탬피드 방지 (분산 락) 적용
- 락 해제와 재고 차감은 Lua로 원자적으로 처리
- 구독 전용 연결을 일반 연결 풀과 분리
라이브러리·기능별 요약
| 항목 | hiredis | redis-plus-plus |
|---|---|---|
| 용도 | 경량, C 호환, 임베디드 | Modern C++, 풍부한 API |
| 연결 | 단일, 직접 관리 | 연결 풀 내장 |
| 에러 | 수동 체크 | 예외 기반 |
| 권장 | 레거시, 최소 의존성 | 신규 프로젝트 |
핵심 원칙:
- RAII로 연결·응답 관리
- 바이너리 데이터는
%b사용 - 멀티스레드에서는 연결 풀 또는 스레드당 연결
- 캐시는 반드시 TTL 설정
- 읽고-계산하고-쓰는 작업은 Lua 또는 WATCH로 원자적으로 다음 글 Redis 고급 활용(#52-3)에서는 Pub/Sub, 파이프라인, Lua 스크립팅, Redis Cluster를 다룹니다.
자주 묻는 질문 (FAQ)
Q. hiredis의 redisCommand에서 %s 대신 %b를 써야 하는 경우는 언제인가요?
A. %s는 인자를 널 종료 C 문자열로 해석하기 때문에, 직렬화한 바이너리나 이미지처럼 중간에 0 바이트가 들어 있는 값은 그 지점에서 잘린 채 저장됩니다. %b는 포인터와 길이를 두 개의 인자로 받아 지정한 길이만큼 그대로 전송하므로 바이너리에 안전합니다. 값에 어떤 바이트가 들어올지 확신할 수 없다면 %b를 기본으로 쓰고, 응답을 읽을 때도 reply의 str 대신 len까지 함께 사용해야 합니다.