본문으로 건너뛰기
Previous
Next
C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]

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

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

이 글의 핵심

데이터베이스는 저장 엔진, 쿼리 파서, 실행기, 트랜잭션이 결합된 시스템입니다. SQLite나 MySQL을 쓰더라도 내부 동작을 이해하지 못하면 인덱스가 왜 필요한가, 트랜잭션이 어떻게 원자성을 보장하는가를 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다.

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

왜 DB 엔진 기초인가

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

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

개념을 잡는 비유

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

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

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

자주 묻는 질문 (FAQ)

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

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

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

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

Q. 더 깊이 공부하려면?

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

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.