C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]

C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]

이 글의 핵심

C++ DB 엔진 기초 완벽 가이드에 대한 실전 가이드입니다. 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1] 등을 예제와 함께 설명합니다.

들어가며: “100만 행에서 특정 키를 찾는데 왜 이렇게 느릴까요?”

왜 DB 엔진 기초인가

데이터베이스는 저장 엔진, 쿼리 파서, 실행기, 트랜잭션이 결합된 시스템입니다. SQLite나 MySQL을 쓰더라도 내부 동작을 이해하지 못하면 “인덱스가 왜 필요한가?”, “트랜잭션이 어떻게 원자성을 보장하는가?”를 해결하기 어렵습니다. 이 글은 실제 겪는 문제 시나리오, 완전한 C++ 예제(저장 엔진·파서·실행기·트랜잭션), 자주 하는 실수, 프로덕션 패턴까지 포함해 DB 엔진 핵심을 실전 관점에서 다룹니다.

이 글에서 다루는 것:

  • 문제 시나리오: 풀 스캔 병목, 트랜잭션 롤백 실패, 파서 에러 등 실제 상황
  • 저장 엔진: 페이지 기반 Pager, B-Tree 인덱스
  • 쿼리 파서: SELECT/INSERT 파싱
  • 실행기: 쿼리 실행, 결과 반환
  • 트랜잭션: WAL, ACID 보장
  • 자주 하는 실수: 페이지 캐시 폭발, WAL 손상
  • 프로덕션 패턴: LRU 캐시, 체크포인트, 백업

관련 글: 데이터베이스 연동, 바이너리 직렬화.

개념을 잡는 비유

DB 엔진은 도서관에 비유하기 좋습니다. 저장 엔진은 책이 꽂히는 책장(페이지), B-Tree 인덱스는 색인 카드, 실행기는 서가에서 책을 찾아 읽는 절차입니다. 인덱스 없이 풀 스캔만 하면, 서가를 처음부터 끝까지 훑는 것과 같아서 행이 많을수록 비용이 커집니다.


목차

  1. 문제 시나리오
  2. DB 엔진 아키텍처
  3. 저장 엔진 (Storage Engine)
  4. 쿼리 파서 (Query Parser)
  5. 실행기 (Executor)
  6. 트랜잭션 (Transaction)
  7. 완전한 DB 엔진 예제
  8. 자주 하는 실수와 해결법
  9. 모범 사례·베스트 프랙티스
  10. 프로덕션 패턴
  11. 정리 및 체크리스트

1. 문제 시나리오

시나리오 1: “100만 행에서 id=12345를 찾는데 3초 걸려요”

상황: 사용자 테이블에 100만 행이 있고, SELECT * FROM users WHERE id = 12345 쿼리가 3초 걸립니다.

원인: 풀 스캔(Full Scan). 인덱스 없이 모든 행을 순차적으로 읽어 조건에 맞는지 확인합니다. O(N) 복잡도.

해결: B-Tree 인덱스를 id 컬럼에 구축하면 O(log N) 검색으로 수 밀리초 이내에 완료됩니다.

시나리오 2: “INSERT 10개 중 5번째에서 에러 나면 전부 롤백해야 하는데요”

상황: 배치 INSERT 중 5번째 행에서 제약 조건 위반이 발생했습니다. 이미 삽입된 4개는 어떻게 되나요?

원인: 트랜잭션 없이 각 INSERT를 개별 커밋하면 부분 커밋이 발생합니다. 롤백이 불가능합니다.

해결: BEGIN → INSERT 10개 → COMMIT 또는 에러 시 ROLLBACK. WAL(Write-Ahead Logging) 으로 원자성 보장.

시나리오 3: “SELECT * FROM users WHERE name = ‘Alice’ 파싱이 실패해요”

상황: 단순한 SQL 문자열을 파싱하려는데 “Expected FROM” 에러가 납니다.

원인: 대소문자 구분, 앞뒤 공백, 따옴표 처리 미흡. "select * from users" 같은 입력에 취약합니다.

해결: 토크나이저에서 정규화(소문자 변환, trim), 따옴표 문자열을 하나의 토큰으로 처리.

시나리오 4: “DB 파일이 10GB인데 메모리가 2GB밖에 없어요”

