본문으로 건너뛰기 C++ 데이터베이스 엔진 구현 완벽 가이드 | B-Tree·WAL·MVCC·쿼리 최적화

C++ 데이터베이스 엔진 구현 완벽 가이드 | B-Tree·WAL·MVCC·쿼리 최적화

C++ 데이터베이스 엔진 구현 완벽 가이드 | B-Tree·WAL·MVCC·쿼리 최적화

들어가며: “SQLite처럼 간단한 DB 엔진을 만들고 싶어요”

데이터베이스 엔진의 핵심

관계형 데이터베이스는 저장 엔진, 인덱스, 트랜잭션, 쿼리 처리로 구성됩니다. 이 글에서는 간단한 DB 엔진의 핵심을 구현합니다. 목표

  • 페이지 기반 저장 엔진

  • B-Tree 인덱스 구현

  • ACID 트랜잭션 (WAL 로그)

  • SQL 파서 및 실행 엔진

  • 쿼리 최적화 요구 환경: C++17 이상 이 글을 읽으면

  • DB 저장 엔진을 구현할 수 있습니다.

  • B-Tree 인덱스를 만들 수 있습니다.

  • 트랜잭션을 구현할 수 있습니다.

  • 간단한 SQL 파서를 작성할 수 있습니다.

문제 시나리오: DB 엔진이 필요한 상황

임베디드 환경의 로컬 저장소

IoT 디바이스나 엣지 서버에서 SQLite 없이 경량 DB가 필요할 때가 있다. 외부 라이브러리 의존성을 줄이며, 메모리·디스크 제약에 맞춘 최소 구현이 필요하다. 특수 목적 인덱스

게임 세이브 데이터, 시계열 로그, 지리공간 데이터처럼 도메인 특화 인덱스가 필요할 때, 범용 DB보다 직접 B-Tree를 구현해 제어할 수 있다. DB 내부 동작 학습

“인덱스가 왜 빠른가?”, “트랜잭션이 어떻게 원자성을 보장하는가?”를 이해하려면 직접 구현하는 것이 가장 효과적이다. 인메모리 캐시 엔진

Redis처럼 키-값 캐시를 만들되, B-Tree 기반 범위 쿼리가 필요할 때, 페이지 기반 저장 구조를 이해하면 설계가 수월합니다. 쿼리 최적화 이해

“왜 이 쿼리가 느린가?”를 파악하려면 실행 계획·인덱스 스캔 vs 풀 스캔 개념이 필요합니다. 직접 파서와 옵티마이저를 구현하면 원리를 깊이 이해할 수 있습니다.

개념을 잡는 비유

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

1. 저장 엔진

아키텍처 다이어그램

flowchart TB
    subgraph Client[클라이언트]
        C1[SQL 쿼리]
    end
    subgraph DB[데이터베이스 엔진]
        subgraph Parser[파서]
            P1[SQL 파싱]
        end
        subgraph Optimizer[옵티마이저]
            O1[실행 계획]
        end
        subgraph Storage[저장 엔진]
            S1[Pager]
            S2[B-Tree]
        end
        subgraph Txn[트랜잭션]
            T1[WAL]
        end
    end
    C1 --> Parser
    Parser --> Optimizer
    Optimizer --> Storage
    Storage --> Txn

페이지 기반 저장

디스크 I/O는 페이지 단위로 수행하는 것이 효율적입니다. 4KB 페이지로 묶어 읽고 쓰면 랜덤 I/O를 줄일 수 있습니다.

constexpr size_t PAGE_SIZE = 4096;  // 4KB
struct Page {
    uint32_t page_id;
    uint8_t data[PAGE_SIZE];
    bool dirty = false;
};
class Pager {
    std::fstream file_;
    std::unordered_map<uint32_t, std::unique_ptr<Page>> cache_;
    uint32_t num_pages_ = 0;
    
public:
    Pager(const std::string& filename) {
        file_.open(filename, std::ios::in | std::ios::out | std::ios::binary);
        if (!file_) {
            file_.open(filename, std::ios::out | std::ios::binary);
            file_.close();
            file_.open(filename, std::ios::in | std::ios::out | std::ios::binary);
        }
        
        // 파일 크기로 페이지 수 계산
        file_.seekg(0, std::ios::end);
        auto size = file_.tellg();
        num_pages_ = size / PAGE_SIZE;
    }
    
