[GlueSQL] Todo 앱으로 살펴본 GlueSQL: SQL 엔진과 Storage Adapter 구조

주재완·2026년 7월 9일

Database

목록 보기
7/9
post-thumbnail

개요

2026년 오픈소스 컨트리뷰션 아카데미에서 GlueSQL 프로젝트로 활동하기 전에, 문서만 읽는 것보다 작은 프로젝트를 하나 만들어보며 감을 잡아보고 싶었습니다. 그래서 이번에는 GlueSQL을 문서로만 읽지 않고, 작은 CLI Todo 앱을 바이브코딩으로 만들어보면서 실제로 어떤 느낌인지 확인해보기로 했습니다.

Todo 앱으로 가볍게 시작하기

물론 저는 이거 작성하는 시점 Rust를 아예 모릅니다.

??? : 코덱스 해줘.

물론 그렇게 말하고 손 놓고 구경만 한 것은 아니고, add, list, done, delete처럼 작은 기능부터 붙였습니다. 이후 search, stats, sql, repl을 차례로 추가했습니다. 기능 자체보다 저장소를 어떻게 바꿔볼지에 초점을 두고 진행했습니다.

Todo 앱의 저장 방식 바꾸기

메모리에서 JSON으로

처음에는 메모리 안의 Vec<Task>로 Todo를 관리했습니다. 이후 tasks.json을 읽고 쓰는 JsonTaskRepository를 추가했습니다. 그다음 TaskRepository trait와 TaskService를 추가해서, 명령 실행 코드와 저장소 코드를 분리했습니다. 이 구조 위에 나중에 GlueSqlTaskRepository를 붙였습니다.

pub trait TaskRepository {
    fn add(&mut self, title: String) -> Result<Task, AppError>;
    fn find_all(&mut self) -> Result<Vec<Task>, AppError>;
    fn mark_done(&mut self, id: i64) -> Result<(), AppError>;
    fn delete(&mut self, id: i64) -> Result<Task, AppError>;
}

JSON에서 GlueSQL로

그다음 GlueSqlTaskRepository를 추가했습니다. 처음에는 테스트하기 쉬운 MemoryStorage를 사용했고, Todo 추가와 조회는 내부에서 INSERT, SELECT를 실행하도록 바꿨습니다. 이후 완료 처리, 삭제, 검색, 통계도 UPDATE, DELETE, ILIKE, COUNT 같은 SQL로 처리했습니다.

fn add(&mut self, title: String) -> Result<Task, AppError> {
    let id = next_id(&self.find_all()?);
    let task = Task::new(id, title);
    let title = sql_string(&task.title);

    self.execute(format!(
        "INSERT INTO tasks VALUES ({}, {}, {});",
        task.id, title, task.done
    ))?;

    Ok(task)
}

SQL 직접 실행

Todo 명령만으로는 GlueSQL을 직접 만져보는 느낌이 조금 부족했습니다. 그래서 사용자가 SQL 문자열을 그대로 넘길 수 있는 sql 명령을 추가했습니다.

??? : 이거 SQL Injection 아님?

의도한겁니다. 이번에는 GlueSQL을 확인해보는 게 목적이라, 일부러 SQL을 그대로 넣을 수 있게 열어두었습니다. 운영 서비스였다면 절대 이렇게 열어두면 안 되지만, 지금은 SQL 엔진을 앱 안에 넣으면 어떤 느낌인지 확인하는 용도입니다.

예를 들어 cargo run -- sql 'SELECT * FROM tasks'처럼 실행하면, GlueSQL의 실행 결과를 SqlResult로 바꿔 터미널에 출력했습니다.

fn execute_sql(&mut self, sql: String) -> Result<Vec<SqlResult>, AppError> {
    let payloads = self.execute(sql)?;

    payloads.into_iter().map(payload_to_sql_result).collect()
}

SQL REPL

한 번 실행하고 끝나는 sql 명령 다음에는 repl 명령도 추가했습니다. cargo run -- repl로 들어가면 같은 실행 안에서 여러 SQL을 입력할 수 있게 했고, .schema, .exit, .quit 같은 간단한 명령도 붙였습니다. 그래서 REPL 안에서는 INSERT한 뒤 바로 SELECT로 결과를 봅니다.