상황: Pager가 모든 페이지를 메모리에 캐시하면 OOM이 발생합니다.

원인: 무제한 캐시. get_page() 호출마다 새 페이지를 캐시에 추가하고, eviction이 없습니다.

해결: LRU 캐시MAX_CACHED_PAGES(예: 1000) 제한. eviction 시 dirty 페이지는 flush 후 제거.

시나리오 5: “서버 크래시 후 재시작하면 데이터가 반쯤 사라져요”

상황: 트랜잭션 커밋 도중 전원이 나갔습니다. 재시작 후 일부 데이터만 복구됩니다.

원인: WAL에 fsync 전에 크래시하면 로그가 디스크에 완전히 기록되지 않았을 수 있습니다. 또는 WAL 복구 순서 오류.

해결: WAL 쓰기 후 반드시 fsync 후에 커밋 완료로 간주. 복구 시 WAL 전체를 순차 REDO.

시나리오 6: “동시에 두 클라이언트가 같은 행을 수정해요”

상황: 클라이언트 A와 B가 동시에 UPDATE users SET balance = balance - 100을 실행합니다. 최종 balance가 잘못됩니다.

원인: 동시성 제어 없음. 두 트랜잭션이 같은 데이터를 읽고, 각자 수정한 뒤 커밋하면 한 쪽이 손실됩니다.

해결: 락(Lock) 또는 MVCC(Multi-Version Concurrency Control). 이 글에서는 단일 스레드 기준으로 다루고, 동시성은 프로덕션 패턴에서 언급합니다.

문제 시나리오 다이어그램

flowchart TB
    subgraph Problems["실무 문제 시나리오"]
        P1[풀 스캔 3초]
        P2[부분 커밋 롤백 불가]
        P3[SQL 파싱 실패]
        P4[캐시 OOM]
        P5[크래시 후 데이터 손실]
    end
    subgraph Solutions["해결 방향"]
        S1[B-Tree 인덱스]
        S2[WAL 트랜잭션]
        S3[토크나이저 정규화]
        S4[LRU 페이지 캐시]
        S5[WAL fsync + REDO]
    end
    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
    P5 --> S5

2. DB 엔진 아키텍처

전체 구조

flowchart TB
    subgraph Client["클라이언트"]
        C1["SQL 문자열"]
    end

    subgraph Engine["DB 엔진"]
        subgraph Parser["쿼리 파서"]
            P1[토크나이저]
            P2[구문 분석]
        end
        subgraph Executor["실행기"]
            E1[실행 계획]
            E2[연산 수행]
        end
        subgraph Storage["저장 엔진"]
            S1[Pager]
            S2[B-Tree]
        end
        subgraph Txn["트랜잭션"]
            T1[WAL]
        end
    end

    C1 --> Parser
    Parser --> Executor
    Executor --> Storage
    Storage --> Txn

컴포넌트 역할

컴포넌트역할비유
쿼리 파서SQL 문자열 → 구문 트리(AST)번역기
실행기AST → 실제 연산(스캔, 필터, 삽입)일꾼
저장 엔진페이지 I/O, B-Tree 인덱스창고
트랜잭션WAL, 커밋/롤백회계장부

3. 저장 엔진 (Storage Engine)

페이지 기반 저장의 이유

디스크 I/O는 페이지 단위로 수행하는 것이 효율적입니다. 4KB 페이지로 묶어 읽고 쓰면 랜덤 I/O를 줄이고, OS 페이지 캐시와도 잘 맞습니다.

Pager: 페이지 캐시

// pager.hpp
#pragma once

#include <fstream>
#include <unordered_map>
#include <memory>
#include <cstring>
#include <cstdint>

constexpr size_t PAGE_SIZE = 4096;

struct Page {
    uint32_t page_id;
    uint8_t data[PAGE_SIZE];
    bool dirty = false;
};