    Page* get_page(uint32_t page_id) {
        // 캐시 확인
        auto it = cache_.find(page_id);
        if (it != cache_.end()) {
            return it->second.get();
        }
        
        // 디스크에서 로드
        auto page = std::make_unique<Page>();
        page->page_id = page_id;
        
        if (page_id < num_pages_) {
            file_.seekg(page_id * PAGE_SIZE);
            file_.read(reinterpret_cast<char*>(page->data), PAGE_SIZE);
        } else {
            std::memset(page->data, 0, PAGE_SIZE);
            num_pages_ = page_id + 1;
        }
        
        auto* ptr = page.get();
        cache_[page_id] = std::move(page);
        return ptr;
    }
    
    void flush_page(uint32_t page_id) {
        auto it = cache_.find(page_id);
        if (it == cache_.end() || !it->second->dirty) {
            return;
        }
        
        file_.seekp(page_id * PAGE_SIZE);
        file_.write(reinterpret_cast<const char*>(it->second->data), PAGE_SIZE);
        file_.flush();
        it->second->dirty = false;
    }
    
    void flush_all() {
        for (auto& [id, page] : cache_) {
            if (page->dirty) {
                flush_page(id);
            }
        }
    }
    
    uint32_t allocate_page() {
        return num_pages_++;
    }
};

테이블 구조

struct Column {
    std::string name;
    enum Type { INT, VARCHAR, FLOAT } type;
    size_t size;
};
struct Schema {
    std::string table_name;
    std::vector<Column> columns;
    std::vector<std::string> primary_keys;
};
class Table {
    Schema schema_;
    Pager& pager_;
    uint32_t root_page_id_;
    
public:
    Table(const Schema& schema, Pager& pager)
        : schema_(schema), pager_(pager) {
        root_page_id_ = pager_.allocate_page();
    }
    
    void insert(const std::vector<std::string>& values) {
        // 행을 직렬화
        std::vector<uint8_t> row_data = serialize_row(values);
        
        // B-Tree에 삽입
        insert_into_btree(root_page_id_, row_data);
    }
    
    std::vector<std::vector<std::string>> select(
        const std::function<bool(const std::vector<std::string>&)>& predicate
    ) {
        std::vector<std::vector<std::string>> results;
        scan_btree(root_page_id_, [&](const std::vector<uint8_t>& row_data) {
            auto row = deserialize_row(row_data);
            if (predicate(row)) {
                results.push_back(row);
            }
        });
        return results;
    }
    
private:
    std::vector<uint8_t> serialize_row(const std::vector<std::string>& values) {
        std::vector<uint8_t> data;
        for (size_t i = 0; i < schema_.columns.size(); ++i) {
            const auto& col = schema_.columns[i];
            const auto& val = values[i];
            
            switch (col.type) {
                case Column::INT: {
                    int32_t num = std::stoi(val);
                    data.insert(data.end(), 
                        reinterpret_cast<uint8_t*>(&num),
                        reinterpret_cast<uint8_t*>(&num) + sizeof(num));
                    break;
                }
                case Column::VARCHAR: {
                    uint16_t len = val.length();
                    data.insert(data.end(),
                        reinterpret_cast<uint8_t*>(&len),
                        reinterpret_cast<uint8_t*>(&len) + sizeof(len));
                    data.insert(data.end(), val.begin(), val.end());
                    break;
                }
                case Column::FLOAT: {
                    float num = std::stof(val);
                    data.insert(data.end(),
                        reinterpret_cast<uint8_t*>(&num),
                        reinterpret_cast<uint8_t*>(&num) + sizeof(num));
                    break;
                }
            }
        }
        return data;
    }
    
    std::vector<std::string> deserialize_row(const std::vector<uint8_t>& data) {
        std::vector<std::string> values;
        size_t offset = 0;
        
        for (const auto& col : schema_.columns) {
            switch (col.type) {
                case Column::INT: {
                    int32_t num;
                    std::memcpy(&num, data.data() + offset, sizeof(num));
                    values.push_back(std::to_string(num));
                    offset += sizeof(num);
                    break;
                }
                case Column::VARCHAR: {
                    uint16_t len;
                    std::memcpy(&len, data.data() + offset, sizeof(len));
                    offset += sizeof(len);
                    values.push_back(std::string(
                        data.begin() + offset,
                        data.begin() + offset + len
                    ));
                    offset += len;
                    break;
                }
                case Column::FLOAT: {
                    float num;
                    std::memcpy(&num, data.data() + offset, sizeof(num));
                    values.push_back(std::to_string(num));
                    offset += sizeof(num);
                    break;
                }
            }
        }
        return values;
    }
};

