C++로 Redis 클론 만들기: Asio 이벤트 루프, 프로토콜 파싱, GET/SET 저장소

들어가며: Redis처럼 동작하는 최소 버전을 만들어 보자

Redis는 명령 실행을 하나의 이벤트 루프에서 처리하면서 많은 연결을 다루는 인메모리 키-값 저장소입니다. 이 글은 그 최소 버전을 Modern C++과 Boost.Asio로 직접 만들어 보는 튜토리얼입니다. 조각 코드가 아니라 끝까지 동작하는 서버를 만들어 보면, 이벤트 루프, 비동기 I/O의 객체 수명, 프로토콜 파싱, 자료구조 선택이 실제로 어떻게 맞물리는지 한 번에 경험할 수 있습니다.

학습 외에도 이런 경량 키-값 서버가 쓸모 있는 경우가 있습니다. 개발 환경에서 Redis 없이 세션 저장을 흉내 내야 할 때, 별도 프로세스를 띄울 수 없는 임베디드·엣지 장비에 작은 캐시가 필요할 때, 기존 C++ 서버 안에 캐시를 내장하거나 자체 프로토콜을 정의하고 싶을 때입니다. 다만 운영 환경에서 범용 캐시가 필요하다면 검증된 Redis를 쓰는 것이 맞습니다.

이 글에서는 하나의 스레드에서 도는 Asio 서버, 한 줄에 한 명령을 받는 단순 프로토콜, std::unordered_map 기반 GET/SET 저장소를 만들고, 멀티스레드·TTL·영속성·RESP로 확장하는 방향을 살펴봅니다. 선수 지식으로 Asio 입문과 고성능 네트워크 가이드 #1~#3을 알면 좋습니다.


io_context 하나로 도는 전체 구조

flowchart TB
    subgraph io["io_context (싱글 스레드)"]
        Acceptor["tcp acceptor (6379 리스닝)"]
        Session1[Session 1]
        Session2[Session 2]
        SessionN[Session N]
    end
    subgraph Store[인메모리 저장소]
        Map["unordered_map: key → value"]
    end
    Client1[클라이언트 1] --> Session1
    Client2[클라이언트 2] --> Session2
    ClientN[클라이언트 N] --> SessionN
    Acceptor -->|async_accept| Session1
    Acceptor -->|async_accept| Session2
    Acceptor -->|async_accept| SessionN
    Session1 -->|GET/SET| Map
    Session2 -->|GET/SET| Map
    SessionN -->|GET/SET| Map

io_context 하나를 한 스레드에서 run()합니다. tcp::acceptor가 연결을 받을 때마다 세션 객체를 만들고, 세션은 async_read_until(..., '\n')으로 한 줄씩 읽어 명령을 파싱한 뒤 저장소에 접근하고 결과를 async_write로 돌려줍니다. 모든 핸들러가 같은 스레드에서 실행되므로 저장소에 락이 필요 없습니다.

sequenceDiagram
    participant C as 클라이언트
    participant A as Acceptor
    participant S as Session
    participant M as Map
    C->>A: TCP 연결
    A->>S: Session 생성 후 start()
    A->>A: do_accept() 재호출
    C->>S: "SET key value\n"
    S->>S: async_read_until 완료, 파싱
    S->>M: store_[key] = value
    S->>C: "+OK\r\n"
    C->>S: "GET key\n"
    S->>M: store_.find(key)
    S->>C: "+value\r\n"

서버·Acceptor·세션 뼈대

acceptor는 Redis 기본 포트인 6379에서 리스닝하고, do_accept()에서 async_accept로 비동기 수락을 걸어 둡니다. 완료 핸들러는 성공 시 세션을 make_shared로 만들어 start()를 호출하고, 다시 do_accept()를 호출해 다음 연결을 기다립니다. 이렇게 핸들러가 스스로 다음 작업을 거는 구조라서 io.run()이 연결을 계속 받아들입니다.

세션은 std::enable_shared_from_this를 상속하고, 비동기 작업을 걸 때마다 shared_from_this()로 얻은 포인터를 핸들러에 캡처합니다. 대기 중인 작업이 있는 동안에는 핸들러가 소유권을 쥐고 있어 세션이 살아 있고, 연결이 끊겨 더 이상 작업을 걸지 않으면 마지막 핸들러가 끝날 때 세션이 자동으로 소멸합니다.


프로토콜 파싱과 명령 처리

여기서는 한 줄에 한 명령을 받는 단순 프로토콜을 씁니다. 공백으로 나눈 첫 토큰이 명령이고 나머지가 인자입니다. 응답은 Redis의 RESP 형식을 흉내 내어 성공은 +, 에러는 -, 정수는 :로 시작합니다.

