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 인덱스는 색인 카드, 실행기는 서가에서 책을 찾아 읽는 절차입니다. 인덱스 없이 풀 스캔만 하면, 서가를 처음부터 끝까지 훑는 것과 같아서 행이 많을수록 비용이 커집니다.
목차
- 문제 시나리오
- DB 엔진 아키텍처
- 저장 엔진 (Storage Engine)
- 쿼리 파서 (Query Parser)
- 실행기 (Executor)
- 트랜잭션 (Transaction)
- 완전한 DB 엔진 예제
- 자주 하는 실수와 해결법
- 모범 사례·베스트 프랙티스
- 프로덕션 패턴
- 정리 및 체크리스트
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_keys와 values 크기 불일치.
// ✅ 올바른 코드: 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. 모범 사례·베스트 프랙티스
- 페이지 크기:
PAGE_SIZE = 4096으로 OS 페이지와 맞추기. - WAL fsync: 커밋 시
flush()만으로는 부족. 프로덕션에서는 platform-specificfsync()필수. - B-Tree 튜닝:
MAX_KEYS를 4KB 페이지에 맞게 200~300개로 조정. - 배치 플러시: 커밋 시점에 한 번에 flush. 매 페이지 수정마다 flush하면 I/O 폭증.
- 페이지 정렬 플러시: dirty 페이지를
page_id순으로 정렬해 순차 I/O로 디스크 효율 향상. - 에러 메시지:
throw std::runtime_error("Expected FROM, got: " + current_.value);처럼 컨텍스트 포함. - 스키마 분리: 테이블 메타데이터는 별도 시스템 페이지에 저장.
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_page는 shared_lock, flush_page는 unique_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 엔진을 구현할 수 있습니다.
참고 자료:
- SQLite Architecture
- Database Internals (Alex Petrov, O’Reilly)
- cppreference - File I/O
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- 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 |