2. B-Tree 인덱스

B-Tree 노드 구조

B-Tree는 균형 트리로, O(log N) 검색·삽입을 보장합니다. 각 노드는 한 페이지에 저장됩니다.

struct BTreeNode {
    bool is_leaf;
    uint32_t num_keys;
    std::vector<int32_t> keys;
    std::vector<uint32_t> children;  // 자식 페이지 ID
    std::vector<std::vector<uint8_t>> values;  // 리프 노드만
    
    static constexpr size_t MAX_KEYS = 100;
    
    void serialize(uint8_t* page_data) {
        size_t offset = 0;
        std::memcpy(page_data + offset, &is_leaf, sizeof(is_leaf));
        offset += sizeof(is_leaf);
        
        std::memcpy(page_data + offset, &num_keys, sizeof(num_keys));
        offset += sizeof(num_keys);
        
        for (uint32_t i = 0; i < num_keys; ++i) {
            std::memcpy(page_data + offset, &keys[i], sizeof(keys[i]));
            offset += sizeof(keys[i]);
        }
        
        if (!is_leaf) {
            for (uint32_t i = 0; i <= num_keys; ++i) {
                std::memcpy(page_data + offset, &children[i], sizeof(children[i]));
                offset += sizeof(children[i]);
            }
        } else {
            // 값 직렬화
            for (uint32_t i = 0; i < num_keys; ++i) {
                uint16_t value_size = values[i].size();
                std::memcpy(page_data + offset, &value_size, sizeof(value_size));
                offset += sizeof(value_size);
                
                std::memcpy(page_data + offset, values[i].data(), value_size);
                offset += value_size;
            }
        }
    }
    
    static BTreeNode deserialize(const uint8_t* page_data) {
        BTreeNode node;
        size_t offset = 0;
        
        std::memcpy(&node.is_leaf, page_data + offset, sizeof(node.is_leaf));
        offset += sizeof(node.is_leaf);
        
        std::memcpy(&node.num_keys, page_data + offset, sizeof(node.num_keys));
        offset += sizeof(node.num_keys);
        
        node.keys.resize(node.num_keys);
        for (uint32_t i = 0; i < node.num_keys; ++i) {
            std::memcpy(&node.keys[i], page_data + offset, sizeof(node.keys[i]));
            offset += sizeof(node.keys[i]);
        }
        
        if (!node.is_leaf) {
            node.children.resize(node.num_keys + 1);
            for (uint32_t i = 0; i <= node.num_keys; ++i) {
                std::memcpy(&node.children[i], page_data + offset, sizeof(node.children[i]));
                offset += sizeof(node.children[i]);
            }
        } else {
            node.values.resize(node.num_keys);
            for (uint32_t i = 0; i < node.num_keys; ++i) {
                uint16_t value_size;
                std::memcpy(&value_size, page_data + offset, sizeof(value_size));
                offset += sizeof(value_size);
                
                node.values[i].resize(value_size);
                std::memcpy(node.values[i].data(), page_data + offset, value_size);
                offset += value_size;
            }
        }
        
        return node;
    }
};
class BTree {
    Pager& pager_;
    uint32_t root_page_id_;
    
public:
    BTree(Pager& pager, uint32_t root_page_id)
        : pager_(pager), root_page_id_(root_page_id) {}
    
    void insert(int32_t key, const std::vector<uint8_t>& value) {
        auto* root_page = pager_.get_page(root_page_id_);
        auto root = BTreeNode::deserialize(root_page->data);
        
        if (root.num_keys >= BTreeNode::MAX_KEYS) {
            // 루트 분할: 새 루트 생성 후 기존 루트를 자식으로
            uint32_t new_root_id = pager_.allocate_page();
            split_root(root_page_id_, new_root_id);
            root_page_id_ = new_root_id;
        }
        
        insert_non_full(root_page_id_, key, value);
    }
    