std::istringstream iss(line);
std::string cmd, key, value;
iss >> cmd;
if (cmd == "GET") {
    iss >> key;                 // store_.find(key)
} else if (cmd == "SET") {
    iss >> key;
    std::getline(iss, value);   // 나머지 전체 (공백 포함)
    if (!value.empty() && value[0] == ' ') value.erase(0, 1);
}

iss >> value로 읽으면 공백에서 끊기므로 “hello world”가 “hello”만 저장됩니다. 값에 공백을 허용하려면 키 다음의 나머지를 getline으로 읽고 앞의 공백 하나를 지웁니다. 실제 Redis는 길이가 앞에 붙은 RESP 배열을 쓰기 때문에 값에 공백이나 줄바꿈이 있어도 문제가 없습니다.


GET/SET/DEL/KEYS를 처리하는 전체 소스

// redis_clone_minimal.cpp
// 컴파일: g++ -std=c++17 -O2 -o redis_clone redis_clone_minimal.cpp -pthread
#include <boost/asio.hpp>
#include <cctype>
#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:
    explicit 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) {
                if (ec) return;  // 연결 종료: 더 이상 작업을 걸지 않으면 세션 소멸
                std::istream is(&buf_);
                std::string line;
                std::getline(is, line);           // 읽은 만큼 buf_에서 소비됨
                if (!line.empty() && line.back() == '\r') line.pop_back();
                bool quit = false;
                write_buf_ = process_command(line, quit);
                do_write(quit);
            });
    }

    void do_write(bool close_after) {
        auto self = shared_from_this();
        // write_buf_는 멤버라서 비동기 쓰기가 끝날 때까지 살아 있음
        boost::asio::async_write(socket_, boost::asio::buffer(write_buf_),
            [this, self, close_after](error_code ec, std::size_t) {
                if (ec) return;
                if (close_after) {
                    error_code ignored;
                    socket_.shutdown(tcp::socket::shutdown_both, ignored);
                    return;
                }
                do_read();
            });
    }

    std::string process_command(const std::string& line, bool& quit) {
        std::istringstream iss(line);
        std::string cmd;
        iss >> cmd;
        for (auto& ch : cmd) ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));

        if (cmd == "GET") {
            std::string key;
            iss >> key;
            auto it = store_.find(key);
            return it != store_.end() ? "+" + it->second + "\r\n" : "$-1\r\n";  // RESP의 nil
        }
        if (cmd == "SET") {
            std::string key, value;
            iss >> key;
            std::getline(iss, value);
            if (!value.empty() && value[0] == ' ') value.erase(0, 1);
            if (key.empty()) return "-ERR wrong number of arguments\r\n";
            store_[key] = std::move(value);
            return "+OK\r\n";
        }
        if (cmd == "DEL") {
            std::string key;
            iss >> key;
            return ":" + std::to_string(store_.erase(key)) + "\r\n";  // 삭제한 개수 (정수)
        }
        if (cmd == "KEYS") {   // 디버깅용: 모든 키를 공백으로 나열
            std::string result;
            for (const auto& [k, v] : store_) result += k + " ";
            return "+" + result + "\r\n";
        }
        if (cmd == "QUIT") {
            quit = true;
            return "+OK\r\n";
        }
        if (cmd.empty()) return "";
        return "-ERR unknown command\r\n";
    }

    tcp::socket socket_;
    boost::asio::streambuf buf_;
    std::string write_buf_;
    static std::unordered_map<std::string, std::string> store_;
};

std::unordered_map<std::string, std::string> Session::store_;