rust-task> INSERT INTO tasks VALUES (1, 'Rust 공부', FALSE);
insert: 1
rust-task> SELECT id, title, done FROM tasks;
id | title | done
1 | Rust 공부 | false

GlueSQL을 DBMS 관점으로 보기

SQL 엔진과 Storage Engine

GlueSQL은 SQL Layer와 Storage Layer를 분리해서 다룹니다. SQL Parser, Planner, Executor는 사용자가 보낸 SQL을 해석하고 실행 계획을 만들며, Storage Engine은 실제 데이터 접근을 맡습니다.

일반 DBMS와 GlueSQL 구조 비교

PostgreSQL이나 MySQL에서는 이 구조가 DB 서버 내부에 있습니다. 애플리케이션은 SQL을 보내고 결과를 받지만, 내부에서는 Parsing, Planning, Execution, Buffer 접근, Storage Engine 호출이 이어집니다. GlueSQL은 그 SQL 실행 계층을 Rust 애플리케이션 안에서 라이브러리로 쓰게 해줍니다.

같은 Todo 테이블이라도 MemoryStorage를 붙이면 데이터가 실행 안에서만 남고, SledStorage를 붙이면 embedded key-value store 위에 데이터가 남습니다. SQL 인터페이스는 그대로 두고 저장 계층만 바뀌기 때문에, GlueSQL은 단순 embedded DB가 아니라 SQL Engine + Storage Adapter 구조의 형태를 띕니다.

SQLite와의 차이

GlueSQL과 SQLite는 겉으로 비슷해 보일 수 있습니다. 둘 다 애플리케이션 안에서 사용할 수 있고, 별도의 DB 서버를 띄우지 않아도 SQL을 실행할 수 있기 때문입니다. 하지만 코드를 놓고 보면 SQLite와 GlueSQL의 구현 방식은 다릅니다.

구분SQLiteGlueSQL
기본 성격완성된 embedded databaseSQL Engine + Storage Adapter Framework
저장 방식단일 DB 파일 중심Memory, Sled, JSON, CSV, MongoDB 등 storage 선택
SQL 엔진과 저장소하나의 제품으로 묶임SQL Layer와 Storage Layer 분리
앱에서 보는 형태SQLite 자체가 데이터베이스Glue::new(storage)로 엔진과 storage 연결
분석 기준파일 기반 내장 DB, 트랜잭션, 동시성 제어Storage별 기능 차이, adapter 구현, multi-storage 구성

SQLite는 SQL 엔진과 저장소가 함께 묶인 완성된 embedded database입니다. 파일 하나를 데이터베이스로 쓰고, 그 위에서 SQL, 인덱스, 트랜잭션, 동시성 제어를 하나의 제품 안에서 제공합니다.

반면 GlueSQL은 특정 저장 파일 하나를 중심으로 동작하는 DB가 아니라, 여러 Storage 위에 SQL 인터페이스를 얹는 구조입니다. 같은 SQL 엔진 아래에 MemoryStorage, SledStorage, JSON, CSV, MongoDB, IndexedDB 같은 저장소를 붙일 수 있습니다. SQLite가 내장형 DB라면, GlueSQL은 SQL Engine + Storage Adapter Framework입니다.

SQL 실행 과정

SELECT id, title, done FROM tasks WHERE done = false 같은 SQL을 기준으로 보면, GlueSQL도 일반적인 DBMS의 SQL 실행 과정을 따릅니다. SQL 문자열은 Parser를 지나 AST가 되고, Translator를 거쳐 Plan이 됩니다. Executor는 그 Plan을 실행하면서 Store trait을 통해 storage에서 row를 읽습니다.

GlueSQL SQL 실행 과정

이때 Parser와 Planner는 실제 데이터가 어디에 저장되어 있는지 몰라도 됩니다. 반대로 Storage는 SQL 문자열 전체를 이해하지 않아도 됩니다. 둘 사이를 Executor와 Store trait이 이어주기 때문에, SQL Layer와 Storage Layer를 나눠서 볼 수 있습니다.