    std::optional<std::vector<uint8_t>> search(int32_t key) {
        return search_recursive(root_page_id_, key);
    }
    
private:
    void split_root(uint32_t old_root_id, uint32_t new_root_id) {
        auto* old_page = pager_.get_page(old_root_id);
        auto old_node = BTreeNode::deserialize(old_page->data);
        
        auto* new_page = pager_.get_page(new_root_id);
        BTreeNode new_root;
        new_root.is_leaf = false;
        new_root.num_keys = 1;
        
        size_t mid = old_node.num_keys / 2;
        new_root.keys.push_back(old_node.keys[mid]);
        new_root.children.push_back(old_root_id);
        
        // 오른쪽 절반을 새 페이지로
        uint32_t right_id = pager_.allocate_page();
        auto* right_page = pager_.get_page(right_id);
        BTreeNode right_node;
        right_node.is_leaf = old_node.is_leaf;
        right_node.keys.assign(old_node.keys.begin() + mid + 1, old_node.keys.end());
        right_node.num_keys = right_node.keys.size();
        
        if (old_node.is_leaf) {
            right_node.values.assign(old_node.values.begin() + mid + 1, old_node.values.end());
        } else {
            right_node.children.assign(old_node.children.begin() + mid + 1, old_node.children.end());
        }
        
        new_root.children.push_back(right_id);
        
        // 기존 노드 크기 줄임
        old_node.keys.resize(mid);
        old_node.num_keys = mid;
        if (old_node.is_leaf) {
            old_node.values.resize(mid);
        } else {
            old_node.children.resize(mid + 1);
        }
        
        new_root.serialize(new_page->data);
        new_page->dirty = true;
        old_node.serialize(old_page->data);
        old_page->dirty = true;
        right_node.serialize(right_page->data);
        right_page->dirty = true;
    }
    
    void insert_non_full(uint32_t page_id, int32_t key, const std::vector<uint8_t>& value) {
        auto* page = pager_.get_page(page_id);
        auto node = BTreeNode::deserialize(page->data);
        
        if (node.is_leaf) {
            // 리프 노드에 삽입
            auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
            size_t pos = it - node.keys.begin();
            
            node.keys.insert(it, key);
            node.values.insert(node.values.begin() + pos, value);
            node.num_keys++;
            
            node.serialize(page->data);
            page->dirty = true;
        } else {
            // 내부 노드: 적절한 자식 찾기
            auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
            size_t pos = it - node.keys.begin();
            
            uint32_t child_id = node.children[pos];
            auto* child_page = pager_.get_page(child_id);
            auto child = BTreeNode::deserialize(child_page->data);
            
            if (child.num_keys >= BTreeNode::MAX_KEYS) {
                split_child(page_id, pos);
                // 분할 후 다시 적절한 자식 선택
                node = BTreeNode::deserialize(page->data);
                if (key > node.keys[pos]) {
                    pos++;
                }
            }
            
            insert_non_full(node.children[pos], key, value);
        }
    }
    
    void split_child(uint32_t parent_id, size_t child_index) {
        auto* parent_page = pager_.get_page(parent_id);
        auto parent = BTreeNode::deserialize(parent_page->data);
        
        uint32_t child_id = parent.children[child_index];
        auto* child_page = pager_.get_page(child_id);
        auto child = BTreeNode::deserialize(child_page->data);
        
        // 새 노드 생성
        uint32_t new_child_id = pager_.allocate_page();
        auto* new_child_page = pager_.get_page(new_child_id);
        BTreeNode new_child;
        new_child.is_leaf = child.is_leaf;
        
        size_t mid = child.num_keys / 2;
        
        // 중간 키를 부모로 올림
        int32_t mid_key = child.keys[mid];
        parent.keys.insert(parent.keys.begin() + child_index, mid_key);
        parent.children.insert(parent.children.begin() + child_index + 1, new_child_id);
        parent.num_keys++;
        
        // 오른쪽 절반을 새 노드로 이동
        new_child.keys.assign(child.keys.begin() + mid + 1, child.keys.end());
        new_child.num_keys = new_child.keys.size();
        
        if (child.is_leaf) {
            new_child.values.assign(child.values.begin() + mid + 1, child.values.end());
        } else {
            new_child.children.assign(child.children.begin() + mid + 1, child.children.end());
        }
        
        // 원래 노드 크기 줄임
        child.keys.resize(mid);
        child.num_keys = mid;
        if (child.is_leaf) {
            child.values.resize(mid);
        } else {
            child.children.resize(mid + 1);
        }
        
        // 페이지에 저장
        parent.serialize(parent_page->data);
        parent_page->dirty = true;
        
        child.serialize(child_page->data);
        child_page->dirty = true;
        
        new_child.serialize(new_child_page->data);
        new_child_page->dirty = true;
    }
    