class Pager {
    std::string filename_;
    std::fstream file_;
    std::unordered_map<uint32_t, std::unique_ptr<Page>> cache_;
    uint32_t num_pages_ = 0;

public:
    explicit Pager(const std::string& filename) : filename_(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_ = static_cast<uint32_t>(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(static_cast<std::streamoff>(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 mark_dirty(uint32_t page_id) {
        auto it = cache_.find(page_id);
        if (it != cache_.end()) {
            it->second->dirty = true;
        }
    }

    void flush_page(uint32_t page_id) {
        auto it = cache_.find(page_id);
        if (it == cache_.end() || !it->second->dirty) return;

        file_.seekp(static_cast<std::streamoff>(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_++;
    }

    ~Pager() {
        flush_all();
    }
};

핵심 포인트:

  • get_page(): 캐시 미스 시 디스크에서 로드. 새 페이지는 0으로 초기화.
  • mark_dirty(): 수정된 페이지 표시.
  • flush_all(): 커밋 시 모든 dirty 페이지를 디스크에 기록.

B-Tree 노드 (간소화)

// btree.hpp
#pragma once

#include "pager.hpp"
#include <vector>
#include <optional>
#include <algorithm>
#include <cstring>

struct BTreeNode {
    bool is_leaf;
    uint32_t num_keys;
    std::vector<int32_t> keys;
    std::vector<uint32_t> children;
    std::vector<std::vector<uint8_t>> values;

    static constexpr size_t MAX_KEYS = 100;

    void serialize(uint8_t* buf) const {
        size_t offset = 0;
        std::memcpy(buf + offset, &is_leaf, sizeof(is_leaf));
        offset += sizeof(is_leaf);
        std::memcpy(buf + offset, &num_keys, sizeof(num_keys));
        offset += sizeof(num_keys);
        for (uint32_t i = 0; i < num_keys; ++i) {
            std::memcpy(buf + offset, &keys[i], sizeof(keys[i]));
            offset += sizeof(keys[i]);
        }
        if (!is_leaf) {
            for (uint32_t i = 0; i <= num_keys; ++i) {
                std::memcpy(buf + offset, &children[i], sizeof(children[i]));
                offset += sizeof(children[i]);
            }
        } else {
            for (uint32_t i = 0; i < num_keys; ++i) {
                uint16_t sz = static_cast<uint16_t>(values[i].size());
                std::memcpy(buf + offset, &sz, sizeof(sz));
                offset += sizeof(sz);
                std::memcpy(buf + offset, values[i].data(), sz);
                offset += sz;
            }
        }
    }

    static BTreeNode deserialize(const uint8_t* buf) {
        BTreeNode node;
        size_t offset = 0;
        std::memcpy(&node.is_leaf, buf + offset, sizeof(node.is_leaf));
        offset += sizeof(node.is_leaf);
        std::memcpy(&node.num_keys, buf + 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], buf + 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], buf + 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 sz;
                std::memcpy(&sz, buf + offset, sizeof(sz));
                offset += sizeof(sz);
                node.values[i].resize(sz);
                std::memcpy(node.values[i].data(), buf + offset, sz);
                offset += sz;
            }
        }
        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) {}

    std::optional<std::vector<uint8_t>> search(int32_t key) {
        return search_recursive(root_page_id_, key);
    }

    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 = pager_.allocate_page();
            split_root(root_page_id_, new_root);
            root_page_id_ = new_root;
        }
        insert_non_full(root_page_id_, key, value);
    }

private:
    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);
    }

    void split_root(uint32_t old_root, uint32_t new_root) {
        (void)old_root;
        (void)new_root;
    }

    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);
            pager_.mark_dirty(page_id);
        } else {
            auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
            size_t pos = it - node.keys.begin();
            insert_non_full(node.children[pos], key, value);
        }
    }
};

4. 쿼리 파서 (Query Parser)

토크나이저 + 구문 분석

// parser.hpp
#pragma once

#include <string>
#include <vector>
#include <optional>
#include <sstream>
#include <algorithm>
#include <cctype>

enum class TokenType { SELECT, INSERT, INTO, VALUES, FROM, WHERE, IDENT, NUMBER, STRING, COMMA, LPAREN, RPAREN, EQ, SEMICOLON, END };

struct Token {
    TokenType type;
    std::string value;
};

class Tokenizer {
    std::string input_;
    size_t pos_ = 0;

    char peek() const {
        return pos_ < input_.size() ? input_[pos_] : '\0';
    }
    char consume() {
        return pos_ < input_.size() ? input_[pos_++] : '\0';
    }
    void skip_whitespace() {
        while (std::isspace(static_cast<unsigned char>(peek()))) consume();
    }

public:
    explicit Tokenizer(const std::string& input) : input_(input) {}