Todo 앱 코드도 이 SQL 실행 과정을 그대로 따릅니다. find_allSELECT id, title, done FROM tasks ORDER BY id를 실행하고, searchWHERE title ILIKE ... 조건을 붙이며, statsCOUNT(*)를 실행합니다. GlueSQL이 반환한 PayloadValue는 다시 Task, TaskStats, SqlResult로 바꿉니다.

fn find_all(&mut self) -> Result<Vec<Task>, AppError> {
    self.select_tasks("SELECT id, title, done FROM tasks ORDER BY id;")
}

fn search(&mut self, keyword: &str) -> Result<Vec<Task>, AppError> {
    let pattern = sql_string(&format!("%{keyword}%"));

    self.select_tasks(format!(
        "SELECT id, title, done FROM tasks WHERE title ILIKE {} ORDER BY id;",
        pattern
    ))
}
fn row_to_task(row: Vec<Value>) -> Result<Task, AppError> {
    match row.as_slice() {
        [Value::I64(id), Value::Str(title), Value::Bool(done)] => Ok(Task {
            id: *id,
            title: title.clone(),
            done: *done,
        }),
        values => Err(AppError::GlueSql(format!(
            "expected task row [I64, Str, Bool], got {values:?}"
        ))),
    }
}

Storage Adapter의 의미

앱의 Repository와 GlueSQL의 Store

이 프로젝트에는 TaskRepository가 있고, GlueSQL에는 Store/StoreMut 계열 trait가 있습니다. 이름만 보면 둘 다 저장소 추상화처럼 보이지만, 바라보는 위치가 다릅니다. TaskRepository는 Todo 앱이 필요한 동작을 정의하고, GlueSQL의 Store trait는 SQL Executor가 storage에 요구하는 동작을 정의합니다.

현재 코드에서 GlueSqlTaskRepository<S>는 앱의 repository 역할과 GlueSQL 실행 객체를 함께 가집니다. 바깥쪽으로는 TaskRepository를 구현하고, 안쪽으로는 GlueSQL의 Glue<S>를 들고 있습니다. SJava의 제네릭 타입 파라미터처럼 storage 타입을 나타내지만, 아무 타입이나 들어갈 수는 없고 GStore + GStoreMut + Planner 조건을 만족해야 합니다.

pub struct GlueSqlTaskRepository<S = MemoryStorage>
where
    S: GStore + GStoreMut + Planner,
{
    glue: Glue<S>,
}

Storage별 역할 차이

현재 프로젝트에서는 직접 만든 JsonTaskRepository, 테스트용 MemoryStorage, 기본 CLI 저장소인 SledStorage를 사용했습니다. SQL 실행은 GlueSqlTaskRepository에서만 지원하고, transaction과 동시성 특성은 storage 구현에 따라 달라집니다. 여기에 GlueSQL이 제공하는 다른 storage까지 함께 놓고 보면 다음처럼 정리할 수 있습니다.

저장소현재 프로젝트에서의 위치영속성SQL 직접 실행확인 내용
JsonTaskRepository직접 만든 이전 저장소있음, tasks.json미지원앱 레벨 repository
MemoryStorageGlueSQL repository 테스트없음지원빠른 테스트, 명시 transaction 미지원
SledStorageCLI 기본 저장소있음, data/rust-task-db지원rollback, commit, snapshot, write lock
SharedMemoryStorage문서 기준 비교없음-공유 메모리 storage
JsonStorage문서 기준 비교있음-GlueSQL의 JSON storage
CsvStorage / ParquetStorage문서 기준 비교있음-파일 기반 storage
RedbStorage문서 기준 비교있음-embedded DB 기반 storage
MongoStorage문서 기준 비교외부 DB 의존-Document DB 위 SQL Layer
WebStorage / IdbStorage문서 기준 비교브라우저 저장소 의존-브라우저 storage 위 SQL Layer
CompositeStorage문서 기준 비교조합 대상에 따라 다름-여러 storage 조합

Transaction과 동시성

SharedMemoryStorage와 SledStorage

SharedMemoryStorageSledStorage의 차이는 데이터가 저장되는 위치만이 아닙니다. Transaction과 동시성 동작도 storage 구현체에 따라 달라집니다. GlueSQL core가 하나의 동시성 제어 방식을 강제한다기보다, storage가 어디까지 구현하느냐에 따라 달라집니다.

