C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]

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을 알면 좋습니다.

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.


목차

  1. 전체 구조
  2. 서버·Acceptor·세션 뼈대
  3. 프로토콜 파싱·명령 처리
  4. Key-Value 저장소·GET/SET
  5. 완전한 Redis 클론 예시
  6. 자주 발생하는 에러와 해결법
  7. 성능 최적화 팁
  8. 프로덕션 패턴
  9. 실행·테스트·확장 아이디어

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

acceptortcp::v4(), 6379 (Redis 기본 포트) 로 리스닝하고, do_accept() 에서 async_accept 로 비동기 수락을 걸어 둡니다. 완료 콜백에서 !ec 이면 Sessionmake_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

  • Sessionshared_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 QPSSET QPS동시 연결
MacBook M1, 싱글 스레드~45,000~42,000100
MacBook M1, 4 스레드~120,000~110,000100
Linux x86_64, 싱글 스레드~55,000~50,000100

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]
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3