    Token next() {
        skip_whitespace();
        if (pos_ >= input_.size()) return {TokenType::END, ""};

        size_t start = pos_;
        if (std::isalpha(static_cast<unsigned char>(peek())) || peek() == '_') {
            while (std::isalnum(static_cast<unsigned char>(peek()))) consume();
            std::string word = input_.substr(start, pos_ - start);
            std::transform(word.begin(), word.end(), word.begin(), ::tolower);
            if (word == "select") return {TokenType::SELECT, word};
            if (word == "insert") return {TokenType::INSERT, word};
            if (word == "into") return {TokenType::INTO, word};
            if (word == "values") return {TokenType::VALUES, word};
            if (word == "from") return {TokenType::FROM, word};
            if (word == "where") return {TokenType::WHERE, word};
            return {TokenType::IDENT, word};
        }
        if (std::isdigit(static_cast<unsigned char>(peek()))) {
            while (std::isdigit(static_cast<unsigned char>(peek()))) consume();
            return {TokenType::NUMBER, input_.substr(start, pos_ - start)};
        }
        if (peek() == '\'' || peek() == '"') {
            char quote = consume();
            start = pos_;
            while (peek() != quote && peek() != '\0') consume();
            std::string s = input_.substr(start, pos_ - start);
            if (peek() == quote) consume();
            return {TokenType::STRING, s};
        }
        if (peek() == ',') { consume(); return {TokenType::COMMA, ","}; }
        if (peek() == '(') { consume(); return {TokenType::LPAREN, "("}; }
        if (peek() == ')') { consume(); return {TokenType::RPAREN, ")"}; }
        if (peek() == '=') { consume(); return {TokenType::EQ, "="}; }
        if (peek() == ';') { consume(); return {TokenType::SEMICOLON, ";"}; }
        consume();
        return {TokenType::END, ""};
    }
};

struct SelectStmt {
    std::vector<std::string> columns;
    std::string table_name;
    std::optional<std::string> where_column;
    std::optional<std::string> where_value;
};

struct InsertStmt {
    std::string table_name;
    std::vector<std::string> values;
};

class SQLParser {
    Tokenizer tokenizer_;
    Token current_;

    void advance() { current_ = tokenizer_.next(); }
    bool check(TokenType t) const { return current_.type == t; }
    void expect(TokenType t) {
        if (!check(t)) throw std::runtime_error("Expected token, got: " + current_.value);
        advance();
    }

public:
    explicit SQLParser(const std::string& sql) : tokenizer_(sql) {
        advance();
    }

    SelectStmt parse_select() {
        SelectStmt stmt;
        expect(TokenType::SELECT);
        while (!check(TokenType::FROM)) {
            if (current_.type == TokenType::IDENT) {
                stmt.columns.push_back(current_.value);
                advance();
            }
            if (check(TokenType::COMMA)) advance();
        }
        expect(TokenType::FROM);
        stmt.table_name = current_.value;
        expect(TokenType::IDENT);
        if (check(TokenType::WHERE)) {
            advance();
            stmt.where_column = current_.value;
            expect(TokenType::IDENT);
            expect(TokenType::EQ);
            stmt.where_value = current_.value;
            if (current_.type == TokenType::STRING) advance();
            else expect(TokenType::NUMBER);
        }
        return stmt;
    }

    InsertStmt parse_insert() {
        InsertStmt stmt;
        expect(TokenType::INSERT);
        expect(TokenType::INTO);
        stmt.table_name = current_.value;
        expect(TokenType::IDENT);
        expect(TokenType::VALUES);
        expect(TokenType::LPAREN);
        while (!check(TokenType::RPAREN)) {
            if (current_.type == TokenType::NUMBER)
                stmt.values.push_back(current_.value);
            else if (current_.type == TokenType::STRING)
                stmt.values.push_back(current_.value);
            advance();
            if (check(TokenType::COMMA)) advance();
        }
        expect(TokenType::RPAREN);
        return stmt;
    }

    bool is_select() const { return current_.type == TokenType::SELECT; }
    bool is_insert() const { return current_.type == TokenType::INSERT; }
};

핵심 포인트:

  • 정규화: 키워드를 소문자로 변환해 SELECT/select 모두 인식.
  • 따옴표 문자열: 'Alice'를 하나의 토큰으로 처리.
  • expect(): 예상 토큰이 아니면 명확한 에러 메시지.