SharedMemoryStorage는 가장 단순한 동시 접근 모델을 보여줍니다. MemoryStorageArc<RwLock<MemoryStorage>>로 감싸서 여러 곳에서 clone해 쓸 수 있게 만든 형태입니다. 조회 계열은 read lock, 변경 계열은 write lock을 잡는 coarse-grained read/write lock 구조입니다.

반면 transaction까지 보려면 SledStorage 쪽을 봐야 합니다. 현재 테스트에서 MemoryStorage는 명시적 BEGIN을 지원하지 않고, SledStorage는 명시적 transaction을 처리합니다. 같은 GlueSQL SQL 엔진을 쓰더라도 storage가 다르면 동시성 모델이 달라집니다.

관찰 항목MemoryStorageSharedMemoryStorageSledStorage
명시적 BEGIN미지원미지원지원
동시 접근단일 테스트에 적합RwLock 기반 공유transaction 상태와 lock 메타데이터 사용
여러 reader확인 대상 아님가능snapshot 기준 읽기
writer 충돌확인 대상 아님write lock 대기database is locked
ROLLBACK확인 대상 아님미지원insert 취소
사용 위치단위 테스트동시 접근 참고용CLI 기본 저장소, transaction 테스트
#[test]
fn memory_storage_rejects_explicit_transactions() {
    let mut repository = GlueSqlTaskRepository::new().unwrap();

    let result = repository.execute_sql("BEGIN;".to_string());

    assert!(
        matches!(result, Err(AppError::GlueSql(message)) if message.contains("transaction is not supported"))
    );
}

COMMIT과 ROLLBACK

SledStorage에서는 BEGIN 이후 COMMIT하면 insert가 확정되고, ROLLBACK하면 사라집니다. SQL 문법이 같아도, storage가 해당 기능을 제공하지 않으면 동작이 달라집니다. 아래 코드는 ROLLBACK 이후 row가 남지 않는지 보는 테스트입니다.

repository
    .execute_sql(
        "
        BEGIN;
        INSERT INTO tasks VALUES (1, 'temporary', FALSE);
        ROLLBACK;
        "
        .to_string(),
    )
    .unwrap();

assert_eq!(repository.find_all(), Ok(Vec::new()));

Snapshot과 write lock

동시성에서는 reader transaction이 commit 전 snapshot을 유지합니다. writer가 새 row를 넣고 commit해도, 이미 열린 reader transaction은 기존 snapshot을 봅니다. 열린 writer transaction이 있을 때 다른 writer는 database is locked 에러를 받습니다.

SledStorage snapshot과 lock

SledStorage는 txid 기준으로 version을 관리합니다. 각 version은 created_by, deleted_by 같은 메타데이터를 갖고, 읽는 transaction의 txid 기준으로 보이는 version을 고릅니다. 현재 값 하나를 덮어쓰기가 아니라 여러 version 중 내 transaction에서 보이는 값 고르기입니다.

쓰기는 snapshot만으로 끝나지 않습니다. lock_txid 같은 lock 메타데이터로 writer 충돌을 막고, 다른 writer가 들어오면 database is locked로 실패합니다. 즉 읽기 일관성은 snapshot/txid로 보고, 쓰기 진행 제어는 lock까지 함께 봐야 합니다.

reader.execute_sql("BEGIN;".to_string()).unwrap();
writer
    .execute_sql(
        "
        BEGIN;
        INSERT INTO tasks VALUES (2, 'after', FALSE);
        COMMIT;
        "
        .to_string(),
    )
    .unwrap();

assert_eq!(
    reader.find_all(),
    Ok(vec![Task::new(1, "before".to_string())])
);

InnoDB와 닮은 점과 다른 점

SledStorage의 snapshot 구조는 MySQL InnoDB의 MVCC와 비교해볼 수 있습니다. 둘 다 transaction id를 기준으로 version visibility를 판단하고, reader가 자기 시점의 일관된 데이터를 보게 한다는 점에서는 비슷합니다. created_by, deleted_by를 보고 보이는 version을 고르는 방식도 MVCC와 비교할 수 있습니다.

하지만 구현 방식은 다릅니다. InnoDB는 row의 transaction id와 rollback pointer, undo log를 이용해 과거 version을 재구성합니다. 반면 GlueSQL SledStorage는 snapshot item 목록에 version과 메타데이터를 직접 관리합니다.