    std::optional<std::vector<uint8_t>> search_recursive(uint32_t page_id, int32_t key) {
        auto* page = pager_.get_page(page_id);
        auto node = BTreeNode::deserialize(page->data);
        
        auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
        size_t pos = it - node.keys.begin();
        
        if (it != node.keys.end() && *it == key) {
            if (node.is_leaf) {
                return node.values[pos];
            }
            // 내부 노드에서 찾은 경우
            return search_recursive(node.children[pos + 1], key);
        }
        
        if (node.is_leaf) {
            return std::nullopt;
        }
        
        return search_recursive(node.children[pos], key);
    }
};

3. 트랜잭션 관리

WAL (Write-Ahead Logging)

WAL은 디스크에 변경 전에 로그를 먼저 기록하는 방식입니다. 크래시 시 REDO/UNDO로 복구할 수 있습니다.

struct WALEntry {
    enum Type { INSERT, UPDATE, DELETE } type;
    uint32_t page_id;
    std::vector<uint8_t> before_image;
    std::vector<uint8_t> after_image;
};
class TransactionManager {
    std::fstream wal_file_;
    std::vector<WALEntry> current_transaction_;
    uint64_t next_txn_id_ = 1;
    
public:
    TransactionManager(const std::string& wal_filename) {
        wal_file_.open(wal_filename, 
            std::ios::in | std::ios::out | std::ios::binary | std::ios::app);
    }
    
    uint64_t begin_transaction() {
        current_transaction_.clear();
        return next_txn_id_++;
    }
    
    void log_write(uint32_t page_id, 
                   const std::vector<uint8_t>& before,
                   const std::vector<uint8_t>& after) {
        WALEntry entry;
        entry.type = WALEntry::UPDATE;
        entry.page_id = page_id;
        entry.before_image = before;
        entry.after_image = after;
        
        current_transaction_.push_back(entry);
    }
    
    void commit() {
        // WAL에 기록
        for (const auto& entry : current_transaction_) {
            write_wal_entry(entry);
        }
        wal_file_.flush();
        
        current_transaction_.clear();
    }
    
    void rollback(Pager& pager) {
        // UNDO: before_image로 복구
        for (auto it = current_transaction_.rbegin(); 
             it != current_transaction_.rend(); ++it) {
            auto* page = pager.get_page(it->page_id);
            std::memcpy(page->data, it->before_image.data(), PAGE_SIZE);
            page->dirty = true;
        }
        
        current_transaction_.clear();
    }
    
    void recover(Pager& pager) {
        wal_file_.seekg(0, std::ios::beg);
        
        while (wal_file_.peek() != EOF) {
            auto entry = read_wal_entry();
            
            // REDO: after_image 적용
            auto* page = pager.get_page(entry.page_id);
            std::memcpy(page->data, entry.after_image.data(), PAGE_SIZE);
            page->dirty = true;
        }
        
        pager.flush_all();
    }
    
private:
    void write_wal_entry(const WALEntry& entry) {
        uint8_t type = static_cast<uint8_t>(entry.type);
        wal_file_.write(reinterpret_cast<const char*>(&type), sizeof(type));
        wal_file_.write(reinterpret_cast<const char*>(&entry.page_id), sizeof(entry.page_id));
        
        uint32_t before_size = entry.before_image.size();
        wal_file_.write(reinterpret_cast<const char*>(&before_size), sizeof(before_size));
        wal_file_.write(reinterpret_cast<const char*>(entry.before_image.data()), before_size);
        
        uint32_t after_size = entry.after_image.size();
        wal_file_.write(reinterpret_cast<const char*>(&after_size), sizeof(after_size));
        wal_file_.write(reinterpret_cast<const char*>(entry.after_image.data()), after_size);
    }
    