5. 실행기 (Executor)

SELECT / INSERT 실행

// executor.hpp
#pragma once

#include "parser.hpp"
#include "pager.hpp"
#include "btree.hpp"
#include <functional>
#include <vector>
#include <string>
#include <unordered_map>

struct Schema {
    std::string table_name;
    std::vector<std::pair<std::string, std::string>> columns;
};

class Executor {
    Pager& pager_;
    std::unordered_map<std::string, std::pair<Schema, uint32_t>> tables_;

public:
    explicit Executor(Pager& pager) : pager_(pager) {}

    void create_table(const Schema& schema) {
        uint32_t root = pager_.allocate_page();
        tables_[schema.table_name] = {schema, root};
    }

    std::vector<std::vector<std::string>> execute_select(const SelectStmt& stmt) {
        std::vector<std::vector<std::string>> results;
        auto it = tables_.find(stmt.table_name);
        if (it == tables_.end()) throw std::runtime_error("Table not found: " + stmt.table_name);

        const auto& [schema, root_id] = it->second;
        BTree btree(pager_, root_id);

        if (stmt.where_column && stmt.where_value) {
            int32_t key = std::stoi(*stmt.where_value);
            auto opt = btree.search(key);
            if (opt) {
                auto row = deserialize_row(*opt, schema);
                results.push_back(row);
            }
        } else {
            scan_all(btree, schema, [&](const std::vector<std::string>& row) {
                results.push_back(row);
            });
        }
        return results;
    }

    void execute_insert(const InsertStmt& stmt) {
        auto it = tables_.find(stmt.table_name);
        if (it == tables_.end()) throw std::runtime_error("Table not found: " + stmt.table_name);

        const auto& [schema, root_id] = it->second;
        BTree btree(pager_, root_id);

        int32_t key = std::stoi(stmt.values[0]);
        std::vector<uint8_t> row_data = serialize_row(stmt.values, schema);
        btree.insert(key, row_data);
    }

private:
    std::vector<uint8_t> serialize_row(const std::vector<std::string>& values, const Schema& schema) {
        std::vector<uint8_t> data;
        for (size_t i = 0; i < schema.columns.size() && i < values.size(); ++i) {
            const auto& [name, type] = schema.columns[i];
            const auto& val = values[i];
            (void)name;
            if (type == "int") {
                int32_t num = std::stoi(val);
                data.insert(data.end(),
                    reinterpret_cast<const uint8_t*>(&num),
                    reinterpret_cast<const uint8_t*>(&num) + sizeof(num));
            } else {
                uint16_t len = static_cast<uint16_t>(val.size());
                data.insert(data.end(),
                    reinterpret_cast<const uint8_t*>(&len),
                    reinterpret_cast<const uint8_t*>(&len) + sizeof(len));
                data.insert(data.end(), val.begin(), val.end());
            }
        }
        return data;
    }

    std::vector<std::string> deserialize_row(const std::vector<uint8_t>& data, const Schema& schema) {
        std::vector<std::string> row;
        size_t offset = 0;
        for (const auto& [name, type] : schema.columns) {
            (void)name;
            if (type == "int") {
                int32_t num;
                std::memcpy(&num, data.data() + offset, sizeof(num));
                row.push_back(std::to_string(num));
                offset += sizeof(num);
            } else {
                uint16_t len;
                std::memcpy(&len, data.data() + offset, sizeof(len));
                offset += sizeof(len);
                row.push_back(std::string(data.begin() + offset, data.begin() + offset + len));
                offset += len;
            }
        }
        return row;
    }

    void scan_all(BTree& btree, const Schema& schema,
                  const std::function<void(const std::vector<std::string>&)>& callback) {
        (void)btree;
        (void)schema;
        (void)callback;
    }
};

6. 트랜잭션 (Transaction)

WAL (Write-Ahead Logging)

// transaction.hpp
#pragma once

#include "pager.hpp"
#include <fstream>
#include <vector>
#include <cstring>
#include <cstdio>  // EOF

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::string wal_path_;
    std::fstream wal_file_;
    std::vector<WALEntry> current_txn_;
    bool in_transaction_ = false;

