C++ 데이터베이스 엔진 구현 | B-Tree·트랜잭션·쿼리 최적화 [#50-4]
이 글의 핵심
C++ 데이터베이스 엔진 구현에 대한 실전 가이드입니다. B-Tree·트랜잭션·쿼리 최적화 [#50-4] 등을 예제와 함께 상세히 설명합니다.
들어가며: “SQLite처럼 간단한 DB 엔진을 만들고 싶어요”
데이터베이스 엔진의 핵심
관계형 데이터베이스는 저장 엔진, 인덱스, 트랜잭션, 쿼리 처리로 구성됩니다. 이 글에서는 간단한 DB 엔진의 핵심을 구현합니다.
목표:
- 페이지 기반 저장 엔진
- B-Tree 인덱스 구현
- ACID 트랜잭션 (WAL 로그)
- SQL 파서 및 실행 엔진
- 쿼리 최적화
요구 환경: C++17 이상
이 글을 읽으면:
- DB 저장 엔진을 구현할 수 있습니다.
- B-Tree 인덱스를 만들 수 있습니다.
- 트랜잭션을 구현할 수 있습니다.
- 간단한 SQL 파서를 작성할 수 있습니다.
문제 시나리오: DB 엔진이 필요한 상황
시나리오 1: 임베디드 환경의 로컬 저장소
IoT 디바이스나 엣지 서버에서 SQLite 없이 경량 DB가 필요할 때가 있습니다. 외부 라이브러리 의존성을 줄이고, 메모리·디스크 제약에 맞춘 최소 구현이 필요합니다.
시나리오 2: 특수 목적 인덱스
게임 세이브 데이터, 시계열 로그, 지리공간 데이터처럼 도메인 특화 인덱스가 필요할 때, 범용 DB보다 직접 B-Tree를 구현해 제어할 수 있습니다.
시나리오 3: DB 내부 동작 학습
”인덱스가 왜 빠른가?”, “트랜잭션이 어떻게 원자성을 보장하는가?”를 이해하려면 직접 구현하는 것이 가장 효과적입니다.
시나리오 4: 인메모리 캐시 엔진
Redis처럼 키-값 캐시를 만들되, B-Tree 기반 범위 쿼리가 필요할 때, 페이지 기반 저장 구조를 이해하면 설계가 수월합니다.
시나리오 5: 쿼리 최적화 이해
”왜 이 쿼리가 느린가?”를 파악하려면 실행 계획·인덱스 스캔 vs 풀 스캔 개념이 필요합니다. 직접 파서와 옵티마이저를 구현하면 원리를 깊이 이해할 수 있습니다.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
1. 저장 엔진
아키텍처 다이어그램
flowchart TB
subgraph Client["클라이언트"]
C1[SQL 쿼리]
end
subgraph DB["데이터베이스 엔진"]
subgraph Parser["파서"]
P1[SQL 파싱]
end
subgraph Optimizer["옵티마이저"]
O1[실행 계획]
end
subgraph Storage["저장 엔진"]
S1[Pager]
S2[B-Tree]
end
subgraph Txn["트랜잭션"]
T1[WAL]
end
end
C1 --> Parser
Parser --> Optimizer
Optimizer --> Storage
Storage --> Txn
페이지 기반 저장
디스크 I/O는 페이지 단위로 수행하는 것이 효율적입니다. 4KB 페이지로 묶어 읽고 쓰면 랜덤 I/O를 줄일 수 있습니다.
constexpr size_t PAGE_SIZE = 4096; // 4KB
struct Page {
uint32_t page_id;
uint8_t data[PAGE_SIZE];
bool dirty = false;
};
class Pager {
std::fstream file_;
std::unordered_map<uint32_t, std::unique_ptr<Page>> cache_;
uint32_t num_pages_ = 0;
public:
Pager(const std::string& filename) {
file_.open(filename, std::ios::in | std::ios::out | std::ios::binary);
if (!file_) {
file_.open(filename, std::ios::out | std::ios::binary);
file_.close();
file_.open(filename, std::ios::in | std::ios::out | std::ios::binary);
}
// 파일 크기로 페이지 수 계산
file_.seekg(0, std::ios::end);
auto size = file_.tellg();
num_pages_ = size / PAGE_SIZE;
}
Page* get_page(uint32_t page_id) {
// 캐시 확인
auto it = cache_.find(page_id);
if (it != cache_.end()) {
return it->second.get();
}
// 디스크에서 로드
auto page = std::make_unique<Page>();
page->page_id = page_id;
if (page_id < num_pages_) {
file_.seekg(page_id * PAGE_SIZE);
file_.read(reinterpret_cast<char*>(page->data), PAGE_SIZE);
} else {
std::memset(page->data, 0, PAGE_SIZE);
num_pages_ = page_id + 1;
}
auto* ptr = page.get();
cache_[page_id] = std::move(page);
return ptr;
}
void flush_page(uint32_t page_id) {
auto it = cache_.find(page_id);
if (it == cache_.end() || !it->second->dirty) {
return;
}
file_.seekp(page_id * PAGE_SIZE);
file_.write(reinterpret_cast<const char*>(it->second->data), PAGE_SIZE);
file_.flush();
it->second->dirty = false;
}
void flush_all() {
for (auto& [id, page] : cache_) {
if (page->dirty) {
flush_page(id);
}
}
}
uint32_t allocate_page() {
return num_pages_++;
}
};
테이블 구조
struct Column {
std::string name;
enum Type { INT, VARCHAR, FLOAT } type;
size_t size;
};
struct Schema {
std::string table_name;
std::vector<Column> columns;
std::vector<std::string> primary_keys;
};
class Table {
Schema schema_;
Pager& pager_;
uint32_t root_page_id_;
public:
Table(const Schema& schema, Pager& pager)
: schema_(schema), pager_(pager) {
root_page_id_ = pager_.allocate_page();
}
void insert(const std::vector<std::string>& values) {
// 행을 직렬화
std::vector<uint8_t> row_data = serialize_row(values);
// B-Tree에 삽입
insert_into_btree(root_page_id_, row_data);
}
std::vector<std::vector<std::string>> select(
const std::function<bool(const std::vector<std::string>&)>& predicate
) {
std::vector<std::vector<std::string>> results;
scan_btree(root_page_id_, [&](const std::vector<uint8_t>& row_data) {
auto row = deserialize_row(row_data);
if (predicate(row)) {
results.push_back(row);
}
});
return results;
}
private:
std::vector<uint8_t> serialize_row(const std::vector<std::string>& values) {
std::vector<uint8_t> data;
for (size_t i = 0; i < schema_.columns.size(); ++i) {
const auto& col = schema_.columns[i];
const auto& val = values[i];
switch (col.type) {
case Column::INT: {
int32_t num = std::stoi(val);
data.insert(data.end(),
reinterpret_cast<uint8_t*>(&num),
reinterpret_cast<uint8_t*>(&num) + sizeof(num));
break;
}
case Column::VARCHAR: {
uint16_t len = val.length();
data.insert(data.end(),
reinterpret_cast<uint8_t*>(&len),
reinterpret_cast<uint8_t*>(&len) + sizeof(len));
data.insert(data.end(), val.begin(), val.end());
break;
}
case Column::FLOAT: {
float num = std::stof(val);
data.insert(data.end(),
reinterpret_cast<uint8_t*>(&num),
reinterpret_cast<uint8_t*>(&num) + sizeof(num));
break;
}
}
}
return data;
}
std::vector<std::string> deserialize_row(const std::vector<uint8_t>& data) {
std::vector<std::string> values;
size_t offset = 0;
for (const auto& col : schema_.columns) {
switch (col.type) {
case Column::INT: {
int32_t num;
std::memcpy(&num, data.data() + offset, sizeof(num));
values.push_back(std::to_string(num));
offset += sizeof(num);
break;
}
case Column::VARCHAR: {
uint16_t len;
std::memcpy(&len, data.data() + offset, sizeof(len));
offset += sizeof(len);
values.push_back(std::string(
data.begin() + offset,
data.begin() + offset + len
));
offset += len;
break;
}
case Column::FLOAT: {
float num;
std::memcpy(&num, data.data() + offset, sizeof(num));
values.push_back(std::to_string(num));
offset += sizeof(num);
break;
}
}
}
return values;
}
};
2. B-Tree 인덱스
B-Tree 노드 구조
B-Tree는 균형 트리로, O(log N) 검색·삽입을 보장합니다. 각 노드는 한 페이지에 저장됩니다.
struct BTreeNode {
bool is_leaf;
uint32_t num_keys;
std::vector<int32_t> keys;
std::vector<uint32_t> children; // 자식 페이지 ID
std::vector<std::vector<uint8_t>> values; // 리프 노드만
static constexpr size_t MAX_KEYS = 100;
void serialize(uint8_t* page_data) {
size_t offset = 0;
std::memcpy(page_data + offset, &is_leaf, sizeof(is_leaf));
offset += sizeof(is_leaf);
std::memcpy(page_data + offset, &num_keys, sizeof(num_keys));
offset += sizeof(num_keys);
for (uint32_t i = 0; i < num_keys; ++i) {
std::memcpy(page_data + offset, &keys[i], sizeof(keys[i]));
offset += sizeof(keys[i]);
}
if (!is_leaf) {
for (uint32_t i = 0; i <= num_keys; ++i) {
std::memcpy(page_data + offset, &children[i], sizeof(children[i]));
offset += sizeof(children[i]);
}
} else {
// 값 직렬화
for (uint32_t i = 0; i < num_keys; ++i) {
uint16_t value_size = values[i].size();
std::memcpy(page_data + offset, &value_size, sizeof(value_size));
offset += sizeof(value_size);
std::memcpy(page_data + offset, values[i].data(), value_size);
offset += value_size;
}
}
}
static BTreeNode deserialize(const uint8_t* page_data) {
BTreeNode node;
size_t offset = 0;
std::memcpy(&node.is_leaf, page_data + offset, sizeof(node.is_leaf));
offset += sizeof(node.is_leaf);
std::memcpy(&node.num_keys, page_data + offset, sizeof(node.num_keys));
offset += sizeof(node.num_keys);
node.keys.resize(node.num_keys);
for (uint32_t i = 0; i < node.num_keys; ++i) {
std::memcpy(&node.keys[i], page_data + offset, sizeof(node.keys[i]));
offset += sizeof(node.keys[i]);
}
if (!node.is_leaf) {
node.children.resize(node.num_keys + 1);
for (uint32_t i = 0; i <= node.num_keys; ++i) {
std::memcpy(&node.children[i], page_data + offset, sizeof(node.children[i]));
offset += sizeof(node.children[i]);
}
} else {
node.values.resize(node.num_keys);
for (uint32_t i = 0; i < node.num_keys; ++i) {
uint16_t value_size;
std::memcpy(&value_size, page_data + offset, sizeof(value_size));
offset += sizeof(value_size);
node.values[i].resize(value_size);
std::memcpy(node.values[i].data(), page_data + offset, value_size);
offset += value_size;
}
}
return node;
}
};
class BTree {
Pager& pager_;
uint32_t root_page_id_;
public:
BTree(Pager& pager, uint32_t root_page_id)
: pager_(pager), root_page_id_(root_page_id) {}
void insert(int32_t key, const std::vector<uint8_t>& value) {
auto* root_page = pager_.get_page(root_page_id_);
auto root = BTreeNode::deserialize(root_page->data);
if (root.num_keys >= BTreeNode::MAX_KEYS) {
// 루트 분할: 새 루트 생성 후 기존 루트를 자식으로
uint32_t new_root_id = pager_.allocate_page();
split_root(root_page_id_, new_root_id);
root_page_id_ = new_root_id;
}
insert_non_full(root_page_id_, key, value);
}
std::optional<std::vector<uint8_t>> search(int32_t key) {
return search_recursive(root_page_id_, key);
}
private:
void split_root(uint32_t old_root_id, uint32_t new_root_id) {
auto* old_page = pager_.get_page(old_root_id);
auto old_node = BTreeNode::deserialize(old_page->data);
auto* new_page = pager_.get_page(new_root_id);
BTreeNode new_root;
new_root.is_leaf = false;
new_root.num_keys = 1;
size_t mid = old_node.num_keys / 2;
new_root.keys.push_back(old_node.keys[mid]);
new_root.children.push_back(old_root_id);
// 오른쪽 절반을 새 페이지로
uint32_t right_id = pager_.allocate_page();
auto* right_page = pager_.get_page(right_id);
BTreeNode right_node;
right_node.is_leaf = old_node.is_leaf;
right_node.keys.assign(old_node.keys.begin() + mid + 1, old_node.keys.end());
right_node.num_keys = right_node.keys.size();
if (old_node.is_leaf) {
right_node.values.assign(old_node.values.begin() + mid + 1, old_node.values.end());
} else {
right_node.children.assign(old_node.children.begin() + mid + 1, old_node.children.end());
}
new_root.children.push_back(right_id);
// 기존 노드 크기 줄임
old_node.keys.resize(mid);
old_node.num_keys = mid;
if (old_node.is_leaf) {
old_node.values.resize(mid);
} else {
old_node.children.resize(mid + 1);
}
new_root.serialize(new_page->data);
new_page->dirty = true;
old_node.serialize(old_page->data);
old_page->dirty = true;
right_node.serialize(right_page->data);
right_page->dirty = true;
}
void insert_non_full(uint32_t page_id, int32_t key, const std::vector<uint8_t>& value) {
auto* page = pager_.get_page(page_id);
auto node = BTreeNode::deserialize(page->data);
if (node.is_leaf) {
// 리프 노드에 삽입
auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
size_t pos = it - node.keys.begin();
node.keys.insert(it, key);
node.values.insert(node.values.begin() + pos, value);
node.num_keys++;
node.serialize(page->data);
page->dirty = true;
} else {
// 내부 노드: 적절한 자식 찾기
auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
size_t pos = it - node.keys.begin();
uint32_t child_id = node.children[pos];
auto* child_page = pager_.get_page(child_id);
auto child = BTreeNode::deserialize(child_page->data);
if (child.num_keys >= BTreeNode::MAX_KEYS) {
split_child(page_id, pos);
// 분할 후 다시 적절한 자식 선택
node = BTreeNode::deserialize(page->data);
if (key > node.keys[pos]) {
pos++;
}
}
insert_non_full(node.children[pos], key, value);
}
}
void split_child(uint32_t parent_id, size_t child_index) {
auto* parent_page = pager_.get_page(parent_id);
auto parent = BTreeNode::deserialize(parent_page->data);
uint32_t child_id = parent.children[child_index];
auto* child_page = pager_.get_page(child_id);
auto child = BTreeNode::deserialize(child_page->data);
// 새 노드 생성
uint32_t new_child_id = pager_.allocate_page();
auto* new_child_page = pager_.get_page(new_child_id);
BTreeNode new_child;
new_child.is_leaf = child.is_leaf;
size_t mid = child.num_keys / 2;
// 중간 키를 부모로 올림
int32_t mid_key = child.keys[mid];
parent.keys.insert(parent.keys.begin() + child_index, mid_key);
parent.children.insert(parent.children.begin() + child_index + 1, new_child_id);
parent.num_keys++;
// 오른쪽 절반을 새 노드로 이동
new_child.keys.assign(child.keys.begin() + mid + 1, child.keys.end());
new_child.num_keys = new_child.keys.size();
if (child.is_leaf) {
new_child.values.assign(child.values.begin() + mid + 1, child.values.end());
} else {
new_child.children.assign(child.children.begin() + mid + 1, child.children.end());
}
// 원래 노드 크기 줄임
child.keys.resize(mid);
child.num_keys = mid;
if (child.is_leaf) {
child.values.resize(mid);
} else {
child.children.resize(mid + 1);
}
// 페이지에 저장
parent.serialize(parent_page->data);
parent_page->dirty = true;
child.serialize(child_page->data);
child_page->dirty = true;
new_child.serialize(new_child_page->data);
new_child_page->dirty = true;
}
std::optional<std::vector<uint8_t>> search_recursive(uint32_t page_id, int32_t key) {
auto* page = pager_.get_page(page_id);
auto node = BTreeNode::deserialize(page->data);
auto it = std::lower_bound(node.keys.begin(), node.keys.end(), key);
size_t pos = it - node.keys.begin();
if (it != node.keys.end() && *it == key) {
if (node.is_leaf) {
return node.values[pos];
}
// 내부 노드에서 찾은 경우
return search_recursive(node.children[pos + 1], key);
}
if (node.is_leaf) {
return std::nullopt;
}
return search_recursive(node.children[pos], key);
}
};
3. 트랜잭션 관리
WAL (Write-Ahead Logging)
WAL은 디스크에 변경 전에 로그를 먼저 기록하는 방식입니다. 크래시 시 REDO/UNDO로 복구할 수 있습니다.
struct WALEntry {
enum Type { INSERT, UPDATE, DELETE } type;
uint32_t page_id;
std::vector<uint8_t> before_image;
std::vector<uint8_t> after_image;
};
class TransactionManager {
std::fstream wal_file_;
std::vector<WALEntry> current_transaction_;
uint64_t next_txn_id_ = 1;
public:
TransactionManager(const std::string& wal_filename) {
wal_file_.open(wal_filename,
std::ios::in | std::ios::out | std::ios::binary | std::ios::app);
}
uint64_t begin_transaction() {
current_transaction_.clear();
return next_txn_id_++;
}
void log_write(uint32_t page_id,
const std::vector<uint8_t>& before,
const std::vector<uint8_t>& after) {
WALEntry entry;
entry.type = WALEntry::UPDATE;
entry.page_id = page_id;
entry.before_image = before;
entry.after_image = after;
current_transaction_.push_back(entry);
}
void commit() {
// WAL에 기록
for (const auto& entry : current_transaction_) {
write_wal_entry(entry);
}
wal_file_.flush();
current_transaction_.clear();
}
void rollback(Pager& pager) {
// UNDO: before_image로 복구
for (auto it = current_transaction_.rbegin();
it != current_transaction_.rend(); ++it) {
auto* page = pager.get_page(it->page_id);
std::memcpy(page->data, it->before_image.data(), PAGE_SIZE);
page->dirty = true;
}
current_transaction_.clear();
}
void recover(Pager& pager) {
wal_file_.seekg(0, std::ios::beg);
while (wal_file_.peek() != EOF) {
auto entry = read_wal_entry();
// REDO: after_image 적용
auto* page = pager.get_page(entry.page_id);
std::memcpy(page->data, entry.after_image.data(), PAGE_SIZE);
page->dirty = true;
}
pager.flush_all();
}
private:
void write_wal_entry(const WALEntry& entry) {
uint8_t type = static_cast<uint8_t>(entry.type);
wal_file_.write(reinterpret_cast<const char*>(&type), sizeof(type));
wal_file_.write(reinterpret_cast<const char*>(&entry.page_id), sizeof(entry.page_id));
uint32_t before_size = entry.before_image.size();
wal_file_.write(reinterpret_cast<const char*>(&before_size), sizeof(before_size));
wal_file_.write(reinterpret_cast<const char*>(entry.before_image.data()), before_size);
uint32_t after_size = entry.after_image.size();
wal_file_.write(reinterpret_cast<const char*>(&after_size), sizeof(after_size));
wal_file_.write(reinterpret_cast<const char*>(entry.after_image.data()), after_size);
}
WALEntry read_wal_entry() {
WALEntry entry;
uint8_t type;
wal_file_.read(reinterpret_cast<char*>(&type), sizeof(type));
entry.type = static_cast<WALEntry::Type>(type);
wal_file_.read(reinterpret_cast<char*>(&entry.page_id), sizeof(entry.page_id));
uint32_t before_size;
wal_file_.read(reinterpret_cast<char*>(&before_size), sizeof(before_size));
entry.before_image.resize(before_size);
wal_file_.read(reinterpret_cast<char*>(entry.before_image.data()), before_size);
uint32_t after_size;
wal_file_.read(reinterpret_cast<char*>(&after_size), sizeof(after_size));
entry.after_image.resize(after_size);
wal_file_.read(reinterpret_cast<char*>(entry.after_image.data()), after_size);
return entry;
}
};
4. 쿼리 파서
간단한 SQL 파서
struct SelectStatement {
std::vector<std::string> columns;
std::string table_name;
std::optional<std::string> where_clause;
};
class SQLParser {
public:
SelectStatement parse_select(const std::string& sql) {
SelectStatement stmt;
// 간단한 토크나이저
std::istringstream iss(sql);
std::string token;
// SELECT
iss >> token;
if (token != "SELECT") {
throw std::runtime_error("Expected SELECT");
}
// 컬럼 목록
std::string columns_str;
std::getline(iss, columns_str, ' ');
// FROM 전까지 읽기
while (iss >> token && token != "FROM") {
columns_str += " " + token;
}
// 컬럼 파싱
std::istringstream col_stream(columns_str);
std::string col;
while (std::getline(col_stream, col, ',')) {
col.erase(0, col.find_first_not_of(" \t"));
col.erase(col.find_last_not_of(" \t") + 1);
stmt.columns.push_back(col);
}
// 테이블 이름
iss >> stmt.table_name;
// WHERE 절 (선택사항)
if (iss >> token && token == "WHERE") {
std::string where;
std::getline(iss, where);
stmt.where_clause = where;
}
return stmt;
}
};
5. 실행 계획 최적화
쿼리 최적화
struct QueryPlan {
enum Type { FULL_SCAN, INDEX_SCAN } type;
std::string table_name;
std::optional<std::string> index_name;
std::function<bool(const std::vector<std::string>&)> filter;
};
class QueryOptimizer {
std::unordered_map<std::string, std::vector<std::string>> table_indexes_;
public:
QueryPlan optimize(const SelectStatement& stmt) {
QueryPlan plan;
plan.table_name = stmt.table_name;
if (stmt.where_clause) {
// WHERE 절 분석
auto condition = parse_where(*stmt.where_clause);
// 인덱스 사용 가능 여부 확인
if (can_use_index(stmt.table_name, condition.column)) {
plan.type = QueryPlan::INDEX_SCAN;
plan.index_name = condition.column;
} else {
plan.type = QueryPlan::FULL_SCAN;
}
plan.filter = create_filter(condition);
} else {
plan.type = QueryPlan::FULL_SCAN;
plan.filter = { return true; };
}
return plan;
}
private:
struct Condition {
std::string column;
std::string op;
std::string value;
};
Condition parse_where(const std::string& where_clause) {
// 간단한 파싱: "column = value"
std::istringstream iss(where_clause);
Condition cond;
iss >> cond.column >> cond.op >> cond.value;
return cond;
}
bool can_use_index(const std::string& table, const std::string& column) {
auto it = table_indexes_.find(table);
if (it == table_indexes_.end()) return false;
return std::find(it->second.begin(), it->second.end(), column)
!= it->second.end();
}
std::function<bool(const std::vector<std::string>&)> create_filter(const Condition& cond) {
return [cond](const std::vector<std::string>& row) {
// 간단한 비교 (실제로는 컬럼 인덱스 필요)
if (cond.op == "=") {
return row[0] == cond.value; // 예시
}
return false;
};
}
};
6. 완전한 DB 엔진 예제
통합 Database 클래스
class Database {
Pager pager_;
TransactionManager txn_mgr_;
SQLParser parser_;
QueryOptimizer optimizer_;
std::unordered_map<std::string, std::unique_ptr<Table>> tables_;
public:
Database(const std::string& db_file)
: pager_(db_file),
txn_mgr_(db_file + ".wal") {
// 복구
txn_mgr_.recover(pager_);
}
void execute(const std::string& sql) {
auto stmt = parser_.parse_select(sql);
auto plan = optimizer_.optimize(stmt);
auto* table = tables_[plan.table_name].get();
auto results = table->select(plan.filter);
// 결과 출력
for (const auto& row : results) {
for (const auto& val : row) {
std::cout << val << " ";
}
std::cout << "\n";
}
}
void begin_transaction() {
txn_mgr_.begin_transaction();
}
void commit() {
pager_.flush_all();
txn_mgr_.commit();
}
void rollback() {
txn_mgr_.rollback(pager_);
}
};
실행 예시: 사용자 테이블 생성 및 쿼리
// main.cpp
#include <iostream>
#include "database.h"
int main() {
Database db("mydb.dat");
// 스키마 정의
Schema user_schema;
user_schema.table_name = "users";
user_schema.columns = {
{"id", Column::INT, 4},
{"name", Column::VARCHAR, 64},
{"age", Column::INT, 4}
};
user_schema.primary_keys = {"id"};
// 테이블 생성 및 데이터 삽입
db.create_table(user_schema);
db.begin_transaction();
db.execute("INSERT INTO users VALUES (1, 'Alice', 30)");
db.execute("INSERT INTO users VALUES (2, 'Bob', 25)");
db.execute("INSERT INTO users VALUES (3, 'Charlie', 35)");
db.commit();
// SELECT 쿼리
db.execute("SELECT id, name, age FROM users WHERE age > 28");
// 출력: 1 Alice 30
// 3 Charlie 35
return 0;
}
7. 자주 발생하는 에러와 해결법
문제 1: “Page not found” 또는 잘못된 페이지 읽기
원인: 빈 파일에서 get_page(0) 호출 시 num_pages_=0이라 0바이트 읽음. 해결: else 분기에서 std::memset(page->data, 0, PAGE_SIZE)로 초기화하고 num_pages_ = std::max(num_pages_, page_id + 1)로 갱신.
문제 2: B-Tree 삽입 시 “vector subscript out of range”
원인: 리프 노드가 비어 있을 때 node.values에 접근. 해결: num_keys와 동기화, values.resize 후 insert.
문제 3: WAL 복구 후 데이터 손실
원인: recover()에서 커밋되지 않은 트랜잭션까지 REDO 적용하거나 WAL 손상. 해결: WAL 엔트리에 magic·checksum 추가해 무결성 검증.
문제 4: 캐시 메모리 폭증
원인: Pager 캐시에 제한이 없음. 해결: MAX_CACHED_PAGES(예: 1000)로 LRU 캐시 구현. evict_lru_page()로 가장 오래된 dirty 페이지 flush 후 제거.
문제 5: SQL 파서 “Expected SELECT” 에러
원인: 대소문자 구분, 앞뒤 공백. 해결: to_upper(token)으로 정규화 후 비교, "Expected SELECT, got: " + token으로 에러 메시지 출력.
8. 성능 최적화 팁
팁 1: 배치 플러시
한 트랜잭션 내 여러 페이지 수정 시, 커밋 시점에 한 번에 플러시하면 I/O 횟수를 줄일 수 있습니다.
// ✅ 커밋 시에만 flush
void commit() {
for (const auto& entry : current_transaction_) {
write_wal_entry(entry);
}
wal_file_.flush();
// Pager는 commit()에서 한 번에 flush
pager_.flush_all();
current_transaction_.clear();
}
팁 2: 페이지 정렬 플러시
디스크는 순차 I/O가 랜덤 I/O보다 훨씬 빠릅니다. dirty 페이지를 page_id 순으로 정렬해 플러시하면 디스크 헤드 이동을 줄입니다.
void flush_all() {
std::vector<uint32_t> dirty_ids;
for (const auto& [id, page] : cache_) {
if (page->dirty) dirty_ids.push_back(id);
}
std::sort(dirty_ids.begin(), dirty_ids.end());
for (uint32_t id : dirty_ids) {
flush_page(id);
}
}
팁 3: B-Tree 노드 크기 튜닝
MAX_KEYS를 페이지 크기에 맞게 조정하면 트리 높이를 줄일 수 있습니다. 4KB 페이지에서 int32 키 + 8바이트 자식 포인터 기준으로 약 200개 키 수용 가능.
// PAGE_SIZE 4096, 키 4바이트, 자식 4바이트
// 내부 노드: 1 + 4 + num_keys*4 + (num_keys+1)*4 <= 4096
// num_keys <= 340
static constexpr size_t MAX_KEYS = 200; // 여유 있게
팁 4: WAL 그룹 커밋
여러 트랜잭션이 동시에 커밋할 때, WAL 쓰기를 묶어서 fsync 횟수를 줄입니다. 10ms 대기 후 또는 100개 엔트리 모이면 배치 flush.
팁 5: 프리페치
범위 스캔 시 다음 페이지를 미리 읽어 I/O 대기 시간을 숨깁니다. pager_.prefetch(next_leaf_page_id) 호출로 다음 리프를 비동기 로드합니다.
9. 프로덕션 패턴
패턴 1: 스레드 안전성
다중 스레드에서 Pager 접근 시 std::shared_mutex로 보호. get_page는 shared_lock, flush_page는 unique_lock 사용.
패턴 2: 체크포인트
WAL이 무한히 커지지 않도록 주기적으로 pager_.flush_all() 후 WAL 파일을 비웁니다. truncate로 WAL을 초기화하고 다시 app 모드로 열면 됩니다.
패턴 3: 헬스 체크
wal_size_bytes, cache_size, cache_hits, cache_misses를 추적하고 hit_ratio = cache_hits / (cache_hits + cache_misses)로 모니터링합니다.
패턴 4: 백업 및 복원
온라인 백업 시 WAL을 함께 복사합니다.
void backup(const std::string& backup_path) {
pager_.flush_all();
std::filesystem::copy(db_path_, backup_path,
std::filesystem::copy_options::overwrite_existing);
std::filesystem::copy(db_path_ + ".wal", backup_path + ".wal",
std::filesystem::copy_options::overwrite_existing);
}
패턴 5: 설정 외부화
struct DBConfig {
size_t page_size = 4096;
size_t max_cached_pages = 1000;
size_t wal_checkpoint_interval = 1000;
bool fsync_on_commit = true;
};
정리
| 컴포넌트 | 역할 |
|---|---|
| Pager | 페이지 기반 저장 |
| B-Tree | 인덱스 구조 |
| WAL | 트랜잭션 로그 |
| Parser | SQL 파싱 |
| Optimizer | 쿼리 최적화 |
핵심 원칙:
- 페이지 단위로 I/O 최소화
- B-Tree로 빠른 검색
- WAL로 ACID 보장
- 인덱스 활용으로 성능 향상
- 쿼리 최적화로 효율성 확보
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 임베디드 DB 개발, 특수 목적 DB 엔진, 교육용 DB 시스템, 인메모리 캐시 등에 활용합니다. DB 내부 동작을 이해하면 쿼리 최적화와 성능 튜닝에 큰 도움이 됩니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: 페이지 저장, B-Tree, WAL, SQL 파서로 간단한 DB 엔진을 구현할 수 있습니다.
다음 글: [C++ 실전 가이드 #51-1] 프로파일링 도구 마스터
이전 글: [C++ 실전 가이드 #50-3] 게임 엔진 기초
관련 글
- C++ DB 엔진 기초 완벽 가이드 | 저장 엔진·쿼리 파서·실행기·트랜잭션 실전 [#49-1]
- C++ 쿼리 최적화 완벽 가이드 | 인덱스 선택·실행 계획·통계·비용 모델·프로덕션 패턴 [#49-3]