    WALEntry read_wal_entry() {
        WALEntry entry;
        
        uint8_t type;
        wal_file_.read(reinterpret_cast<char*>(&type), sizeof(type));
        entry.type = static_cast<WALEntry::Type>(type);
        
        wal_file_.read(reinterpret_cast<char*>(&entry.page_id), sizeof(entry.page_id));
        
        uint32_t before_size;
        wal_file_.read(reinterpret_cast<char*>(&before_size), sizeof(before_size));
        entry.before_image.resize(before_size);
        wal_file_.read(reinterpret_cast<char*>(entry.before_image.data()), before_size);
        
        uint32_t after_size;
        wal_file_.read(reinterpret_cast<char*>(&after_size), sizeof(after_size));
        entry.after_image.resize(after_size);
        wal_file_.read(reinterpret_cast<char*>(entry.after_image.data()), after_size);
        
        return entry;
    }
};

4. 쿼리 파서

간단한 SQL 파서

struct SelectStatement {
    std::vector<std::string> columns;
    std::string table_name;
    std::optional<std::string> where_clause;
};
class SQLParser {
public:
    SelectStatement parse_select(const std::string& sql) {
        SelectStatement stmt;
        
        // 간단한 토크나이저
        std::istringstream iss(sql);
        std::string token;
        
        // SELECT
        iss >> token;
        if (token != "SELECT") {
            throw std::runtime_error("Expected SELECT");
        }
        
        // 컬럼 목록
        std::string columns_str;
        std::getline(iss, columns_str, ' ');
        // FROM 전까지 읽기
        while (iss >> token && token != "FROM") {
            columns_str += " " + token;
        }
        
        // 컬럼 파싱
        std::istringstream col_stream(columns_str);
        std::string col;
        while (std::getline(col_stream, col, ',')) {
            col.erase(0, col.find_first_not_of(" \t"));
            col.erase(col.find_last_not_of(" \t") + 1);
            stmt.columns.push_back(col);
        }
        
        // 테이블 이름
        iss >> stmt.table_name;
        
        // WHERE 절 (선택사항)
        if (iss >> token && token == "WHERE") {
            std::string where;
            std::getline(iss, where);
            stmt.where_clause = where;
        }
        
        return stmt;
    }
};

5. 실행 계획 최적화

쿼리 최적화

struct QueryPlan {
    enum Type { FULL_SCAN, INDEX_SCAN } type;
    std::string table_name;
    std::optional<std::string> index_name;
    std::function<bool(const std::vector<std::string>&)> filter;
};
class QueryOptimizer {
    std::unordered_map<std::string, std::vector<std::string>> table_indexes_;
    
public:
    QueryPlan optimize(const SelectStatement& stmt) {
        QueryPlan plan;
        plan.table_name = stmt.table_name;
        
        if (stmt.where_clause) {
            // WHERE 절 분석
            auto condition = parse_where(*stmt.where_clause);
            
            // 인덱스 사용 가능 여부 확인
            if (can_use_index(stmt.table_name, condition.column)) {
                plan.type = QueryPlan::INDEX_SCAN;
                plan.index_name = condition.column;
            } else {
                plan.type = QueryPlan::FULL_SCAN;
            }
            
            plan.filter = create_filter(condition);
        } else {
            plan.type = QueryPlan::FULL_SCAN;
            plan.filter = [](const std::vector<std::string>&) { return true; };
        }
        
        return plan;
    }
    
private:
    struct Condition {
        std::string column;
        std::string op;
        std::string value;
    };
    
    Condition parse_where(const std::string& where_clause) {
        // 간단한 파싱: "column = value"
        std::istringstream iss(where_clause);
        Condition cond;
        iss >> cond.column >> cond.op >> cond.value;
        return cond;
    }
    
    bool can_use_index(const std::string& table, const std::string& column) {
        auto it = table_indexes_.find(table);
        if (it == table_indexes_.end()) return false;
        
        return std::find(it->second.begin(), it->second.end(), column) 
            != it->second.end();
    }
    