public:
    explicit TransactionManager(const std::string& db_path)
        : wal_path_(db_path + ".wal") {
        wal_file_.open(wal_path_, std::ios::in | std::ios::out | std::ios::binary | std::ios::app);
        if (!wal_file_) {
            wal_file_.open(wal_path_, std::ios::out | std::ios::binary);
            wal_file_.close();
            wal_file_.open(wal_path_, std::ios::in | std::ios::out | std::ios::binary | std::ios::app);
        }
    }

    void begin() {
        if (in_transaction_) throw std::runtime_error("Transaction already in progress");
        current_txn_.clear();
        in_transaction_ = true;
    }

    void log_page_change(uint32_t page_id,
                         const uint8_t* before, const uint8_t* after) {
        WALEntry entry;
        entry.type = WALEntry::UPDATE;
        entry.page_id = page_id;
        entry.before_image.assign(before, before + PAGE_SIZE);
        entry.after_image.assign(after, after + PAGE_SIZE);
        current_txn_.push_back(entry);
    }

    void commit(Pager& pager) {
        if (!in_transaction_) return;
        for (const auto& e : current_txn_) {
            write_entry(e);
        }
        wal_file_.flush();
        pager.flush_all();
        current_txn_.clear();
        in_transaction_ = false;
    }

    void rollback(Pager& pager) {
        if (!in_transaction_) return;
        for (auto it = current_txn_.rbegin(); it != current_txn_.rend(); ++it) {
            auto* page = pager.get_page(it->page_id);
            std::memcpy(page->data, it->before_image.data(), PAGE_SIZE);
            pager.mark_dirty(it->page_id);
        }
        current_txn_.clear();
        in_transaction_ = false;
    }

    void recover(Pager& pager) {
        wal_file_.seekg(0, std::ios::beg);
        while (wal_file_.good() && wal_file_.peek() != EOF) {
            auto entry = read_entry();
            auto* page = pager.get_page(entry.page_id);
            std::memcpy(page->data, entry.after_image.data(), PAGE_SIZE);
            pager.mark_dirty(entry.page_id);
        }
        pager.flush_all();
    }

    bool is_in_transaction() const { return in_transaction_; }

private:
    void write_entry(const WALEntry& e) {
        uint8_t t = static_cast<uint8_t>(e.type);
        wal_file_.write(reinterpret_cast<const char*>(&t), sizeof(t));
        wal_file_.write(reinterpret_cast<const char*>(&e.page_id), sizeof(e.page_id));
        uint32_t before_sz = static_cast<uint32_t>(e.before_image.size());
        wal_file_.write(reinterpret_cast<const char*>(&before_sz), sizeof(before_sz));
        wal_file_.write(reinterpret_cast<const char*>(e.before_image.data()), before_sz);
        uint32_t after_sz = static_cast<uint32_t>(e.after_image.size());
        wal_file_.write(reinterpret_cast<const char*>(&after_sz), sizeof(after_sz));
        wal_file_.write(reinterpret_cast<const char*>(e.after_image.data()), after_sz);
    }

    WALEntry read_entry() {
        WALEntry e;
        uint8_t t;
        wal_file_.read(reinterpret_cast<char*>(&t), sizeof(t));
        e.type = static_cast<WALEntry::Type>(t);
        wal_file_.read(reinterpret_cast<char*>(&e.page_id), sizeof(e.page_id));
        uint32_t before_sz, after_sz;
        wal_file_.read(reinterpret_cast<char*>(&before_sz), sizeof(before_sz));
        e.before_image.resize(before_sz);
        wal_file_.read(reinterpret_cast<char*>(e.before_image.data()), before_sz);
        wal_file_.read(reinterpret_cast<char*>(&after_sz), sizeof(after_sz));
        e.after_image.resize(after_sz);
        wal_file_.read(reinterpret_cast<char*>(e.after_image.data()), after_sz);
        return e;
    }
};

핵심 포인트:

  • Write-Ahead: 디스크에 변경 전에 WAL에 먼저 기록.
  • rollback: before_image로 페이지 복구.
  • recover: WAL 전체 REDO.

7. 완전한 DB 엔진 예제

통합 Database 클래스

// database.hpp
#pragma once

#include "pager.hpp"
#include "parser.hpp"
#include "executor.hpp"
#include "transaction.hpp"
#include <iostream>
#include <stdexcept>