class Server {
public:
    explicit Server(boost::asio::io_context& io)
        : acceptor_(io, tcp::endpoint(tcp::v4(), 6379)) {  // 이 생성자는 SO_REUSEADDR도 설정
        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();
}

이 코드에서 가장 놓치기 쉬운 부분은 쓰기 버퍼의 수명입니다. async_write는 호출 즉시 반환하고 실제 전송은 나중에 일어나므로, 응답 문자열을 지역 변수에 두고 그 참조를 boost::asio::buffer로 넘기면 핸들러가 반환된 뒤 해제된 메모리를 보내게 됩니다. 응답을 멤버(write_buf_)에 두고, 이 세션에서는 쓰기가 끝나야 다음 읽기를 거는 구조라 버퍼가 덮어써지지 않습니다.

빌드

# Ubuntu/Debian
sudo apt-get install libboost-dev
# macOS (Homebrew)
brew install boost
g++ -std=c++17 -O2 -o redis_clone redis_clone_minimal.cpp -pthread
cmake_minimum_required(VERSION 3.16)
project(redis_clone CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(Boost 1.70 REQUIRED)
find_package(Threads REQUIRED)
add_executable(redis_clone redis_clone_minimal.cpp)
target_link_libraries(redis_clone PRIVATE Boost::headers Threads::Threads)

Boost.System은 1.69부터 헤더 전용이라 Asio만 쓴다면 -lboost_system 없이도 링크됩니다. 오래된 Boost에서는 undefined reference to boost::system::... 에러가 나므로 그때만 추가합니다.

테스트

./redis_clone          # 터미널 1
nc localhost 6379      # 터미널 2 (또는 telnet localhost 6379)
SET user:1 홍길동
+OK
GET user:1
+홍길동
GET user:999
$-1
DEL user:1
:1
QUIT
+OK

std::string은 바이트를 그대로 저장하므로, 터미널이 UTF-8로 보내면 한글 값도 그대로 저장되고 돌려받습니다. 값을 따옴표로 감싸 SET user:1 "홍길동"처럼 보내면 이 단순 파서는 따옴표까지 값으로 저장합니다.

redis-cli로는 이 서버를 테스트할 수 없습니다. redis-cli는 명령을 *2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n 같은 RESP 배열로 보내는데, 이 서버는 한 줄 텍스트만 이해하므로 첫 줄 *2를 알 수 없는 명령으로 처리합니다. redis-cli나 Redis 클라이언트 라이브러리와 호환되게 하려면 아래 RESP 절처럼 배열 파싱을 구현해야 합니다.


자주 만나는 문제

”Connection refused” / “Address already in use”

Connection refused는 서버가 그 포트에서 리스닝하지 않을 때, Address already in use는 다른 프로세스(대개 실제 Redis)가 이미 6379를 쓰고 있을 때 납니다. lsof -i :6379나 ss -ltnp | grep 6379로 확인하고, 실제 Redis가 떠 있다면 종료하거나 클론의 포트를 6380 등으로 바꿉니다. tcp::acceptor(io, endpoint) 생성자는 이미 SO_REUSEADDR를 켜므로, 서버를 재시작할 때 TIME_WAIT 때문에 바인드가 실패하는 경우는 드뭅니다. 직접 open/bind/listen을 호출하는 방식이라면 reuse_address(true)를 bind 전에 설정합니다.

bad_weak_ptr

// ❌ shared_ptr로 관리되지 않는 객체에서 shared_from_this() 호출
Session session(std::move(socket));
session.start();   // std::bad_weak_ptr

// ✅
std::make_shared<Session>(std::move(socket))->start();

생성자 안에서 shared_from_this()를 호출해도 아직 shared_ptr가 만들어지기 전이라 같은 예외가 납니다.

읽은 데이터가 중복되거나 잘림

async_read_until은 구분자까지 읽었다고 알려 주지만, 실제로는 그 뒤의 데이터까지 streambuf에 미리 읽어 두었을 수 있습니다. 위 코드처럼 std::istream과 getline으로 한 줄을 꺼내면 꺼낸 만큼만 버퍼에서 소비되고, 남은 데이터는 다음 async_read_until이 먼저 검사하므로 클라이언트가 여러 명령을 한 번에 보내도 순서대로 처리됩니다. 반대로 buf_.data()를 직접 읽고 consume을 빠뜨리면 같은 명령을 반복 처리하고, 한 번에 버퍼 전체를 소비하면 뒤의 명령이 사라집니다. 텔넷처럼 \r\n으로 줄을 끝내는 클라이언트를 위해 끝의 \r도 지웁니다.

멀티스레드에서 map 접근 시 크래시

여러 스레드가 같은 io_context에서 run()을 돌리면 서로 다른 세션의 핸들러가 동시에 실행되어, 정적 store_에 동시 쓰기가 일어나고 unordered_map 내부가 깨집니다. 저장소 접근을 하나의 strand로 직렬화하거나 mutex로 보호해야 합니다(아래 “멀티스레드 확장” 참고).

대량 연결 시 “Too many open files”

연결마다 파일 디스크립터를 하나씩 쓰므로 프로세스의 상한(ulimit -n, 흔히 기본 1024)에 닿으면 accept가 실패합니다. ulimit -n 65535나 systemd의 LimitNOFILE로 상한을 올리고, 이 에러가 났을 때 do_accept()를 바로 다시 호출하면 같은 에러가 반복되므로 짧게 기다렸다가 재시도하는 처리를 넣습니다.


처리량을 높이는 방법

응답이 작은 요청-응답 프로토콜에서는 Nagle 알고리즘이 지연을 만들 수 있으므로 세션 소켓에 no_delay를 켭니다.

socket_.set_option(tcp::no_delay(true));

receive_buffer_size(SO_RCVBUF)는 커널 수신 버퍼 크기를 바꾸는 옵션으로, 처리할 수 있는 명령의 최대 길이와는 관계가 없습니다. 명령 길이는 streambuf가 필요에 따라 늘어나므로, 오히려 악의적인 클라이언트가 줄바꿈 없이 데이터를 계속 보내 메모리를 소진하지 않도록 boost::asio::streambuf buf_{64 * 1024};처럼 최대 크기를 정해 두는 편이 중요합니다. 상한을 넘으면 async_read_until이 not_found 에러로 완료됩니다.

파싱 단계의 문자열 복사는 std::string_view로 줄일 수 있고, 키 개수를 예상할 수 있다면 store_.reserve(n)으로 재해시 횟수를 줄입니다. 클라이언트가 여러 명령을 연달아 보내는 파이프라이닝 상황에서는 버퍼에 쌓인 명령을 모두 처리한 뒤 응답을 한 번에 쓰면 시스템 콜 수가 줄어듭니다. 실제 처리량은 하드웨어와 클라이언트 동작에 크게 좌우되므로, RESP를 구현했다면 redis-benchmark -p 6379 -t get,set으로 직접 측정해 보는 것이 가장 정확합니다.


운영 기능으로 확장하기

Graceful Shutdown

class Server {
public:
    explicit Server(boost::asio::io_context& io)
        : acceptor_(io, tcp::endpoint(tcp::v4(), 6379)),
          signals_(io, SIGINT, SIGTERM) {
        do_accept();
        signals_.async_wait([this, &io](error_code, int) {
            acceptor_.close();  // 새 연결 중단
            io.stop();          // 필요하면 저장 후 종료
        });
    }
    // ...
private:
    tcp::acceptor acceptor_;
    boost::asio::signal_set signals_;
};

TTL (Time-To-Live)

struct Entry {
    std::string value;
    std::optional<std::chrono::steady_clock::time_point> expiry;  // 없으면 만료 안 함
};
std::unordered_map<std::string, Entry> store_;

void set_with_ttl(const std::string& key, std::string value, int seconds) {
    store_[key] = {std::move(value), std::chrono::steady_clock::now() + std::chrono::seconds(seconds)};
}

std::optional<std::string> get(const std::string& key) {
    auto it = store_.find(key);
    if (it == store_.end()) return std::nullopt;
    if (it->second.expiry && *it->second.expiry <= std::chrono::steady_clock::now()) {
        store_.erase(it);  // 접근할 때 만료 확인 (lazy expiration)
        return std::nullopt;
    }
    return it->second.value;
}

접근할 때만 만료를 확인하면 다시 읽히지 않는 키는 메모리에 계속 남습니다. Redis는 이 방식에 더해, 만료 시간이 있는 키 일부를 주기적으로 샘플링해 지우는 능동 만료를 함께 씁니다. 클론에서는 steady_timer로 주기 작업을 걸어 만료된 키를 일정 개수씩 지우는 방식으로 흉내 낼 수 있습니다.

영속성 (명령 재생 방식)

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)) {
        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);
    }
}

저장 파일을 명령 목록으로 남겨 재생하는 방식은 Redis의 AOF와 비슷한 생각입니다(실제 RDB는 바이너리 스냅샷 형식). 값에 줄바꿈이 들어가면 이 텍스트 형식이 깨지므로, 길이를 앞에 붙이는 형식으로 바꾸고, 저장 도중 크래시에 대비해 임시 파일에 쓴 뒤 rename으로 교체하는 것이 안전합니다.

연결 타임아웃과 최대 연결 수

세션마다 steady_timer를 두고 읽기가 끝날 때마다 expires_after(std::chrono::seconds(300))로 재설정하면, 일정 시간 아무 데이터도 보내지 않는 연결을 닫을 수 있습니다. 동시 연결 수는 서버가 카운터를 들고 세션 생성 시 증가, 소멸 시(소멸자나 종료 콜백) 감소시키고, 상한을 넘으면 -ERR max clients reached를 보낸 뒤 연결을 닫습니다.

INFO 명령

struct Metrics {
    std::uint64_t total_commands = 0;
    std::uint64_t get_count = 0;
    std::uint64_t set_count = 0;
} metrics;

// INFO: 여러 줄이므로 단순 문자열(+)이 아니라 벌크 문자열($)로 응답
std::string info = "total_commands:" + std::to_string(metrics.total_commands)
                 + "\r\nget_count:" + std::to_string(metrics.get_count)
                 + "\r\nset_count:" + std::to_string(metrics.set_count);
