C++ 데이터베이스 엔진 구현 | B-Tree·트랜잭션·쿼리 최적화 [#50-4]

C++ 데이터베이스 엔진 구현 | B-Tree·트랜잭션·쿼리 최적화 [#50-4]

이 글의 핵심

C++ 데이터베이스 엔진 구현에 대한 실전 가이드입니다. B-Tree·트랜잭션·쿼리 최적화 [#50-4] 등을 예제와 함께 상세히 설명합니다.

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

데이터베이스 엔진의 핵심

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

목표:

  • 페이지 기반 저장 엔진
  • B-Tree 인덱스 구현
  • ACID 트랜잭션 (WAL 로그)
  • SQL 파서 및 실행 엔진
  • 쿼리 최적화

요구 환경: C++17 이상

이 글을 읽으면:

  • DB 저장 엔진을 구현할 수 있습니다.
  • B-Tree 인덱스를 만들 수 있습니다.
  • 트랜잭션을 구현할 수 있습니다.
  • 간단한 SQL 파서를 작성할 수 있습니다.

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

시나리오 1: 임베디드 환경의 로컬 저장소
IoT 디바이스나 엣지 서버에서 SQLite 없이 경량 DB가 필요할 때가 있습니다. 외부 라이브러리 의존성을 줄이고, 메모리·디스크 제약에 맞춘 최소 구현이 필요합니다.

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

시나리오 3: DB 내부 동작 학습
”인덱스가 왜 빠른가?”, “트랜잭션이 어떻게 원자성을 보장하는가?”를 이해하려면 직접 구현하는 것이 가장 효과적입니다.

시나리오 4: 인메모리 캐시 엔진
Redis처럼 키-값 캐시를 만들되, B-Tree 기반 범위 쿼리가 필요할 때, 페이지 기반 저장 구조를 이해하면 설계가 수월합니다.

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

개념을 잡는 비유

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


목차

  1. 저장 엔진
  2. B-Tree 인덱스
  3. 트랜잭션 관리
  4. 쿼리 파서
  5. 실행 계획 최적화
  6. 완전한 DB 엔진 예제
  7. 자주 발생하는 에러와 해결법
  8. 성능 최적화 팁
  9. 프로덕션 패턴

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 =  { 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++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]
  • C++ 쿼리 최적화 완벽 가이드 | 인덱스 선택·실행 계획·통계·비용 모델·프로덕션 패턴 [#49-3]