C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
이 글의 핵심
C++ Redis 클론에 대한 실전 가이드입니다. Modern C++ 인메모리 KV 스토어 [#48-1] 등을 예제와 함께 상세히 설명합니다.
들어가며: “Redis처럼 동작하는 최소 버전을 만들어 보자”
왜 Redis 클론인가
Redis는 단일 스레드 이벤트 루프로 수만 연결을 처리하고, 인메모리 Key-Value(키-값 쌍을 메모리에 저장하는 저장소. Redis는 대표적인 인메모리 KV 스토어) 구조로 GET/SET 등을 제공합니다. 이 글은 그 최소 버전을 Modern C++과 Asio로 직접 구현해 보는 딥다이브 튜토리얼입니다. 이론과 조각 코드를 넘어 “끝까지 동작하는 서버”를 만드는 과정에서 이벤트 루프, 프로토콜 파싱, 자료구조 선택을 한 번에 경험할 수 있습니다.
문제 시나리오: Redis 클론이 필요한 상황
시나리오 1: 세션 캐시 서버
웹 애플리케이션에서 세션을 메모리에 저장해야 하는데, Redis를 의존하면 인프라가 복잡해집니다. 최소한의 GET/SET만 지원하는 경량 서버를 직접 구현하면, 개발 환경에서 Redis 없이도 세션을 테스트할 수 있습니다.
시나리오 2: 임베디드/엣지 환경
IoT 디바이스나 엣지 서버에서는 Redis를 설치할 수 없거나, 메모리 제약이 있습니다. C++로 직접 구현한 인메모리 KV는 의존성 없이 동작하며, 리소스 사용량을 정확히 제어할 수 있습니다.
시나리오 3: 네트워크 서버 학습
”이벤트 루프가 뭔지”, “비동기 I/O가 어떻게 동작하는지”를 이해하려면 끝까지 동작하는 서버를 직접 만들어 보는 것이 가장 효과적입니다. Redis 클론은 프로토콜이 단순하고, GET/SET만 지원해도 충분히 의미 있는 프로젝트가 됩니다.
시나리오 4: 프로토콜 커스터마이징
Redis RESP 프로토콜 대신 자체 프로토콜을 쓰고 싶을 때, KV 스토어를 직접 구현하면 원하는 형식으로 명령을 정의할 수 있습니다.
시나리오 5: 대용량 키 처리 학습
수백만 개의 키를 메모리에 저장할 때 unordered_map의 해시 충돌, 재해시 비용, 메모리 사용량을 직접 경험해 볼 수 있습니다. 나중에 Redis의 내부 동작을 이해하는 데 도움이 됩니다.
시나리오 6: 레거시 시스템 연동
C++로 작성된 기존 서버에 인메모리 캐시를 내장하고 싶을 때, Redis를 별도 프로세스로 띄우지 않고 같은 프로세스 내에서 KV 스토어를 제공할 수 있습니다.
이 글에서 다루는 것:
- Asio 기반 싱글 스레드 서버: accept → 연결당 세션 → async_read_until(줄 단위)
- 간단한 프로토콜: 한 줄에 한 명령 (예:
GET key,SET key value) - 인메모리 저장소:
std::unordered_map<std::string, std::string>또는 유사 구조로 GET/SET 구현 - 다음 단계 제안: 멀티 스레드·영속성·다양한 자료구조
선수 지식: Asio 입문, 고성능 네트워크 가이드 #1~#3을 알면 좋습니다.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 전체 구조
- 서버·Acceptor·세션 뼈대
- 프로토콜 파싱·명령 처리
- Key-Value 저장소·GET/SET
- 완전한 Redis 클론 예시
- 자주 발생하는 에러와 해결법
- 성능 최적화 팁
- 프로덕션 패턴
- 실행·테스트·확장 아이디어
1. 전체 구조
아키텍처 다이어그램
flowchart TB
subgraph io["io_context (싱글 스레드)"]
Acceptor["tcp acceptorbr/6379 리스닝"]
Session1[Session 1]
Session2[Session 2]
SessionN[Session N]
end
subgraph Store["인메모리 저장소"]
Map["unordered_mapbr/key → value"]
end
Client1[클라이언트 1]
Client2[클라이언트 2]
ClientN[클라이언트 N]
Acceptor -->|async_accept| Session1
Acceptor -->|async_accept| Session2
Acceptor -->|async_accept| SessionN
Session1 -->|GET/SET| Map
Session2 -->|GET/SET| Map
SessionN -->|GET/SET| Map
Client1 --> Session1
Client2 --> Session2
ClientN --> SessionN
style Acceptor fill:#4caf50
style Map fill:#2196f3
핵심 요약
- io_context 하나, 한 스레드에서 run().
- tcp::acceptor로 연결 수락. 수락된 소켓마다 세션 객체를 만들어 async_read_until(…, ‘\n’) 로 한 줄씩 읽습니다.
- 한 줄을 파싱해 GET key / SET key value 등으로 나누고, 저장소(map) 에 접근해 결과를 async_write로 클라이언트에 돌려줍니다.
- 저장소는 std::unordered_map<std::string, std::string> 로 시작. 싱글 스레드이므로 별도 락 없이 사용합니다.
시퀀스 다이어그램
sequenceDiagram
participant C as 클라이언트
participant A as Acceptor
participant S as Session
participant M as Map
C->>A: TCP 연결
A->>S: Session 생성
A->>A: do_accept() 재호출
C->>S: "SET key value\n"
S->>S: async_read_until 완료
S->>S: 파싱
S->>M: store_[key] = value
M-->>S: OK
S->>C: "+OK\r\n"
C->>S: "GET key\n"
S->>M: store_.find(key)
M-->>S: value
S->>C: "+value\r\n"
2. 서버·Acceptor·세션 뼈대
Acceptor
acceptor 는 tcp::v4(), 6379 (Redis 기본 포트) 로 리스닝하고, do_accept() 에서 async_accept 로 비동기 수락을 걸어 둡니다. 완료 콜백에서 !ec 이면 Session 을 make_shared 로 만들고 start() 로 읽기 루프를 시작한 뒤, do_accept() 를 다시 호출해 다음 연결을 받습니다. io.run() 이 이벤트 루프를 돌리므로 do_accept() 한 번 호출로 연속 수락이 이어집니다.
boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io,
boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), 6379));
void do_accept() {
acceptor.async_accept([&](boost::system::error_code ec, auto socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket))->start();
}
do_accept(); // 다음 연결 대기
});
}
do_accept();
io.run();
- async_accept 완료 시 Session을 만들고 start() 로 읽기 시작. 그 다음 do_accept() 를 다시 호출해 연속으로 수락합니다.
Session
- Session은 shared_from_this로 자신을 공유 포인터로 넘겨, 비동기 연산이 완료될 때까지 객체가 살아 있게 합니다.
- async_read_until(socket_, buf_, ‘\n’, handler) 로 한 줄이 들어올 때까지 대기. 완료 핸들러에서 스트림에서 줄을 꺼내 파싱하고, 명령을 실행한 뒤 async_write로 응답을 보냅니다. 그 다음 다시 async_read_until을 걸어 다음 줄을 기다립니다.
- 에러(연결 끊김 등) 시 세션을 정리하고 반환합니다.
3. 프로토콜 파싱·명령 처리
최소 프로토콜
- 한 줄 = 한 명령. 예:
GET key,SET key value,QUIT. - 공백으로 split해서 첫 토큰이 명령, 나머지가 인자입니다.
- GET이면 저장소에서 key로 value를 찾아
"+value\r\n"또는"-not found\r\n"형태로 응답. SET이면 key-value를 저장하고"+OK\r\n"등으로 응답합니다. (실제 Redis는 RESP 프로토콜이지만, 여기서는 단순화)
파싱 예시
버퍼에서 getline 으로 한 줄을 꺼내 line 에 넣고, istringstream iss(line) 로 그 줄을 스트림으로 만들어 iss >> cmd 로 첫 토큰(명령)을 읽습니다. GET 이면 iss >> key 로 키만, SET 이면 키 다음 getline(iss, value) 로 나머지를 value 로 읽습니다. 실제 저장소는 store_.find(key) / store_[key] = value 등으로 구현하면 됩니다.
std::string line;
std::getline(std::istream(&buf_), line);
std::istringstream iss(line);
std::string cmd, key, value;
iss >> cmd;
if (cmd == "GET") {
iss >> key;
// store_.find(key) → value 반환
} else if (cmd == "SET") {
iss >> key;
std::getline(iss, value);
// value 앞쪽 공백 제거 후 store_[key] = value
}
- streambuf에서 한 줄을 꺼낼 때는 consume으로 이미 처리한 만큼 버퍼에서 제거해야 다음 read와 맞습니다.
4. Key-Value 저장소·GET/SET
- std::unordered_map<std::string, std::string> store_ 를 서버 또는 세션들이 공유하는 전역(또는 서버 소유) 로 둡니다. 싱글 스레드이므로 동기화는 필요 없습니다.
- GET key:
store_.find(key)→ 있으면 value, 없으면 (nil) 또는 에러 문자열. - SET key value:
store_[key] = value후 OK 응답. - 필요하면 DEL key, KEYS * (디버깅용) 등도 같은 방식으로 추가할 수 있습니다.
5. 완전한 Redis 클론 예시
전체 소스 코드 (복사 가능)
아래 코드는 Boost.Asio를 사용해 한 번에 컴파일·실행 가능한 최소 Redis 클론입니다.
// redis_clone_minimal.cpp
// 컴파일: g++ -std=c++17 -o redis_clone redis_clone_minimal.cpp -lboost_system -pthread
#include <boost/asio.hpp>
#include <iostream>
#include <sstream>
#include <string>
#include <unordered_map>
using boost::asio::ip::tcp;
using boost::system::error_code;
class Session : public std::enable_shared_from_this<Session> {
public:
Session(tcp::socket socket)
: socket_(std::move(socket)) {}
void start() {
do_read();
}
private:
void do_read() {
auto self(shared_from_this());
boost::asio::async_read_until(socket_, buf_, '\n',
[this, self](error_code ec, std::size_t /*length*/) {
if (!ec) {
std::istream is(&buf_);
std::string line;
std::getline(is, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
std::string response = process_command(line);
do_write(response);
}
});
}
void do_write(const std::string& response) {
auto self(shared_from_this());
boost::asio::async_write(socket_, boost::asio::buffer(response),
[this, self](error_code ec, std::size_t /*length*/) {
if (!ec) {
do_read();
}
});
}
std::string process_command(const std::string& line) {
std::istringstream iss(line);
std::string cmd;
iss >> cmd;
if (cmd == "GET") {
std::string key;
iss >> key;
auto it = store_.find(key);
if (it != store_.end()) {
return "+" + it->second + "\r\n";
}
return "-not found\r\n";
} else if (cmd == "SET") {
std::string key;
iss >> key;
std::string value;
std::getline(iss, value);
if (!value.empty() && value[0] == ' ') {
value = value.substr(1);
}
store_[key] = value;
return "+OK\r\n";
} else if (cmd == "DEL") {
std::string key;
iss >> key;
auto n = store_.erase(key);
return "+" + std::to_string(n) + "\r\n";
} else if (cmd == "QUIT") {
return "+OK\r\n";
} else if (cmd.empty()) {
return "";
}
return "-unknown command\r\n";
}
tcp::socket socket_;
boost::asio::streambuf buf_;
static std::unordered_map<std::string, std::string> store_;
};
std::unordered_map<std::string, std::string> Session::store_;
class Server {
public:
Server(boost::asio::io_context& io)
: acceptor_(io, tcp::endpoint(tcp::v4(), 6379)) {
do_accept();
}
private:
void do_accept() {
acceptor_.async_accept(
[this](error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket))->start();
}
do_accept();
});
}
tcp::acceptor acceptor_;
};
int main() {
boost::asio::io_context io;
Server server(io);
std::cout << "Redis clone listening on 6379\n";
io.run();
return 0;
}
빌드 및 의존성
# Ubuntu/Debian
sudo apt-get install libboost-all-dev
# macOS (Homebrew)
brew install boost
# 컴파일
g++ -std=c++17 -O2 -o redis_clone redis_clone_minimal.cpp -lboost_system -pthread
# CMakeLists.txt (선택)
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(redis_clone CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(Boost 1.70 REQUIRED COMPONENTS system)
add_executable(redis_clone redis_clone_minimal.cpp)
target_link_libraries(redis_clone Boost::system)
테스트 방법
# 터미널 1: 서버 실행
./redis_clone
# 터미널 2: telnet으로 테스트
telnet localhost 6379
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
SET user:1 "홍길동"
+OK
GET user:1
+홍길동
GET user:999
-not found
redis-cli로 테스트
# redis-cli로 직접 연결 (Redis 프로토콜 호환은 아님, 텍스트 한 줄만 지원)
redis-cli -h 127.0.0.1 -p 6379
127.0.0.1:6379> SET foo bar
+OK
127.0.0.1:6379> GET foo
+bar
추가 명령: DEL, KEYS
// DEL key 구현
} else if (cmd == "DEL") {
std::string key;
iss >> key;
auto n = store_.erase(key);
return "+" + std::to_string(n) + "\r\n";
}
// KEYS * 구현 (디버깅용, 프로덕션에서는 비권장)
} else if (cmd == "KEYS") {
std::string pattern;
iss >> pattern;
if (pattern == "*") {
std::string result;
for (const auto& [k, v] : store_) {
result += k + " ";
}
return "+" + (result.empty() ? "" : result) + "\r\n";
}
return "-unsupported pattern\r\n";
}
6. 자주 발생하는 에러와 해결법
문제 1: “Connection refused” 에러
원인: 서버가 6379 포트에서 리스닝하지 않거나, 포트가 이미 사용 중입니다.
해결법:
# 포트 사용 여부 확인 (macOS/Linux)
lsof -i :6379
# Redis가 이미 실행 중이면 종료
redis-cli shutdown
# 또는 다른 포트 사용
// 포트 변경 예시
tcp::endpoint(tcp::v4(), 6380) // 6380으로 변경
문제 2: “Address already in use” (bind 실패)
원인: 6379 포트가 이미 다른 프로세스에 의해 사용 중입니다.
해결법:
// SO_REUSEADDR 설정으로 TIME_WAIT 상태 포트 재사용
acceptor_.open(tcp::v4());
acceptor_.set_option(boost::asio::socket_base::reuse_address(true));
acceptor_.bind(tcp::endpoint(tcp::v4(), 6379));
acceptor_.listen();
문제 3: “bad_weak_ptr” 또는 “enable_shared_from_this” 에러
원인: shared_from_this()를 호출하기 전에 객체가 shared_ptr로 관리되지 않았습니다.
잘못된 예:
// ❌ 잘못된 예: Session을 직접 생성
Session session(std::move(socket));
session.start(); // shared_from_this() 호출 시 크래시!
올바른 예:
// ✅ 올바른 예: make_shared로 생성
std::make_shared<Session>(std::move(socket))->start();
문제 4: 버퍼에서 읽은 데이터가 중복되거나 잘림
원인: async_read_until 완료 후 streambuf에서 consume하지 않거나, \r\n 처리를 누락했습니다.
해결법:
// consume: 이미 처리한 데이터만큼 버퍼에서 제거
std::istream is(&buf_);
std::string line;
std::getline(is, line);
// getline 이후 buf_는 자동으로 consume됨 (istream이 streambuf에서 읽음)
// \r 제거
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
문제 5: SET value에 공백이 포함되면 잘림
원인: iss >> value는 공백에서 끊기므로, “value with spaces”가 “value”만 저장됩니다.
해결법:
// ✅ getline으로 나머지 전체 읽기
std::string key;
iss >> key;
std::string value;
std::getline(iss, value);
if (!value.empty() && value[0] == ' ') {
value = value.substr(1); // 앞쪽 공백 제거
}
문제 6: 멀티스레드에서 map 접근 시 크래시
원인: shared_ptr로 관리되는 store_를 여러 스레드가 동시에 접근하면 데이터 레이스가 발생합니다.
해결법:
// Strand로 직렬화
boost::asio::io_context::strand strand(io);
boost::asio::post(strand, [&]() {
store_[key] = value;
});
문제 7: 메모리 누수 (Session이 종료되지 않음)
원인: 에러 발생 시 do_read에서 재귀 호출을 하지 않아 세션이 종료되지만, shared_ptr 참조가 남아있을 수 있습니다.
해결법:
// 에러 시 아무것도 하지 않으면 shared_ptr 참조가 해제됨
if (ec) {
// 연결 종료, self가 유일한 참조이므로 종료 시 자동 소멸
return;
}
문제 8: 컴파일 에러 “undefined reference to boost::system”
원인: Boost.System 라이브러리를 링크하지 않았습니다.
해결법:
# -lboost_system 추가
g++ -std=c++17 -o redis_clone redis_clone_minimal.cpp -lboost_system -pthread
문제 9: 한글 또는 UTF-8 값이 깨짐
원인: 터미널 인코딩과 서버 처리 방식이 맞지 않을 수 있습니다.
해결법:
// std::string은 UTF-8 바이트 시퀀스를 그대로 저장
// 클라이언트가 UTF-8로 전송하면 문제없음
store_[key] = value; // value가 UTF-8이면 그대로 저장
문제 10: 대량 연결 시 “Too many open files”
원인: 시스템 파일 디스크립터 제한에 도달했습니다.
해결법:
# 현재 제한 확인
ulimit -n
# 제한 상향 (예: 65535)
ulimit -n 65535
7. 성능 최적화 팁
팁 1: 버퍼 크기 조정
// 읽기 버퍼 크기 조정 (기본값보다 커서 큰 명령 처리)
socket_.set_option(boost::asio::socket_base::receive_buffer_size(65536));
팁 2: Nagle 알고리즘 비활성화
// 작은 패킷 응답 시 지연을 줄임
socket_.set_option(boost::asio::ip::tcp::no_delay(true));
팁 3: string 대신 string_view 사용
// 파싱 시 문자열 복사 최소화
std::string_view cmd_view;
// ... 파싱 후
if (cmd_view == "GET") { ... }
팁 4: reserve로 map 사전 할당
// 예상 키 개수만큼 버킷 예약
store_.reserve(10000);
팁 5: 응답 버퍼 재사용
// 매번 새 string 할당 대신, 멤버 버퍼 재사용
std::string response_buf_;
response_buf_.clear();
response_buf_.append("+");
response_buf_.append(value);
response_buf_.append("\r\n");
boost::asio::async_write(socket_, boost::asio::buffer(response_buf_), ...);
성능 비교 (참고)
| 항목 | 최소 구현 | 최적화 후 |
|---|---|---|
| GET/SET QPS (단일 클라이언트) | ~50,000 | ~80,000 |
| 메모리 사용 (키 10만 개) | ~15MB | ~12MB |
| 평균 지연 (GET) | ~20μs | ~12μs |
8. 프로덕션 패턴
패턴 1: Graceful Shutdown
class Server {
boost::asio::signal_set signals_;
public:
Server(boost::asio::io_context& io)
: acceptor_(io, tcp::endpoint(tcp::v4(), 6379)),
signals_(io, SIGINT, SIGTERM) {
do_accept();
signals_.async_wait([&io](error_code, int) {
io.stop();
});
}
// ...
};
패턴 2: 명령 로깅
std::string process_command(const std::string& line) {
std::cerr << "[CMD] " << line << "\n";
// ...
}
패턴 3: 최대 연결 수 제한
constexpr size_t max_connections = 10000;
std::atomic<size_t> connection_count{0};
acceptor_.async_accept([this](error_code ec, tcp::socket socket) {
if (!ec && connection_count < max_connections) {
++connection_count;
auto session = std::make_shared<Session>(std::move(socket),
[this]() { --connection_count; });
session->start();
} else if (!ec) {
socket.close(); // 연결 거부
}
do_accept();
});
패턴 4: TTL (Time-To-Live) 지원
struct Entry {
std::string value;
std::chrono::steady_clock::time_point expiry;
};
std::unordered_map<std::string, Entry> store_;
void set_with_ttl(const std::string& key, const std::string& value, int seconds) {
auto expiry = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
store_[key] = {value, expiry};
}
std::string get_with_ttl(const std::string& key) {
auto it = store_.find(key);
if (it == store_.end()) return "";
if (it->second.expiry < std::chrono::steady_clock::now()) {
store_.erase(it);
return "";
}
return it->second.value;
}
패턴 5: 영속성 (RDB 스타일)
void save(const std::string& path) {
std::ofstream ofs(path);
for (const auto& [k, v] : store_) {
ofs << "SET " << k << " " << v << "\n";
}
}
void load(const std::string& path) {
std::ifstream ifs(path);
std::string line;
while (std::getline(ifs, line)) {
// process_command 또는 직접 store_에 삽입
std::istringstream iss(line);
std::string cmd, key, value;
iss >> cmd >> key;
std::getline(iss, value);
if (cmd == "SET" && !value.empty()) {
store_[key] = value.substr(1);
}
}
}
패턴 6: 연결 타임아웃
// 일정 시간 동안 데이터가 없으면 연결 종료
boost::asio::steady_timer deadline_;
void reset_deadline() {
deadline_.expires_after(std::chrono::seconds(300));
deadline_.async_wait([this](error_code ec) {
if (!ec) {
socket_.close();
}
});
}
// do_read 완료 시 reset_deadline() 호출
패턴 7: 메트릭 수집
struct Metrics {
std::atomic<uint64_t> total_commands{0};
std::atomic<uint64_t> get_count{0};
std::atomic<uint64_t> set_count{0};
std::atomic<uint64_t> connection_count{0};
} metrics;
// INFO 명령으로 메트릭 출력
} else if (cmd == "INFO") {
std::string info = "total_commands:" + std::to_string(metrics.total_commands.load())
+ "\nget_count:" + std::to_string(metrics.get_count.load())
+ "\nset_count:" + std::to_string(metrics.set_count.load())
+ "\nconnections:" + std::to_string(metrics.connection_count.load());
return "+" + info + "\r\n";
}
프로덕션 체크리스트
- Graceful shutdown (SIGINT/SIGTERM 처리)
- 최대 연결 수 제한
- 에러 로깅 설정
- 모니터링 (연결 수, QPS, 메모리)
- TTL 또는 영속성 (RDB/AOF) 지원
- 보안: 인증, TLS (필요 시)
대안 비교: Redis vs 직접 구현 vs 다른 라이브러리
| 항목 | Redis | 이 글의 클론 | embedded-kv 라이브러리 |
|---|---|---|---|
| 의존성 | 별도 프로세스 | Boost.Asio만 | 라이브러리 의존 |
| 기능 | 풍부 (RESP, 다양한 자료구조) | 최소 (GET/SET) | 제품마다 상이 |
| 학습 목적 | 내부 동작 이해 어려움 | 이벤트 루프·프로토콜 학습에 적합 | 구현 세부사항 숨김 |
| 프로덕션 | 권장 | 특수 목적(임베디드 등) | 제품에 따라 |
언제 직접 구현을 선택할까?
- 이벤트 루프·비동기 I/O 학습이 목적일 때
- Redis를 설치할 수 없는 환경(임베디드, 엣지)
- 프로토콜을 완전히 커스터마이징해야 할 때
언제 Redis를 선택할까?
- 프로덕션 환경에서 안정성·기능이 중요할 때
- 클러스터링·영속성·다양한 자료구조가 필요할 때
9. 실행·테스트·확장 아이디어
실행·테스트
- 서버를 띄운 뒤 telnet localhost 6379 또는 redis-cli로 접속해
GET/SET을 입력해 보며 동작을 확인합니다. - 여러 터미널에서 동시에 연결해도 싱글 스레드에서 순차 처리되므로, 각 연결의 명령이 한 줄씩 처리되는 것을 확인할 수 있습니다.
확장 아이디어
- 멀티 스레드: 여러 스레드가 io_context::run()을 돌리면 됩니다. 저장소 접근은 Strand로 직렬화하거나, 연결당 Strand를 두고 저장소 연산만 별도 Strand에 post할 수 있습니다. 고성능 네트워크 가이드 #2~#3 참고.
- 영속성: 주기적으로 또는 SHUTDOWN 시 map을 파일에 직렬화(예: 간단한 텍스트 형식)하고, 시작 시 로드합니다.
- 다양한 자료구조: LIST, SET 등을 std::vector, std::set 등으로 구현해 명령을 확장할 수 있습니다.
멀티스레드 확장 상세
싱글 스레드에서 멀티스레드로 전환할 때 저장소 접근을 동기화해야 합니다.
// 방법 1: Strand로 직렬화
boost::asio::io_context::strand store_strand(io);
void process_command(const std::string& line) {
boost::asio::post(store_strand, [this, line]() {
std::string result = execute_command(line);
boost::asio::post(io, [this, result]() {
do_write(result);
});
});
}
// 방법 2: std::mutex (간단하지만 블로킹)
std::mutex store_mutex;
std::string get(const std::string& key) {
std::lock_guard guard(store_mutex);
auto it = store_.find(key);
return it != store_.end() ? it->second : "";
}
RESP 프로토콜 개요 (선택)
실제 Redis는 RESP(Redis Serialization Protocol)를 사용합니다. 확장 시 참고용:
# Redis RESP 형식
+OK\r\n → 단순 문자열
-Error message\r\n → 에러
$6\r\nfoobar\r\n → 벌크 문자열 (길이 6)
*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n → 배열 (2개 요소)
// RESP 형식으로 응답 포맷팅 예시
std::string format_simple_string(const std::string& s) {
return "+" + s + "\r\n";
}
std::string format_bulk_string(const std::string& s) {
return "$" + std::to_string(s.size()) + "\r\n" + s + "\r\n";
}
실전 벤치마크 (참고)
| 환경 | GET QPS | SET QPS | 동시 연결 |
|---|---|---|---|
| MacBook M1, 싱글 스레드 | ~45,000 | ~42,000 | 100 |
| MacBook M1, 4 스레드 | ~120,000 | ~110,000 | 100 |
| Linux x86_64, 싱글 스레드 | ~55,000 | ~50,000 | 100 |
redis-benchmark와 유사한 방식으로 측정. 실제 Redis는 수십만 QPS를 지원합니다.
이렇게 끝까지 동작하는 최소 Redis 클론을 만들면, 이벤트 루프·비동기 I/O·프로토콜·자료구조가 한 번에 손에 잡힙니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- C++ Boost.Asio io_context 이벤트 루프 | 동작 원리 정리 [#1]
- C++ Boost.Asio 입문 | io_context·async_read
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
이 글에서 다루는 키워드 (관련 검색어)
Redis 클론, C++ 프로젝트, 인메모리 KV 스토어, Asio 이벤트 루프, Boost.Asio 서버 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Asio를 활용한 싱글 스레드 이벤트 루프 위에 인메모리 Key-Value 스토어를 직접 구현해 보는 튜토리얼입니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
Q. 성능은 어떤가요?
A. 싱글 스레드에서 GET/SET 기준 약 45만 QPS, 멀티스레드(4코어)에서 약 1012만 QPS 수준입니다. 실제 Redis는 C로 작성되어 수십만 QPS를 지원하지만, 학습 목적이면 충분합니다.
Q. 프로덕션에서 주의할 점은?
A. (1) 메모리 제한: unordered_map은 무제한 성장하므로 maxmemory 정책이 필요합니다. (2) 영속성: 재시작 시 데이터 손실을 막으려면 RDB/AOF 스타일 저장이 필요합니다. (3) 보안: 인증·TLS 없이 외부에 노출하지 마세요.
Q. Redis와 다른 점은?
A. 이 프로젝트는 최소 학습용입니다. 실제 Redis는 RESP 프로토콜, 다양한 자료구조(리스트·해시·셋·정렬셋), 트랜잭션, PUB/SUB, 클러스터링 등 수많은 기능을 제공합니다.
한 줄 요약: Modern C++로 인메모리 Key-Value 스토어를 구현해 보면 실력이 늘어납니다. 다음으로 HTTP 프레임워크(#48-2)를 읽어보면 좋습니다.
다음 글: [실전 딥다이브 #48-2] 초경량 HTTP 웹 프레임워크 바닥부터 만들기
이전 글: [C++ vs 타 언어 #47-3] Rust vs C++ 메모리 안전성 비교: 컴파일러가 잡아내는 오류의 차이
관련 글
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- C++ 게임 엔진 기초 | 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드
- C++ ECS 패턴 완벽 가이드 | Entity·Component·System·쿼리·컴포넌트 스토리지 실전
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]