    std::function<bool(const std::vector<std::string>&)> create_filter(const Condition& cond) {
        return [cond](const std::vector<std::string>& row) {
            // 간단한 비교 (실제로는 컬럼 인덱스 필요)
            if (cond.op == "=") {
                return row[0] == cond.value;  // 예시
            }
            return false;
        };
    }
};

6. 완전한 DB 엔진 예제

통합 Database 클래스

class Database {
    Pager pager_;
    TransactionManager txn_mgr_;
    SQLParser parser_;
    QueryOptimizer optimizer_;
    std::unordered_map<std::string, std::unique_ptr<Table>> tables_;
    
public:
    Database(const std::string& db_file)
        : pager_(db_file),
          txn_mgr_(db_file + ".wal") {
        // 복구
        txn_mgr_.recover(pager_);
    }
    
    void execute(const std::string& sql) {
        auto stmt = parser_.parse_select(sql);
        auto plan = optimizer_.optimize(stmt);
        
        auto* table = tables_[plan.table_name].get();
        auto results = table->select(plan.filter);
        
        // 결과 출력
        for (const auto& row : results) {
            for (const auto& val : row) {
                std::cout << val << " ";
            }
            std::cout << "\n";
        }
    }
    
    void begin_transaction() {
        txn_mgr_.begin_transaction();
    }
    
    void commit() {
        pager_.flush_all();
        txn_mgr_.commit();
    }
    
    void rollback() {
        txn_mgr_.rollback(pager_);
    }
};

실행 예시: 사용자 테이블 생성 및 쿼리

// main.cpp
#include <iostream>
#include "database.h"
int main() {
    Database db("mydb.dat");
    
    // 스키마 정의
    Schema user_schema;
    user_schema.table_name = "users";
    user_schema.columns = {
        {"id", Column::INT, 4},
        {"name", Column::VARCHAR, 64},
        {"age", Column::INT, 4}
    };
    user_schema.primary_keys = {"id"};
    
    // 테이블 생성 및 데이터 삽입
    db.create_table(user_schema);
    db.begin_transaction();
    
    db.execute("INSERT INTO users VALUES (1, 'Alice', 30)");
    db.execute("INSERT INTO users VALUES (2, 'Bob', 25)");
    db.execute("INSERT INTO users VALUES (3, 'Charlie', 35)");
    
    db.commit();
    
    // SELECT 쿼리
    db.execute("SELECT id, name, age FROM users WHERE age > 28");
    // 출력: 1 Alice 30
    //       3 Charlie 35
    
    return 0;
}

7. 자주 발생하는 에러와 해결법

문제 1: “Page not found” 또는 잘못된 페이지 읽기

원인: 빈 파일에서 get_page(0) 호출 시 num_pages_=0이라 0바이트 읽음. 해결: else 분기에서 std::memset(page->data, 0, PAGE_SIZE)로 초기화하고 num_pages_ = std::max(num_pages_, page_id + 1)로 갱신.

문제 2: B-Tree 삽입 시 “vector subscript out of range”

원인: 리프 노드가 비어 있을 때 node.values에 접근. 해결: num_keys와 동기화, values.resizeinsert.

문제 3: WAL 복구 후 데이터 손실

원인: recover()에서 커밋되지 않은 트랜잭션까지 REDO 적용하거나 WAL 손상. 해결: WAL 엔트리에 magic·checksum 추가해 무결성 검증.

문제 4: 캐시 메모리 폭증

원인: Pager 캐시에 제한이 없음. 해결: MAX_CACHED_PAGES(예: 1000)로 LRU 캐시 구현. evict_lru_page()로 가장 오래된 dirty 페이지 flush 후 제거.

문제 5: SQL 파서 “Expected SELECT” 에러

원인: 대소문자 구분, 앞뒤 공백. 해결: to_upper(token)으로 정규화 후 비교, "Expected SELECT, got: " + token으로 에러 메시지 출력.

8. 성능 최적화 팁

팁 1: 배치 플러시

한 트랜잭션 내 여러 페이지 수정 시, 커밋 시점에 한 번에 플러시하면 I/O 횟수를 줄일 수 있습니다.

// ✅ 커밋 시에만 flush
void commit() {
    for (const auto& entry : current_transaction_) {
        write_wal_entry(entry);
    }
    wal_file_.flush();
    
    // Pager는 commit()에서 한 번에 flush
    pager_.flush_all();
    current_transaction_.clear();
}