return "$" + std::to_string(info.size()) + "\r\n" + info + "\r\n";

RESP의 단순 문자열(+)에는 줄바꿈을 넣을 수 없으므로, 여러 줄 응답은 길이를 앞에 붙인 벌크 문자열로 보냅니다. 싱글 스레드 서버라면 메트릭 카운터에 atomic이 필요 없고, 멀티스레드로 확장할 때 바꾸면 됩니다.

멀티스레드 확장

여러 스레드가 io.run()을 돌리게 하면 세션들이 병렬로 처리되지만, 공유 저장소 접근은 직렬화해야 합니다.

// 방법 1: 저장소 전용 strand에서 명령 실행 후, 세션의 strand로 돌아와 쓰기
auto store_strand = boost::asio::make_strand(io);
boost::asio::post(store_strand, [self, line] {
    std::string result = self->execute_command(line);
    boost::asio::post(self->socket_.get_executor(), [self, result = std::move(result)] {
        self->write_buf_ = std::move(result);
        self->do_write(false);
    });
});

// 방법 2: mutex (단순하지만 저장소 접근이 길어지면 스레드가 대기)
std::mutex store_mutex;
std::optional<std::string> get(const std::string& key) {
    std::lock_guard lock(store_mutex);
    auto it = store_.find(key);
    return it != store_.end() ? std::optional{it->second} : std::nullopt;
}

세션 소켓은 make_strand(io)로 만든 executor에 묶어 한 세션의 핸들러가 동시에 실행되지 않게 합니다. 자세한 내용은 고성능 네트워크 가이드 #2~#3을 참고하십시오. 키를 해시로 나눠 샤드마다 별도 맵과 strand를 두면 저장소 직렬화로 인한 병목도 줄일 수 있습니다.

RESP 프로토콜

실제 Redis 클라이언트와 통신하려면 RESP(Redis Serialization Protocol)를 구현해야 합니다.

+OK\r\n                              단순 문자열
-ERR message\r\n                     에러
:1\r\n                               정수
$6\r\nfoobar\r\n                     벌크 문자열 (길이 6)
$-1\r\n                              nil (RESP2)
*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n     배열: 클라이언트가 보내는 명령 형식

요청은 *<개수> 줄을 읽은 뒤, 각 원소마다 $<길이> 줄과 정확히 그 길이만큼의 바이트(그리고 \r\n)를 읽는 상태 기계로 파싱합니다. 길이가 명시되어 있으므로 값에 공백이나 줄바꿈이 있어도 됩니다. 이를 구현하면 redis-cli와 redis-benchmark를 그대로 쓸 수 있습니다. 참고로 실제 Redis도 사람이 telnet으로 입력하는 것을 위해 공백으로 구분된 한 줄 형식(inline command)을 함께 받아들입니다.

std::string format_bulk_string(const std::string& s) {
    return "$" + std::to_string(s.size()) + "\r\n" + s + "\r\n";
}

직접 구현과 Redis 중 무엇을 쓸까

이벤트 루프와 비동기 I/O를 배우는 것이 목적이거나, Redis를 설치할 수 없는 환경에서 아주 작은 기능만 필요하거나, 프로토콜을 완전히 직접 정의해야 한다면 직접 구현이 의미가 있습니다. 운영 환경에서 안정성, 영속성, 복제, 다양한 자료구조가 필요하다면 Redis(또는 호환 서버)를 쓰는 것이 맞습니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 이 클론을 실제로 쓰려면 무엇이 더 필요한가요?

A. 먼저 메모리 상한이 필요합니다. unordered_map은 제한 없이 커지므로, Redis의 maxmemory처럼 상한을 두고 LRU 같은 축출 정책을 정해야 합니다. 재시작 시 데이터를 잃지 않으려면 영속성이, 외부에 노출한다면 인증과 TLS가 필요하고, 줄바꿈 없이 데이터를 계속 보내는 클라이언트를 막기 위한 입력 크기 제한과 유휴 연결 타임아웃도 있어야 합니다.

다음으로 HTTP 프레임워크(#48-2)를 읽어 보면 좋습니다.

다음 글: [실전 딥다이브 #48-2] 초경량 HTTP 웹 프레임워크 바닥부터 만들기

이전 글: [C++ vs 타 언어 #47-3] C++ 개발자가 보는 Rust 메모리 안전성: 소유권, Borrow Checker, 수명, unsafe 경계