구분MySQL InnoDBGlueSQL SledStorage
version 관리row trx id + rollback pointer + undo logsnapshot item + created_by + deleted_by
일관 읽기ReadView 기준 consistent readtxid 기준 visible version 선택
write 제어row lock, gap lock, next-key lock 등lock_txid 기반 writer 제어
복구/로그redo log, undo log, purge 등 포함storage 구현 범위 안에서 단순화

Query 처리와 Pushdown

Predicate 처리

Predicate는 WHERE에 들어가는 조건이라고 보면 됩니다. WHERE title ILIKE '%rust%', WHERE done = false, WHERE id > 10 같은 조건이 모두 predicate입니다. Todo 앱에서는 searchWHERE title ILIKE ...를 사용합니다.

여기서 중요한 건 WHERE 조건을 누가 처리하느냐입니다. GlueSQL이 storage에서 row를 읽어온 뒤 title ILIKE ...를 직접 검사할 수도 있고, storage가 처리할 수 있는 조건이라면 아예 storage에 맡길 수도 있습니다. 이렇게 조건을 아래 storage 쪽으로 넘기는 방식을 pushdown이라고 부릅니다.

이번 프로젝트에서 pushdown을 직접 구현하지는 않았습니다. 그래도 GlueSQL처럼 여러 storage를 붙이는 엔진에서는 조건을 어디에서 처리할지 정해야 합니다. MongoDB 같은 외부 storage라면 어떤 조건을 MongoDB query로 넘기고, 어떤 조건을 GlueSQL Executor에서 처리할지 나눠야 합니다.

MemoryStorageSledStorage만 보면 이 차이가 크게 드러나지 않습니다. 하지만 JsonStorage, MongoStorage, CompositeStorage까지 놓고 보면 predicate 처리 위치가 성능 차이로 이어질 수 있습니다. 같은 WHERE 조건이라도 storage가 먼저 걸러주는지, GlueSQL이 나중에 걸러주는지에 따라 읽어야 하는 row 수가 달라집니다.

처리 방식의미비용이 생기는 부분
Executor filteringstorage에서 row를 읽은 뒤 GlueSQL이 조건 평가읽어온 row가 많을수록 비용 증가
Predicate pushdownstorage가 처리할 수 있는 조건을 먼저 적용storage query 변환과 기능 지원 여부가 중요
Index accessstorage index를 이용해 필요한 row만 접근index trait와 storage 구현에 의존
Remote delegationMongoDB 같은 외부 storage에 query 일부 위임네트워크, 원격 query planner, 결과 매핑 비용 발생

Index와 Transaction

SQL 기능은 Parser와 Executor만으로 끝나지 않습니다. index, transaction, lock 같은 기능은 storage가 어디까지 받쳐주느냐에 따라 달라집니다. GlueSQL에서 모든 storage가 같은 동작을 한다고 보면 안 됩니다.

예를 들어 MemoryStorage는 빠른 테스트에 맞지만 transaction 검증에는 한계가 있습니다. 반대로 SledStorage는 embedded key-value store 기반이라 영속성과 transaction 동작을 확인할 수 있습니다. 같은 SQL을 실행해도 storage에 따라 결과와 에러가 달라질 수 있습니다.

그래서 GlueSQL의 SQL 지원 범위는 문법만 보고 판단하기 어렵습니다. SQL Parser가 받아들이는 문장, Executor가 처리하는 연산, Storage가 제공하는 기능을 나눠서 봐야 합니다. SQL 문법이 된다고 해서 모든 storage에서 같은 의미로 동작한다고 볼 수는 없습니다.

인덱스도 같은 이유로 storage와 분리해서 봐야 합니다. SQL 입장에서는 WHERE id = 1이 단순한 조건이지만, storage 입장에서는 full scan으로 찾을 수도 있고 index로 바로 찾을 수도 있습니다. GlueSQL을 분석할 때는 SQL 문법보다 그 문법이 어떤 storage 기능과 연결되는지를 봐야 합니다.

여러 Storage를 하나의 SQL로 보기

Composite Storage와 JOIN

CompositeStorage서로 다른 storage를 하나의 SQL 인터페이스로 묶는 구성을 보여줍니다. RDB, JSON, MongoDB 같은 데이터 소스를 SQL로 함께 다룰 수 있다면, GlueSQL은 단일 저장소보다 SQL 실행 계층의 성격이 강해집니다.

물론 이번 Todo 프로젝트에서 CompositeStorage를 직접 사용하지는 않았습니다. 현재 코드에 연결된 것은 MemoryStorageSledStorage이고, CompositeStorage문서 기준으로만 다룹니다.

SQL과 NoSQL을 완전히 다른 세계로만 보면 이런 구성이 어색할 수 있습니다. GlueSQL은 storage의 물리 구조를 그대로 두고, 그 위에 SQL layer를 얹습니다. 그렇게 보면 MongoDB나 IndexedDB 같은 storage도 SQL 실행 계층 아래에 놓고 볼 수 있습니다.

Schemaful과 Schemaless

GlueSQL이 multi-model database engine이라고 불리는 이유도 여기와 연결됩니다. 정해진 schema를 가진 테이블만 다루는 것이 아니라, schema가 느슨한 데이터 모델도 SQL 실행 계층 아래에 놓을 수 있습니다. JSON이나 MongoDB 같은 storage가 여기에 해당합니다.

일반적인 RDB 테이블은 컬럼과 타입이 먼저 정해져 있고, row는 그 schema를 따릅니다. 반대로 document 기반 storage는 row마다 필드 구성이 달라질 수 있습니다. GlueSQL은 storage 차이를 유지한 채, 그 위에서 SQL로 접근할 수 있는 공통 실행 계층을 제공합니다.

이때 기준은 단순히 SELECT가 되느냐가 아닙니다. 없는 컬럼을 읽으면 어떻게 되는지, 타입이 다른 값을 비교하면 어디에서 에러가 나는지, schema가 있는 테이블과 없는 데이터를 JOIN하면 결과 컬럼을 어떻게 맞추는지를 봐야 합니다.

Storage Adapter 분석

SQL 엔진이 storage에 요구하는 것

GlueSQL에서 storage adapter를 본다는 것은 SQL 엔진이 storage에 무엇을 요구하는지 보는 일입니다. TaskRepository가 Todo 앱을 위한 추상화라면, Store/StoreMut은 SQL 엔진을 위한 추상화입니다. Storage adapter는 SQL 문법을 새로 만드는 것이 아니라, Executor가 요구하는 schema와 row 읽기/쓰기를 제공합니다.

GlueSQL Storage Adapter 책임

읽기 전용 storage라면 SELECT에 필요한 schema 조회와 row scan이 중심이 됩니다. 쓰기까지 지원하려면 INSERT, UPDATE, DELETE가 변경할 row와 schema를 다룰 수 있어야 합니다. 여기에 index나 transaction을 붙이면 storage가 담당해야 할 범위가 더 넓어집니다.

책임storage가 제공하는 것SQL에서 드러나는 부분
Row 조회table scan, key 기반 row 조회SELECT, WHERE
Row 변경insert, update, deleteINSERT, UPDATE, DELETE
Schema 관리table/column 정보 저장CREATE TABLE, schema 조회
Index 지원특정 key나 조건으로 row 접근WHERE, 정렬, 탐색 비용
Transaction 지원commit, rollback, lock, snapshotBEGIN, COMMIT, ROLLBACK

Storage마다 달라지는 DB의 성격

같은 SQL 엔진을 쓰더라도 storage가 바뀌면 DB의 성격도 바뀝니다. MemoryStorage는 빠른 검증에 맞고, SledStorage는 로컬 영속성과 transaction 확인에 맞습니다. 외부 storage를 붙이면 네트워크, 원격 DB의 query 모델, 에러 처리까지 함께 다뤄야 합니다.

GlueSQL은 하나의 완성된 DB 제품이라기보다, SQL 엔진을 공통으로 두고 storage별 DB 특성을 조합하는 구조입니다. Todo 앱에서는 저장소를 바꿔 붙이는 과정에서 이 차이를 확인할 수 있었습니다.

profile
데이터베이스, 트랜잭션 구조 설계에 관심이 많은 백엔드 개발자입니다.

0개의 댓글