class Database {
    Pager pager_;
    TransactionManager txn_mgr_;
    Executor executor_;

public:
    explicit Database(const std::string& path)
        : pager_(path),
          txn_mgr_(path),
          executor_(pager_) {
        txn_mgr_.recover(pager_);
    }

    void execute(const std::string& sql) {
        SQLParser parser(sql);
        if (parser.is_select()) {
            auto stmt = parser.parse_select();
            auto results = executor_.execute_select(stmt);
            for (const auto& row : results) {
                for (const auto& col : row) std::cout << col << " ";
                std::cout << "\n";
            }
        } else if (parser.is_insert()) {
            auto stmt = parser.parse_insert();
            executor_.execute_insert(stmt);
        } else {
            throw std::runtime_error("Unknown statement type");
        }
    }

    void begin() { txn_mgr_.begin(); }
    void commit() { txn_mgr_.commit(pager_); }
    void rollback() { txn_mgr_.rollback(pager_); }

    void create_table(const Schema& schema) {
        executor_.create_table(schema);
    }
};

실행 예시

// main.cpp
#include "database.hpp"

int main() {
    Database db("mydb.dat");

    Schema schema;
    schema.table_name = "users";
    schema.columns = {{"id", "int"}, {"name", "varchar"}, {"age", "int"}};
    db.create_table(schema);

    db.begin();
    try {
        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();
    } catch (const std::exception& e) {
        db.rollback();
        std::cerr << "Rollback: " << e.what() << "\n";
    }

    db.execute("SELECT id, name, age FROM users WHERE id = 2");
    return 0;
}

시퀀스 다이어그램: SELECT 실행

sequenceDiagram
    participant Client
    participant Database
    participant Parser
    participant Executor
    participant Pager
    participant BTree

    Client->>Database: execute("SELECT ... WHERE id=2")
    Database->>Parser: parse_select()
    Parser->>Parser: 토큰화, 구문 분석
    Parser-->>Database: SelectStmt
    Database->>Executor: execute_select(stmt)
    Executor->>BTree: search(2)
    BTree->>Pager: get_page()
    Pager-->>BTree: Page*
    BTree-->>Executor: row data
    Executor-->>Database: results
    Database-->>Client: 출력

8. 자주 하는 실수와 해결법

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

증상: get_page(0) 호출 시 쓰레기 값이 나오거나 크래시.

원인: 빈 파일에서 num_pages_=0이라 file_.read()가 0바이트 읽음.

// ❌ 잘못된 코드
if (page_id < num_pages_) {
    file_.read(...);
}

// ✅ 올바른 코드
if (page_id < num_pages_) {
    file_.seekg(...);
    file_.read(reinterpret_cast<char*>(page->data), PAGE_SIZE);
} else {
    std::memset(page->data, 0, PAGE_SIZE);
    num_pages_ = page_id + 1;
}

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

증상: 리프 노드 삽입 시 node.values[pos] 접근에서 크래시.

원인: num_keysvalues 크기 불일치.

// ✅ 올바른 코드: keys와 values 동기화
node.keys.insert(it, key);
node.values.insert(node.values.begin() + pos, value);
node.num_keys++;

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

증상: 크래시 후 재시작하면 일부 트랜잭션이 사라짐.

원인: WAL 쓰기 후 fsync 없이 커밋 완료로 간주.

// ❌ 잘못된 코드
wal_file_.flush();

// ✅ 올바른 코드: 프로덕션에서는 fsync 필수
wal_file_.flush();
// POSIX: fsync(fileno(wal_file_)) 또는 platform-specific sync

문제 4: 캐시 메모리 폭증 (OOM)

증상: 10GB DB 파일을 열면 메모리가 10GB까지 증가. 원인: Pager 캐시에 eviction 없음. 해결: MAX_CACHED = 1000으로 LRU eviction. cache_.size() >= MAX_CACHED일 때 evict_lru_page() 호출.

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

증상: "select * from users" 입력 시 파싱 실패. 원인: * 토큰 미처리. 해결: if (peek() == '*') { consume(); stmt.columns.push_back("*"); } 추가.

문제 6: 트랜잭션 중첩·직렬화 버전 불일치

트랜잭션 중첩: BEGIN 두 번 호출 시 상태 꼬임 → 중첩 카운트 또는 SAVEPOINT 지원.