팁 2: 페이지 정렬 플러시

디스크는 순차 I/O가 랜덤 I/O보다 훨씬 빠릅니다. dirty 페이지를 page_id 순으로 정렬해 플러시하면 디스크 헤드 이동을 줄입니다.

void flush_all() {
    std::vector<uint32_t> dirty_ids;
    for (const auto& [id, page] : cache_) {
        if (page->dirty) dirty_ids.push_back(id);
    }
    std::sort(dirty_ids.begin(), dirty_ids.end());
    
    for (uint32_t id : dirty_ids) {
        flush_page(id);
    }
}

팁 3: B-Tree 노드 크기 튜닝

MAX_KEYS를 페이지 크기에 맞게 조정하면 트리 높이를 줄일 수 있습니다. 4KB 페이지에서 int32 키 + 8바이트 자식 포인터 기준으로 약 200개 키 수용 가능.

// PAGE_SIZE 4096, 키 4바이트, 자식 4바이트
// 내부 노드: 1 + 4 + num_keys*4 + (num_keys+1)*4 <= 4096
// num_keys <= 340
static constexpr size_t MAX_KEYS = 200;  // 여유 있게

팁 4: WAL 그룹 커밋

여러 트랜잭션이 동시에 커밋할 때, WAL 쓰기를 묶어서 fsync 횟수를 줄입니다. 10ms 대기 후 또는 100개 엔트리 모이면 배치 flush.

팁 5: 프리페치

범위 스캔 시 다음 페이지를 미리 읽어 I/O 대기 시간을 숨깁니다. pager_.prefetch(next_leaf_page_id) 호출로 다음 리프를 비동기 로드합니다.

9. 프로덕션 패턴

패턴 1: 스레드 안전성

다중 스레드에서 Pager 접근 시 std::shared_mutex로 보호. get_pageshared_lock, flush_pageunique_lock 사용.

패턴 2: 체크포인트

WAL이 무한히 커지지 않도록 주기적으로 pager_.flush_all() 후 WAL 파일을 비웁니다. truncate로 WAL을 초기화하고 다시 app 모드로 열면 됩니다.

패턴 3: 헬스 체크

wal_size_bytes, cache_size, cache_hits, cache_misses를 추적하고 hit_ratio = cache_hits / (cache_hits + cache_misses)로 모니터링합니다.

패턴 4: 백업 및 복원

온라인 백업 시 WAL을 함께 복사합니다.

void backup(const std::string& backup_path) {
    pager_.flush_all();
    std::filesystem::copy(db_path_, backup_path, 
        std::filesystem::copy_options::overwrite_existing);
    std::filesystem::copy(db_path_ + ".wal", backup_path + ".wal",
        std::filesystem::copy_options::overwrite_existing);
}

패턴 5: 설정 외부화

struct DBConfig {
    size_t page_size = 4096;
    size_t max_cached_pages = 1000;
    size_t wal_checkpoint_interval = 1000;
    bool fsync_on_commit = true;
};

정리

컴포넌트역할
Pager페이지 기반 저장
B-Tree인덱스 구조
WAL트랜잭션 로그
ParserSQL 파싱
Optimizer쿼리 최적화
핵심 원칙:
  1. 페이지 단위로 I/O 최소화
  2. B-Tree로 빠른 검색
  3. WAL로 ACID 보장
  4. 인덱스 활용으로 성능 향상
  5. 쿼리 최적화로 효율성 확보

실전 체크리스트

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 경우가 충분한가?
  • 문서화가 되어 있는가? 이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 임베디드 DB 개발, 특수 목적 DB 엔진, 교육용 DB 시스템, 인메모리 캐시 등에 활용합니다. DB 내부 동작을 이해하면 쿼리 최적화와 성능 튜닝에 큰 도움이 됩니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다. 한 줄 요약: 페이지 저장, B-Tree, WAL, SQL 파서로 간단한 DB 엔진을 구현할 수 있습니다. 다음 글: [C++ 실전 가이드 #51-1] 프로파일링 도구 마스터 이전 글: [C++ 실전 가이드 #50-3] 게임 엔진 기초

관련 글


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, 데이터베이스, B-Tree, 트랜잭션, ACID, 쿼리최적화 등으로 검색하시면 이 글이 도움이 됩니다.