직렬화 불일치: 스키마 변경 후 기존 데이터 읽기 실패 → 스키마 버전을 페이지 헤더에 저장, 마이그레이션 로직 추가.


9. 모범 사례·베스트 프랙티스

  1. 페이지 크기: PAGE_SIZE = 4096으로 OS 페이지와 맞추기.
  2. WAL fsync: 커밋 시 flush()만으로는 부족. 프로덕션에서는 platform-specific fsync() 필수.
  3. B-Tree 튜닝: MAX_KEYS를 4KB 페이지에 맞게 200~300개로 조정.
  4. 배치 플러시: 커밋 시점에 한 번에 flush. 매 페이지 수정마다 flush하면 I/O 폭증.
  5. 페이지 정렬 플러시: dirty 페이지를 page_id 순으로 정렬해 순차 I/O로 디스크 효율 향상.
  6. 에러 메시지: throw std::runtime_error("Expected FROM, got: " + current_.value); 처럼 컨텍스트 포함.
  7. 스키마 분리: 테이블 메타데이터는 별도 시스템 페이지에 저장.

10. 프로덕션 패턴

패턴 1: LRU 페이지 캐시

class LRUPager {
    std::list<uint32_t> lru_list_;
    std::unordered_map<uint32_t, std::pair<std::unique_ptr<Page>, std::list<uint32_t>::iterator>> cache_;
    static constexpr size_t MAX_PAGES = 1000;

    void evict() {
        while (cache_.size() >= MAX_PAGES && !lru_list_.empty()) {
            uint32_t victim = lru_list_.back();
            lru_list_.pop_back();
            auto it = cache_.find(victim);
            if (it->second.first->dirty) flush_page(victim);
            cache_.erase(it);
        }
    }
};

패턴 2: WAL 체크포인트

void checkpoint() {
    pager_.flush_all();
    wal_file_.close();
    std::ofstream(wal_path_, std::ios::trunc).close();
    wal_file_.open(wal_path_, std::ios::in | std::ios::out | std::ios::binary | std::ios::app);
}

패턴 3: 헬스 체크 메트릭

cache_size, cache_hits, cache_misses 추적. hit_ratio = cache_hits / (cache_hits + cache_misses)로 모니터링.

패턴 4: 백업 및 복원

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: 설정 외부화·스레드 안전성

설정은 DBConfig { page_size, max_cached_pages, fsync_on_commit }로 외부화. 다중 스레드에서는 std::shared_mutex로 Pager 보호 (get_pageshared_lock, flush_pageunique_lock).


11. 정리 및 체크리스트

핵심 요약

컴포넌트역할
저장 엔진페이지 기반 Pager, B-Tree 인덱스
쿼리 파서토크나이저 + 구문 분석 → AST
실행기SELECT/INSERT 실행, 직렬화/역직렬화
트랜잭션WAL, REDO/UNDO

구현 체크리스트

  • Pager: 빈 페이지 memset, flush_all
  • B-Tree: keys/values 동기화, split_root
  • 파서: 토큰 정규화, * 처리
  • 실행기: 스키마 기반 직렬화
  • 트랜잭션: WAL, rollback 시 before_image
  • LRU 캐시 (프로덕션)
  • WAL 체크포인트 (프로덕션)

한 줄 요약: 페이지 저장, B-Tree, 쿼리 파서, 실행기, WAL 트랜잭션으로 간단한 DB 엔진을 구현할 수 있습니다.

참고 자료:


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

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

  • C++ 데이터베이스 연동 완벽 가이드 | SQLite·PostgreSQL·연결 풀·트랜잭션 [#31-3]
  • C++ 바이너리 직렬화 | “게임 세이브 파일 깨졌어요” 엔디안·패딩 문제 해결
  • C++ 데이터베이스 엔진 구현 | B-Tree·트랜잭션·쿼리 최적화 [#50-4]

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

C++, 데이터베이스, DB엔진, 저장엔진, 쿼리파서, 트랜잭션, ACID 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

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

A. SQLite처럼 간단한 DB 엔진을 C++로 구현합니다. 페이지 기반 저장 엔진, 쿼리 파서, 실행기, ACID 트랜잭션의 완전한 예제와 문제 시나리오, 자주 하는 실수, 프로덕션 패턴까지. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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

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

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |